Compare commits

...
Author SHA1 Message Date
Lam Wei Lun 78e5ecdcd7 WIP fix for filament type syncing 2026-09-25 19:44:02 +08:00
Lam Wei Lun 5f589ff909 Fix broken editing of filament in device 2026-09-25 11:54:34 +08:00
Lam Wei Lun d69d14d6f8 Fixes issue where it would display a question mark instead of empty when there's an empty filament slot in the AMS or external slot 2026-09-25 11:05:51 +08:00
Lam Wei Lun d3003dfa5e fix(orca-agent): retain virtual trays and preserve external AMS ids
External spool ids 254/255 keep their identity through filament changes and RFID refresh
Orca virtual trays persist across push_status snapshots and reset on reconnect
AMS writes gate on the device's declared FMS and filament-slot capabilities
2026-09-24 23:49:30 +08:00
Lam Wei Lun 37b7e70490 Merge impl branch 2026-09-24 14:46:30 +08:00
Lam Wei Lun aa934409a7 feat(orca-agent): integrate OrcaSonar AMS synchronization
- Translate slicer AMS commands to OrcaSonar’s canonical write format
- Gate AMS operations using printer capabilities
- Refresh capabilities when filament topology becomes available
- Apply virtual-slot removals only from full status snapshots
- Add synchronization tests and document the integration
2026-09-24 12:34:51 +08:00
Lam Wei Lun 4cc23e6492 fix: send the AMS tray's own coordinates on tray selection
command_ams_select_tray split the BBL tray id into ams_id/slot_id inline and untested. Extract build_ams_change_filament_body so the mapping is a named unit that command_ams_select_tray routes verbatim, and lock it with a test: tray 9 (a 6-slot box's slot 5) sends (2, 1), never a fabricated flat lane.
2026-09-23 17:00:39 +08:00
Lam Wei Lun 6849979d60 Initial implementation of filament mapping on OrcaSonar side 2026-09-23 15:34:02 +08:00
Lam Wei Lun a9d145cab9 Merge branch 'feat/printer-agent-impl' into feat/orca_printer_agent_ams_sync 2026-09-22 15:54:35 +08:00
Lam Wei Lun 5a4fcafb5b Bunch of dedupes 2026-09-21 18:43:08 +08:00
Lam Wei Lun 6fdf697bc7 Safety checks for GUI 2026-09-21 18:15:29 +08:00
Lam Wei Lun ad440edfd8 Merge 2026-09-21 17:01:46 +08:00
Lam Wei Lun 6f5856f06c Initial Commit for OrcaPrinterAgent AMS Sync 2026-09-18 15:46:35 +08:00
40 changed files with 3108 additions and 952 deletions
+42
View File
@@ -152,6 +152,44 @@ The methods remain on the common interface so an agent that supports them can ov
`sequence_id` remains part of the command contract because `DeviceManager` creates and tracks it as
the command ID.
## Filament and AMS synchronization
The `OrcaPrinterAgent` bridges OrcaSonar's filament/AMS state. Its capability reply
carries two independent signals: `protocol.features.fms` means a material system resolves,
and `protocol.features.filament_slots` means the connector owns a filament-slot model that
persists without any material hardware. Either one enables filament synchronization, so a
printer with slots but no AMS still exposes the slot workflow. The agent also caches
`protocol.ams_ops`, the canonical material write operations the resolved drivers implement,
and uses it to gate AMS writes; `print.ams_filament_setting` is exempt because it persists
connector state and is advertised by `filament_slots` alone. A write whose operation is not
declared is refused with `CAP_NOT_AVAILABLE` before it reaches the wire.
Printer state arrives as the pushed `ams`/`vir_slot` projection inside `push_status`; the
Orca agent does not poll the Moonraker `lane_data` namespace, which exists for
Moonraker-channel consumers. Slot presence is the user's declaration, not sensed material:
`tray_exist_bits` and the presence of a `vir_slot` entry mark a slot as present even with an
empty `tray_type`, which is the `is_exists` state the filament UI reads. A full status frame
(`msg=0`, or a LAN frame with no `msg`) updates each virtual-tray ID it contains but does not
remove IDs already observed during the current connection. An observed ID remains if omitted
from a populated `vir_slot`, if `vir_slot` is empty, or if the field is absent. Before any
virtual tray has been observed, a full frame with no virtual-tray entries clears the initial
placeholder. `MachineObject::reset()` clears retained IDs on reconnect. Delta frames update
named entries only. This retention rule is specific to the native Orca agent.
Writes follow the same edge-translation rule as the rest of the agent. The shared
`MachineObject` command builders emit Bambu-shaped `print.ams_*` payloads, and the agent's
single send funnel decodes them into OrcaSonar's canonical bodies — `selector` with a flat
lane or `ams_id`/`slot_id` coordinates for `ams_change_filament`, coordinates for
`ams_filament_setting` — before publishing. The server resolves and rejects with its own
`errno` backstop; the agent only withholds operations the device did not declare.
A filament frame can arrive before the capability reply that decides synchronization mode,
for example when the topology bootstraps after connect or Klipper restarts. While a device's
capabilities are still unknown, the agent re-requests `get_capabilities` and a full status on
a filament frame, throttled per device, and stops once an answer arrives. Dedicated
capability state is cleared when a cloud device is deselected, while an independently active
LAN session for the same device id keeps its declaration.
## Device ownership and stale responses
Printer-agent ownership is represented by `printer_agent_id` on device records and `MachineObject`
@@ -233,6 +271,10 @@ inside the Bambu agent and the Orca agent's v1 sink adapter can be removed as a
cloud camera contract
- [`NetworkAgent`](../../src/slic3r/Utils/NetworkAgent.hpp) — façade and dispatch between active agents
- [`NetworkAgentFactory`](../../src/slic3r/Utils/NetworkAgentFactory.hpp) — built-in and Python agent registry
- [`OrcaPrinterAgent`](../../src/slic3r/Utils/OrcaPrinterAgent.hpp) — OrcaSonar transport, capability
discovery, and Bambu-to-canonical command translation
- [`AmsPayload`](../../src/slic3r/Utils/AmsPayload.hpp) — shared filament payload rendering and the
per-device AMS capability cache
- [`DeviceManager`](../../src/slic3r/GUI/DeviceCore/DevManager.cpp) — device ownership, filtering, and
stale-response checks
- [`PrinterAgentPluginCapability`](../../src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp)
+6 -6
View File
@@ -24,7 +24,7 @@ NAMESPACE = "lane_data"
# Repo-relative paths for the offline generic-map check
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MOONRAKER_AGENT_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "MoonrakerPrinterAgent.cpp")
AMS_PAYLOAD_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "AmsPayload.cpp")
OFL_FILAMENT_DIR = os.path.join(REPO_ROOT, "resources", "profiles", "OrcaFilamentLibrary", "filament")
LANE_KEYS = [f"lane{i}" for i in range(1, 9)] # lane1-lane8
MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""]
@@ -41,16 +41,16 @@ MATERIAL_TEMPS = {
}
def parse_cpp_type_map():
"""Extract the normalized-type -> OFL generic family table from MoonrakerPrinterAgent.cpp.
"""Extract the normalized-type -> OFL generic family table from AmsPayload.cpp.
Reads MoonrakerPrinterAgent::map_filament_type_to_generic_id's type_to_ofl_family
initializer so the check tracks the C++ normalization without a duplicated list.
Reads map_filament_type_to_generic_id's type_to_ofl_family initializer so the
check tracks the C++ normalization without a duplicated list.
"""
with open(MOONRAKER_AGENT_CPP, encoding="utf-8") as f:
with open(AMS_PAYLOAD_CPP, encoding="utf-8") as f:
src = f.read()
m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL)
if not m:
raise RuntimeError(f"type_to_ofl_family table not found in {MOONRAKER_AGENT_CPP}")
raise RuntimeError(f"type_to_ofl_family table not found in {AMS_PAYLOAD_CPP}")
pairs = re.findall(r'\{\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\}', m.group(1))
if not pairs:
raise RuntimeError("type_to_ofl_family table parsed empty")
+19 -7
View File
@@ -3456,7 +3456,7 @@ size_t PresetCollection::first_visible_idx() const
return first_visible;
}
size_t PresetCollection::first_visible_idx_by_type(const std::string& filament_type) const
size_t PresetCollection::first_matching_filament_idx(const std::string& filament_type) const
{
size_t start = m_default_suppressed ? m_num_default_presets : 0;
@@ -3481,14 +3481,17 @@ size_t PresetCollection::first_visible_idx_by_type(const std::string& filament_t
// e.g. "PLA High Speed" -> "PLA"
// Dash-separated types like "PA-CF", "PET-CF" are distinct materials, not modifiers.
auto sep = filament_type.find(' ');
if (sep != std::string::npos) {
idx = find_by_type(filament_type.substr(0, sep));
if (idx != size_t(-1))
return idx;
}
if (sep != std::string::npos)
return find_by_type(filament_type.substr(0, sep));
return size_t(-1);
}
size_t PresetCollection::first_visible_idx_by_type(const std::string& filament_type) const
{
size_t idx = first_matching_filament_idx(filament_type);
// 3. Any visible preset
return first_visible_idx();
return idx != size_t(-1) ? idx : first_visible_idx();
}
std::string PresetCollection::filament_id_by_type(const std::string& filament_type) const
@@ -3496,6 +3499,15 @@ std::string PresetCollection::filament_id_by_type(const std::string& filament_ty
return preset(first_visible_idx_by_type(filament_type)).filament_id;
}
bool PresetCollection::filament_id_by_type(const std::string& filament_type, std::string& out) const
{
size_t idx = first_matching_filament_idx(filament_type);
if (idx == size_t(-1))
return false;
out = preset(idx).filament_id;
return true;
}
std::vector<std::string> PresetCollection::diameters_of_selected_printer()
{
std::set<std::string> diameters;
+9
View File
@@ -756,6 +756,11 @@ public:
size_t first_visible_idx_by_type(const std::string& filament_type) const;
// Return the filament_id of the best-matching visible preset for the given filament type.
std::string filament_id_by_type(const std::string& filament_type) const;
// As above, but returns false (and leaves `out` untouched) when no preset
// matches instead of falling back to first_visible_idx(). The fallback is a
// plain first-visible/PLA preset, which would bind an unknown material to an
// unrelated default.
bool filament_id_by_type(const std::string& filament_type, std::string& out) const;
// Return index of the first compatible preset. Certainly at least the '- default -' preset shall be compatible.
// If one of the prefered_alternates is compatible, select it.
template<typename PreferedCondition> size_t first_compatible_idx(PreferedCondition prefered_condition) const
@@ -889,6 +894,10 @@ protected:
void set_custom_preset_alias(Preset &preset);
private:
// Index of the first visible, compatible, system base preset matching
// filament_type (exact, then base type), or (size_t)-1 when none matches.
size_t first_matching_filament_idx(const std::string& filament_type) const;
std::string canonical_preset_name(const std::string &name, const PresetOrigin &load_origin = PresetOrigin()) const;
// Comparator that sorts "Generic " prefixed presets before others, then alphabetically within each group.
+13 -3
View File
@@ -4255,9 +4255,19 @@ std::vector<std::vector<DynamicPrintConfig>> PresetBundle::get_extruder_filament
return filament_infos;
}
// ORCA TODO: currently, this function assumes the printer name follows the pattern of "<printer_model> <nozzle_diameter>", e.g.
// printer_type: "Bambu Lab X2D", nozzle_diameter_str: "0.4 nozzle" => printer_name: "Bambu Lab X2D 0.4 nozzle". If the printer name does
// not follow this pattern, the function may not work correctly.
std::string PresetBundle::get_printer_model_display_name(const std::string &model_id) const
{
for (const auto &vendor_entry : vendors) {
for (const auto &model : vendor_entry.second.models) {
if (model.model_id == model_id)
return model.name;
}
}
return {};
}
// ORCA TODO: this assumes printer names follow "<printer_model> <nozzle_diameter>", e.g.
// "Bambu Lab X2D 0.4 nozzle". Other naming schemes may not resolve correctly.
std::set<std::string> PresetBundle::get_printer_names_by_printer_type_and_nozzle(const std::string &printer_type, std::string nozzle_diameter_str, bool system_only)
{
std::set<std::string> printer_names;
+1
View File
@@ -391,6 +391,7 @@ public:
std::vector<std::vector<DynamicPrintConfig>> get_extruder_filament_info() const;
std::string get_printer_model_display_name(const std::string &model_id) const;
std::set<std::string> get_printer_names_by_printer_type_and_nozzle(const std::string &printer_type, std::string nozzle_diameter_str, bool system_only = true);
// Orca: the root filament presets a connected machine can use, resolved with the rule the rest
// of the app applies (is_compatible_with_printer): an empty compatible_printers means every
+2
View File
@@ -758,6 +758,8 @@ set(SLIC3R_GUI_SOURCES
Utils/OrcaMqttConnection.hpp
Utils/OrcaPrinterAgent.cpp
Utils/OrcaPrinterAgent.hpp
Utils/AmsPayload.cpp
Utils/AmsPayload.hpp
Utils/OrcaCloudSignalingChannel.cpp
Utils/OrcaCloudSignalingChannel.hpp
Utils/QidiPrinterAgent.cpp
+116 -56
View File
@@ -4,6 +4,7 @@
#include "GUI_App.hpp"
#include "GUI.hpp"
#include "slic3r/Utils/NetworkAgent.hpp"
#include "slic3r/Utils/AmsPayload.hpp"
#include "libslic3r/Preset.hpp"
#include "I18N.hpp"
#include <algorithm>
@@ -146,6 +147,7 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent)
#else
m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY);
#endif
m_comboBox_filament->SetClientData(&m_comboBox_from_printer);
m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0);
@@ -431,6 +433,10 @@ void AMSMaterialsSetting::update_filament_editing(bool is_printing)
m_button_confirm->Show(true);
}
const bool can_edit = !is_printing || obj->is_support_filament_setting_inprinting;
m_input_nozzle_min->Enable(m_is_third && can_edit);
m_input_nozzle_max->Enable(m_is_third && can_edit);
if (!m_is_third) {
m_tip_readonly->SetLabelText(wxEmptyString);
m_tip_readonly->Hide();
@@ -453,6 +459,8 @@ void AMSMaterialsSetting::update_filament_editing(bool is_printing)
if (m_view_only) { // Orca: view-only (2D laser/cut) — lock every edit control and hide apply/reset
m_comboBox_filament->Enable(false);
m_comboBox_cali_result->Enable(false);
m_input_nozzle_min->Enable(false);
m_input_nozzle_max->Enable(false);
m_input_k_val->Enable(false);
m_input_n_val->Enable(false);
m_button_confirm->Hide();
@@ -477,10 +485,7 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) {
m_filament_type = "";
long nozzle_temp_min_int = 0;
long nozzle_temp_max_int = 0;
wxColour color = *wxWHITE;
char col_buf[10];
sprintf(col_buf, "%02X%02X%02X00", (int)color.Red(), (int)color.Green(), (int)color.Blue());
std::string color_str; // reset use empty string
std::string color_str; // empty; command_ams_filament_settings maps it to the empty-tray color
std::string selected_ams_id;
PresetBundle *preset_bundle = wxGetApp().preset_bundle;
@@ -497,7 +502,7 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) {
if (obj) {
if(m_is_third){
obj->command_ams_filament_settings(ams_id, slot_id, ams_filament_id, ams_setting_id, std::string(col_buf), m_filament_type, nozzle_temp_min_int,
obj->command_ams_filament_settings(ams_id, slot_id, ams_filament_id, ams_setting_id, color_str, m_filament_type, nozzle_temp_min_int,
nozzle_temp_max_int);
}
@@ -668,7 +673,7 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
wxString nozzle_temp_max = m_input_nozzle_max->GetTextCtrl()->GetValue();
long nozzle_temp_min_int, nozzle_temp_max_int;
long nozzle_temp_min_int = 0, nozzle_temp_max_int = 0;
nozzle_temp_min.ToLong(&nozzle_temp_min_int);
nozzle_temp_max.ToLong(&nozzle_temp_max_int);
wxColour color = m_clr_picker->m_colour;
@@ -872,12 +877,22 @@ void AMSMaterialsSetting::on_clr_picker(wxMouseEvent &event)
}
std::vector<wxColour> ams_colors;
obj->GetFilaSystem()->CollectAmsColors(ams_colors);
// A fixed-vocabulary printer can only show its own palette; anything else
// would offer a color its screen cannot hold.
const FilamentMetadataValues metadata = filament_metadata_values(obj->get_dev_id());
const bool constrained = metadata.constrained && !metadata.colors.empty();
if (constrained) {
for (const std::string& color : metadata.colors)
ams_colors.push_back(DevAmsTray::decode_color(color));
} else {
obj->GetFilaSystem()->CollectAmsColors(ams_colors);
}
wxPoint img_pos = m_clr_picker->ClientToScreen(wxPoint(0, 0));
wxPoint popup_pos(img_pos.x - m_color_picker_popup.GetSize().x - FromDIP(95), img_pos.y - FromDIP(65));
m_color_picker_popup.Position(popup_pos, wxSize(0, 0));
m_color_picker_popup.set_ams_colours(ams_colors);
m_color_picker_popup.set_constrained(constrained);
m_color_picker_popup.set_def_colour(m_clr_picker->m_colour);
m_color_picker_popup.Popup();
}
@@ -940,6 +955,21 @@ static void _collect_filament_info(const wxString& shown_name,
query_filament_types[shown_name] = filament.config.get_filament_type();
}
static std::string sGetFilamentPrinterModel(MachineObject *obj, PresetBundle *preset_bundle)
{
if (!obj || !preset_bundle)
return {};
const wxString device_model = obj->get_printer_type_display_str();
if (!device_model.empty() && device_model != "OrcaSonar Printer" && device_model != _L("Unknown"))
return device_model.ToStdString();
// OrcaSonar's model ID is optional; use the selected profile when it is generic.
const ConfigOption *opt = preset_bundle->printers.get_selected_preset().config.option("printer_model");
const auto *model = dynamic_cast<const ConfigOptionString *>(opt);
return model ? model->value : std::string();
}
void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_min, wxString temp_max, wxString k, wxString n)
{
if (!obj) return;
@@ -952,6 +982,8 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
m_input_k_val->GetTextCtrl()->SetValue(k);
m_input_n_val->GetTextCtrl()->SetValue(n);
m_input_nozzle_min->GetTextCtrl()->SetValue(wxEmptyString);
m_input_nozzle_max->GetTextCtrl()->SetValue(wxEmptyString);
wxArrayString filament_items;
wxString bambu_filament_name;
@@ -965,9 +997,7 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
map_filament_items.clear();
PresetBundle * preset_bundle = wxGetApp().preset_bundle;
std::ostringstream stream;
// Defensive: this dialog is opened only from StatusPanel (BBL-only) today, so the fallback fires
// only during the brief BBL startup window before firmware reports nozzle info. Without this,
// the "0.0" lookup string returns an empty set and the filament dropdown goes blank.
// Use the selected profile's nozzle diameter until the connected device reports one.
float machine_diameter = obj->GetExtderSystem()->GetNozzleDiameter(0);
if (machine_diameter == 0.0f && preset_bundle) {
const ConfigOption *opt = preset_bundle->printers.get_selected_preset().config.option("nozzle_diameter");
@@ -975,19 +1005,24 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
}
stream << std::fixed << std::setprecision(1) << machine_diameter;
std::string nozzle_diameter_str = stream.str();
const std::string filament_printer_model = sGetFilamentPrinterModel(obj, preset_bundle);
if (preset_bundle) {
if (preset_bundle && !filament_printer_model.empty()) {
BOOST_LOG_TRIVIAL(trace) << "system_preset_bundle filament number=" << preset_bundle->filaments.size();
for (Preset *filament_it : preset_bundle->get_filament_presets_for_machine(
DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) {
filament_printer_model, nozzle_diameter_str, obj->is_support_user_preset)) {
if (!filament_id_set.insert(filament_it->filament_id).second)
continue;
const std::string alias = preset_bundle->filaments.get_preset_alias(*filament_it, true);
if (alias.empty())
continue;
filament_items.push_back(alias);
_collect_filament_info(alias, *filament_it, query_filament_vendors, query_filament_types);
// A printer with a fixed vocabulary only offers the types it can
// hold, so an incompatible preset never reaches the list.
if (!filament_material_compatible(obj->get_dev_id(), query_filament_types[alias].ToStdString()))
continue;
filament_items.push_back(alias);
FilamentInfos filament_infos;
filament_infos.filament_id = filament_it->filament_id;
@@ -1045,6 +1080,10 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
else {
m_comboBox_filament->Show();
m_readonly_filament->Hide();
if (!temp_min.IsEmpty() && temp_min != "0")
m_input_nozzle_min->GetTextCtrl()->SetValue(temp_min);
if (!temp_max.IsEmpty() && temp_max != "0")
m_input_nozzle_max->GetTextCtrl()->SetValue(temp_max);
}
if (obj->cali_version >= 0) {
@@ -1182,6 +1221,7 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
}
m_comboBox_filament->Set(filament_items);
m_comboBox_from_printer = 1;
m_comboBox_filament->SetSelection(selection_idx);
post_select_event(selection_idx);
@@ -1189,9 +1229,6 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi
m_comboBox_filament->SetValue(wxEmptyString);
}
// Set the flag whether to open the filament setting dialog from the device page
m_comboBox_filament->SetClientData(new int(1));
update();
Layout();
Fit();
@@ -1221,16 +1258,14 @@ void AMSMaterialsSetting::on_select_cali_result(wxCommandEvent &evt)
void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
{
// Get the flag whether to open the filament setting dialog from the device page
int* from_printer = static_cast<int*>(m_comboBox_filament->GetClientData());
const bool initial_printer_selection = m_comboBox_from_printer == 1;
m_filament_type = "";
PresetBundle* preset_bundle = wxGetApp().preset_bundle;
if (preset_bundle) {
std::ostringstream stream;
if (obj) {
// Defensive: this dialog is opened only from StatusPanel (BBL-only) today, so the fallback fires
// only during the brief BBL startup window before firmware reports nozzle info. Without this,
// the "0.0" lookup string returns an empty set and filament lookup yields no results.
// Use the selected profile's nozzle diameter until the connected device reports one.
float machine_diameter = obj->GetExtderSystem()->GetNozzleDiameter(0);
if (machine_diameter == 0.0f) {
const ConfigOption *opt = preset_bundle->printers.get_selected_preset().config.option("nozzle_diameter");
@@ -1239,30 +1274,32 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
stream << std::fixed << std::setprecision(1) << machine_diameter;
}
std::string nozzle_diameter_str = stream.str();
const std::string filament_printer_model = sGetFilamentPrinterModel(obj, preset_bundle);
// Resolve the selection against the same list Popup() built the dropdown from, so the two
// halves of the dialog cannot disagree about which filaments this machine can use.
const std::string selected = m_comboBox_filament->GetValue().ToStdString();
if (!selected.empty()) {
if (!selected.empty() && !filament_printer_model.empty()) {
const std::string filament_id = map_filament_items[selected].filament_id;
for (Preset *it : preset_bundle->get_filament_presets_for_machine(
DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str, obj->is_support_user_preset)) {
filament_printer_model, nozzle_diameter_str, obj->is_support_user_preset)) {
if (it->filament_id != filament_id)
continue;
// ) if nozzle_temperature_range is found
ConfigOption* opt_min = it->config.option("nozzle_temperature_range_low");
if (opt_min) {
ConfigOptionInts* opt_min_ints = dynamic_cast<ConfigOptionInts*>(opt_min);
if (opt_min_ints) {
wxString text_nozzle_temp_min = wxString::Format("%d", opt_min_ints->get_at(0));
m_input_nozzle_min->GetTextCtrl()->SetValue(text_nozzle_temp_min);
if (!initial_printer_selection) {
ConfigOption* opt_min = it->config.option("nozzle_temperature_range_low");
if (opt_min) {
ConfigOptionInts* opt_min_ints = dynamic_cast<ConfigOptionInts*>(opt_min);
if (opt_min_ints) {
wxString text_nozzle_temp_min = wxString::Format("%d", opt_min_ints->get_at(0));
m_input_nozzle_min->GetTextCtrl()->SetValue(text_nozzle_temp_min);
}
}
}
ConfigOption* opt_max = it->config.option("nozzle_temperature_range_high");
if (opt_max) {
ConfigOptionInts* opt_max_ints = dynamic_cast<ConfigOptionInts*>(opt_max);
if (opt_max_ints) {
wxString text_nozzle_temp_max = wxString::Format("%d", opt_max_ints->get_at(0));
m_input_nozzle_max->GetTextCtrl()->SetValue(text_nozzle_temp_max);
ConfigOption* opt_max = it->config.option("nozzle_temperature_range_high");
if (opt_max) {
ConfigOptionInts* opt_max_ints = dynamic_cast<ConfigOptionInts*>(opt_max);
if (opt_max_ints) {
wxString text_nozzle_temp_max = wxString::Format("%d", opt_max_ints->get_at(0));
m_input_nozzle_max->GetTextCtrl()->SetValue(text_nozzle_temp_max);
}
}
}
ConfigOption* opt_type = it->config.option("filament_type");
@@ -1301,13 +1338,16 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
m_button_confirm->Disable(); // ORCA No need to change style
m_comboBox_cali_result->Clear();
m_comboBox_cali_result->SetValue(wxEmptyString);
m_input_k_val->GetTextCtrl()->SetValue(wxEmptyString);
m_input_n_val->GetTextCtrl()->SetValue(wxEmptyString);
m_comboBox_filament->SetClientData(new int(0));
if (!initial_printer_selection) {
m_input_k_val->GetTextCtrl()->SetValue(wxEmptyString);
m_input_n_val->GetTextCtrl()->SetValue(wxEmptyString);
}
m_comboBox_from_printer = 0;
return;
}
else {
m_button_confirm->Enable(true); // ORCA No need to change style
if (!m_view_only)
m_button_confirm->Enable(true); // ORCA No need to change style
}
//filament id
@@ -1331,6 +1371,9 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
}
}
if (!ams_filament_id.empty())
m_clr_picker->is_empty(false);
wxArrayString items;
m_pa_profile_items.clear();
m_comboBox_cali_result->SetValue(wxEmptyString);
@@ -1386,7 +1429,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
m_comboBox_cali_result->Set(items);
if (ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID) {
if (from_printer && (*from_printer == 1)) {
if (m_comboBox_from_printer == 1) {
for (auto slot : obj->vt_slot) {
if (slot.id == std::to_string(ams_id))
cali_select_idx = CalibUtils::get_selected_calib_idx(m_pa_profile_items, slot.cali_idx);
@@ -1403,7 +1446,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
}
}
else {
if (from_printer && (*from_printer == 1)) {
if (m_comboBox_from_printer == 1) {
DevAmsTray* selected_tray = this->obj->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id));
if (!selected_tray)
{
@@ -1437,7 +1480,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
else {
if (!ams_filament_id.empty()) {
//m_input_k_val->GetTextCtrl()->SetValue("0.00");
m_input_k_val->Enable(true);
m_input_k_val->Enable(!m_view_only);
}
else {
//m_input_k_val->GetTextCtrl()->SetValue("0.00");
@@ -1445,7 +1488,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt)
}
}
m_comboBox_filament->SetClientData(new int(0));
m_comboBox_from_printer = 0;
}
void AMSMaterialsSetting::on_dpi_changed(const wxRect &suggested_rect)
@@ -1707,6 +1750,7 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
cp->set_selected(false);
cp->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(238,238,238)));
m_color_pickers.push_back(cp);
m_def_color_pickers.push_back(cp);
fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
cp->Bind(wxEVT_LEFT_DOWN, [this, cp](auto& e) {
set_def_colour(cp->m_colour);
@@ -1719,27 +1763,27 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
}
wxBoxSizer* m_sizer_other = new wxBoxSizer(wxHORIZONTAL);
auto m_title_other = new wxStaticText(m_def_color_box, wxID_ANY, _L("Other Color"), wxDefaultPosition, wxDefaultSize, 0);
m_title_other = new wxStaticText(m_def_color_box, wxID_ANY, _L("Other Color"), wxDefaultPosition, wxDefaultSize, 0);
m_title_other->SetFont(::Label::Body_14);
m_title_other->SetBackgroundColour(wxColour(238, 238, 238));
m_sizer_other->Add(m_title_other, 0, wxALL, 5);
auto other_line = new wxPanel(m_def_color_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL);
other_line->SetMinSize(wxSize(-1, 1));
other_line->SetMaxSize(wxSize(-1, 1));
other_line->SetBackgroundColour(wxColour("#CECECE"));
m_sizer_other->Add(other_line, 1, wxALIGN_CENTER, 0);
m_other_line = new wxPanel(m_def_color_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL);
m_other_line->SetMinSize(wxSize(-1, 1));
m_other_line->SetMaxSize(wxSize(-1, 1));
m_other_line->SetBackgroundColour(wxColour("#CECECE"));
m_sizer_other->Add(m_other_line, 1, wxALIGN_CENTER, 0);
//custom color
wxBoxSizer* m_sizer_custom = new wxBoxSizer(wxHORIZONTAL);
auto m_title_custom = new wxStaticText(m_def_color_box, wxID_ANY, _L("Custom Color"), wxDefaultPosition, wxDefaultSize, 0);
m_title_custom = new wxStaticText(m_def_color_box, wxID_ANY, _L("Custom Color"), wxDefaultPosition, wxDefaultSize, 0);
m_title_custom->SetFont(::Label::Body_14);
m_title_custom->SetBackgroundColour(wxColour(238, 238, 238));
auto custom_line = new wxPanel(m_def_color_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL);
custom_line->SetBackgroundColour(wxColour("#CECECE"));
custom_line->SetMinSize(wxSize(-1, 1));
custom_line->SetMaxSize(wxSize(-1, 1));
m_custom_line = new wxPanel(m_def_color_box, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL);
m_custom_line->SetBackgroundColour(wxColour("#CECECE"));
m_custom_line->SetMinSize(wxSize(-1, 1));
m_custom_line->SetMaxSize(wxSize(-1, 1));
m_sizer_custom->Add(m_title_custom, 0, wxALL, 5);
m_sizer_custom->Add(custom_line, 1, wxALIGN_CENTER, 0);
m_sizer_custom->Add(m_custom_line, 1, wxALIGN_CENTER, 0);
m_custom_cp = new StaticBox(m_def_color_box);
m_custom_cp->SetSize(FromDIP(60), FromDIP(25));
@@ -1879,6 +1923,22 @@ void ColorPickerPopup::set_ams_colours(std::vector<wxColour> ams)
Fit();
}
// A printer with a fixed vocabulary only shows its own palette: the default
// swatches and the custom picker would offer colors its screen cannot hold.
void ColorPickerPopup::set_constrained(bool constrained)
{
const bool show = !constrained;
m_title_other->Show(show);
m_other_line->Show(show);
for (ColorPicker* cp : m_def_color_pickers)
cp->Show(show);
m_title_custom->Show(show);
m_custom_line->Show(show);
m_custom_cp->Show(show);
Layout();
Fit();
}
void ColorPickerPopup::set_def_colour(wxColour col)
{
m_def_col = col;
+14
View File
@@ -7,6 +7,8 @@
#include "DeviceManager.hpp"
#include "wx/clrpicker.h"
#include "wx/colourdata.h"
#include "wx/panel.h"
#include "wx/stattext.h"
#include "Widgets/RadioBox.hpp"
#include "Widgets/Button.hpp"
#include "Widgets/RoundedRectangle.hpp"
@@ -77,12 +79,20 @@ public:
std::vector<wxColour> m_ams_colors;
std::vector<ColorPicker*> m_color_pickers;
std::vector<ColorPicker*> m_ams_color_pickers;
// Sections a fixed-vocabulary printer hides in set_constrained: its own
// palette is the only color its screen can show.
std::vector<ColorPicker*> m_def_color_pickers;
wxStaticText* m_title_other{nullptr};
wxPanel* m_other_line{nullptr};
wxStaticText* m_title_custom{nullptr};
wxPanel* m_custom_line{nullptr};
public:
ColorPickerPopup(wxWindow* parent);
~ColorPickerPopup() {};
void on_custom_clr_picker(wxMouseEvent& event);
void set_ams_colours(std::vector<wxColour> ams);
void set_constrained(bool constrained);
void set_def_colour(wxColour col);
void paintEvent(wxPaintEvent& evt);
virtual void OnDismiss() wxOVERRIDE;
@@ -123,6 +133,10 @@ public:
std::string ams_setting_id;
bool m_is_third;
// Marks whether the filament combo was seeded from the printer's own tray.
// Stored as a member so the combo's client data never owns an allocation.
int m_comboBox_from_printer{0};
// Orca: view-only mode (laser/cut). When set, the dialog is inspectable but every
// editing control is disabled and no command is sent.
bool m_view_only = false;
+4 -2
View File
@@ -162,8 +162,10 @@ void DevCalib::ExtrusionCalibSetParse(const json & jj){
int slot_id = tray_ams_slot_map.find(tray_id) != tray_ams_slot_map.end() ? tray_ams_slot_map[tray_id].second : -1;
if(tray_id == VIRTUAL_TRAY_MAIN_ID) {
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k = jj.value("k_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k);
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n = jj.value("n_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n);
if (GetOwner()->vt_slot.size() > MAIN_EXTRUDER_ID) {
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k = jj.value("k_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k);
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n = jj.value("n_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n);
}
}else{
auto tray_item = GetOwner()->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id));
if (tray_item) {
+34 -162
View File
@@ -11,6 +11,8 @@
#include "DevUtil.h"
#include "DevUtilBackend.h"
#include <algorithm>
using namespace nlohmann;
namespace Slic3r {
@@ -18,23 +20,31 @@ static int _hex_digit_to_int(const char c) { return (c >= '0' && c <= '9') ? c -
wxColour DevAmsTray::decode_color(const std::string &color)
{
std::array<int, 4> ret = {0, 0, 0, 0};
// 6 hex digits (RRGGBB) and 8 (RRGGBBAA) are both legal on the wire;
// normalize_ams_color pads the short form, so accept it here too.
const size_t hex_digits = (color.size() == 6 || color.size() == 8) ? color.size() : 0;
if (hex_digits == 0) return wxColour(255, 255, 255, 255);
std::array<int, 4> ret = {0, 0, 0, 255};
const char * c = color.data();
if (color.size() == 8) {
for (size_t j = 0; j < 4; ++j) {
int digit1 = _hex_digit_to_int(*c++);
int digit2 = _hex_digit_to_int(*c++);
if (digit1 == -1 || digit2 == -1) break;
ret[j] = static_cast<float>(digit1 * 16 + digit2);
}
} else { return wxColour(255, 255, 255, 255); }
for (size_t j = 0; j < hex_digits / 2; ++j) {
int digit1 = _hex_digit_to_int(*c++);
int digit2 = _hex_digit_to_int(*c++);
if (digit1 == -1 || digit2 == -1) break;
ret[j] = digit1 * 16 + digit2;
}
return wxColour(ret[0], ret[1], ret[2], ret[3]);
}
void DevAmsTray::UpdateColorFromStr(const std::string& color)
{
if (color.empty()) return;
// An empty color clears, exactly like a payload that omits the key.
if (color.empty()) {
wx_color = wxColour();
this->color.clear();
return;
}
if (this->color != color)
{
wx_color = "#" + wxString::FromUTF8(color);
@@ -42,6 +52,15 @@ void DevAmsTray::UpdateColorFromStr(const std::string& color)
}
}
void DevAmsTray::UpdateEmptyState(bool material_fields_present)
{
const auto is_zero_or_empty = [](const std::string& value) {
return value.empty() || std::all_of(value.begin(), value.end(), [](char c) { return c == '0'; });
};
is_empty = material_fields_present && setting_id.empty() && m_fila_type.empty() &&
is_zero_or_empty(color) && is_zero_or_empty(tag_uid);
}
void DevAmsTray::reset()
{
tag_uid = "";
@@ -63,6 +82,7 @@ void DevAmsTray::reset()
k = 0.0f;
n = 0.0f;
is_bbl = false;
is_empty = false;
hold_count = 0;
remain = 0;
}
@@ -662,7 +682,7 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
//std::string type = (*tray_it)["tray_type"].get<std::string>();
std::string type = MachineObject::setting_id_to_type(curr_tray->setting_id, (*tray_it)["tray_type"].get<std::string>());
// curr_tray->setting_id is our OF id; GFS00/GFS01 are the printer's own support-filament ids.
auto* agent = GUI::wxGetApp().getAgent();
auto* agent = wxTheApp != nullptr ? GUI::wxGetApp().getAgent() : nullptr;
const std::string printer_filament_id = agent ? agent->from_orca_filament_id(curr_tray->setting_id) : curr_tray->setting_id;
if (printer_filament_id == "GFS00")
{
@@ -717,7 +737,8 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
}
else
{
curr_tray->color = "";
// Absent and empty carry the same meaning.
curr_tray->UpdateColorFromStr(std::string());
}
if (tray_it->contains("nozzle_temp_max"))
{
@@ -762,6 +783,7 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
{
curr_tray->remain = -1;
}
curr_tray->UpdateEmptyState(tray_it->contains("tray_info_idx") && tray_it->contains("tray_type"));
// The tray objects are reused across status updates. Reset this
// state when a previously empty slot receives a filament again.
curr_tray->is_slot_placeholder = tray_it->contains("tray_slot_placeholder");
@@ -849,154 +871,4 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
}
}
static DevAms::AmsType ams_type_from_string(const std::string& s)
{
if (s == "ams_lite" || s == "ams-lite") return DevAms::AMS_LITE;
if (s == "n3f") return DevAms::N3F;
if (s == "n3s") return DevAms::N3S;
return DevAms::AMS; // default
}
void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* obj, DevFilaSystem* system)
{
if (!system || !data.is_object())
return;
// --- AMS units ---
if (data.contains("units") && data["units"].is_array())
{
std::set<std::string> seen_units;
for (const auto& u : data["units"])
{
if (!u.is_object() || !u.contains("id"))
continue;
const std::string ams_id = u.value("id", std::string());
if (ams_id.empty())
continue;
seen_units.insert(ams_id);
const int ext_id = u.value("extruder", MAIN_EXTRUDER_ID);
const DevAms::AmsType type = ams_type_from_string(u.value("type", std::string("ams")));
DevAms* ams = nullptr;
auto it = system->amsList.find(ams_id);
if (it == system->amsList.end())
{
ams = new DevAms(ams_id, ext_id, type);
system->amsList.insert(std::make_pair(ams_id, ams));
}
else
{
ams = it->second;
ams->m_ext_id = ext_id;
ams->SetAmsType(type);
}
ams->m_exist = true;
ams->m_current_temperature = u.value("temperature", (float) INVALID_AMS_TEMPERATURE);
ams->m_humidity_percent = u.value("humidity_percent", -1);
ams->m_left_dry_time = u.value("dry_time_min", 0);
// --- slots / trays ---
std::set<std::string> seen_slots;
if (u.contains("slots") && u["slots"].is_array())
{
for (const auto& s : u["slots"])
{
if (!s.is_object())
continue;
const std::string tray_id = std::to_string(s.value("index", -1));
seen_slots.insert(tray_id);
DevAmsTray* tray = nullptr;
auto tit = ams->m_trays.find(tray_id);
if (tit == ams->m_trays.end())
{
tray = new DevAmsTray(tray_id);
ams->m_trays.insert(std::make_pair(tray_id, tray));
}
else
{
tray = tit->second;
}
tray->is_exists = s.value("loaded", false);
tray->m_fila_type = s.value("material", std::string());
tray->setting_id = s.value("preset_id", std::string());
tray->UpdateColorFromStr(s.value("color", std::string()));
tray->nozzle_temp_min = std::to_string(s.value("nozzle_temp_min", 0));
tray->nozzle_temp_max = std::to_string(s.value("nozzle_temp_max", 0));
tray->remain = s.value("remain_percent", -1);
tray->k = s.value("k", 0.0f);
if (s.contains("diameter_mm") && s["diameter_mm"].is_number())
tray->diameter = std::to_string(s["diameter_mm"].get<double>());
if (s.contains("weight_g") && s["weight_g"].is_number())
tray->weight = std::to_string(s["weight_g"].get<int>());
tray->cols.clear();
if (s.contains("colors") && s["colors"].is_array())
{
for (const auto& c : s["colors"])
if (c.is_string())
tray->cols.push_back(c.get<std::string>());
}
tray->ctype = tray->cols.size() > 1 ? 1 : 0;
}
}
// prune trays no longer reported
for (auto tit = ams->m_trays.begin(); tit != ams->m_trays.end();)
{
if (seen_slots.count(tit->first) == 0)
{
delete tit->second;
tit = ams->m_trays.erase(tit);
}
else
{
++tit;
}
}
}
// prune units no longer reported
for (auto it = system->amsList.begin(); it != system->amsList.end();)
{
if (seen_units.count(it->first) == 0)
{
delete it->second;
it = system->amsList.erase(it);
}
else
{
++it;
}
}
}
// --- external / direct spools -> obj->vt_slot ---
// extruder 0 -> main virtual slot, extruder >0 -> deputy.
if (obj && data.contains("external") && data["external"].is_array())
{
obj->vt_slot.clear();
for (const auto& e : data["external"])
{
if (!e.is_object())
continue;
const int ext = e.value("extruder", MAIN_EXTRUDER_ID);
const int vt_id = (ext == MAIN_EXTRUDER_ID) ? VIRTUAL_TRAY_MAIN_ID : VIRTUAL_TRAY_DEPUTY_ID;
DevAmsTray tray(std::to_string(vt_id));
tray.is_exists = e.value("loaded", false);
tray.m_fila_type = e.value("material", std::string());
tray.setting_id = e.value("preset_id", std::string());
tray.UpdateColorFromStr(e.value("color", std::string()));
tray.nozzle_temp_min = std::to_string(e.value("nozzle_temp_min", 0));
tray.nozzle_temp_max = std::to_string(e.value("nozzle_temp_max", 0));
tray.remain = e.value("remain_percent", -1);
obj->vt_slot.push_back(tray);
}
}
}
}
+7 -5
View File
@@ -45,7 +45,8 @@ struct DevFilamentDryingPreset;
* - color: Hex color string without '#' prefix (e.g., "FF0000")
* - cols: Multi-color component list for gradient/multi-color filaments
* - ctype: Color type indicator
* - is_exists: Whether filament is currently loaded in the tray
* - is_exists: Whether the slot is present/configured (the user's layout wins);
* not whether filament is loaded, so a configured empty slot stays present
*/
class DevAmsTray
{
@@ -82,6 +83,7 @@ public:
wxColour wx_color;
bool is_bbl;
bool is_exists = false;
bool is_empty = false; // Explicitly reported as having no filament.
bool is_slot_placeholder = false; // Orca: True for empty tray slots from pull-mode agents
int hold_count = 0;
int remain = 0; // filament remain: 0 ~ 100
@@ -90,13 +92,15 @@ public:
// operators
bool operator==(DevAmsTray const& o) const
{
return id == o.id && m_fila_type == o.m_fila_type && filament_setting_id == o.filament_setting_id && color == o.color;
return id == o.id && m_fila_type == o.m_fila_type && filament_setting_id == o.filament_setting_id &&
color == o.color && is_empty == o.is_empty;
}
bool operator!=(DevAmsTray const& o) const { return !operator==(o); }
// setters
void reset();
void UpdateColorFromStr(const std::string& color);
void UpdateEmptyState(bool material_fields_present);
void set_hold_count() { hold_count = HOLD_COUNT_MAX; }
// getter
@@ -415,8 +419,6 @@ class DevFilaSystemParser
{
public:
static void ParseV1_0(const json& print_json, MachineObject* obj, DevFilaSystem* system, bool key_field_only);
static void ParseAgentFilament(const json& data, MachineObject* obj, DevFilaSystem* system);
};
struct DevFilamentDryingPreset
@@ -433,4 +435,4 @@ struct DevFilamentDryingPreset
float filament_dev_ams_drying_heat_distortion_temperature = 0.0f;
};
}// namespace Slic3r
}// namespace Slic3r
+4 -2
View File
@@ -68,7 +68,9 @@ namespace Slic3r
AppConfig* DeviceManager::get_app_config() const
{
return m_app_config ? m_app_config : GUI::wxGetApp().app_config;
if (m_app_config)
return m_app_config;
return wxTheApp ? GUI::wxGetApp().app_config : nullptr;
}
void DeviceManager::load_local_machines_from_config()
@@ -476,7 +478,7 @@ namespace Slic3r
void DeviceManager::update_local_machine(const MachineObject& m)
{
update_local_machine(m, GUI::wxGetApp().app_config);
update_local_machine(m, wxTheApp ? GUI::wxGetApp().app_config : nullptr);
}
int DeviceManager::query_bind_status(std::string& msg, const std::string& provider)
+261 -112
View File
@@ -378,16 +378,9 @@ wxString MachineObject::get_printer_type_display_str() const
{
std::string display_name = DevPrinterConfigUtil::get_printer_display_name(printer_type);
// Bambu printers use m_resource_file_path + "/printers/" + type_str + ".json", which is a semantic that only works for their profiles.
// For any other profile, we can simply consult preset bundle if the model_id exists.
if (display_name.empty()) {
for (const auto& [vendor_id, vendor] : GUI::wxGetApp().preset_bundle->vendors) {
for (const auto& model : vendor.models) {
if (printer_type == model.model_id)
display_name = model.name;
}
}
}
// Bambu names come from Bambu configs; other vendors resolve through preset model IDs.
if (display_name.empty() && GUI::wxGetApp().preset_bundle)
display_name = GUI::wxGetApp().preset_bundle->get_printer_model_display_name(printer_type);
if (!display_name.empty())
return display_name;
@@ -479,7 +472,8 @@ void MachineObject::set_access_code(std::string code, bool only_refresh)
{
this->access_code = code;
if (only_refresh) {
AppConfig* config = m_manager ? m_manager->get_app_config() : GUI::wxGetApp().app_config;
AppConfig* config = m_manager ? m_manager->get_app_config()
: (wxTheApp ? GUI::wxGetApp().app_config : nullptr);
if (config) {
if (is_lan_mode_printer()) {
// why: LAN codes are scoped via BBLocalMachine::access_code, keyed by dev_id and
@@ -539,6 +533,69 @@ PrinterSeries MachineObject::get_printer_series() const
return PrinterSeries::SERIES_P1P;
}
bool MachineObject::is_bbl_agent() const
{
// Empty id means "unattributed", treated as BBL to preserve the RFID lock.
return printer_agent_id == BBL_PRINTER_AGENT_ID || printer_agent_id.empty();
}
bool MachineObject::is_orca_agent() const
{
return printer_agent_id == ORCA_PRINTER_AGENT_ID;
}
void MachineObject::reset_orca_virtual_trays_for_reconnect()
{
if (!is_orca_agent())
return;
m_orca_seen_virtual_trays.clear();
vt_slot.clear();
vt_slot.emplace_back(std::to_string(VIRTUAL_TRAY_MAIN_ID));
ams_support_virtual_tray = true;
}
bool MachineObject::ams_filament_ack_failed(const nlohmann::json& jj, std::string& reason)
{
reason.clear();
if (!jj.contains("result") || !jj["result"].is_string() ||
jj["result"].get<std::string>() == "success")
return false;
if (jj.contains("reason") && jj["reason"].is_string())
reason = jj["reason"].get<std::string>();
return true;
}
bool MachineObject::ams_filament_native_sync_pending(const nlohmann::json& jj)
{
return jj.contains("native_sync") && jj["native_sync"].is_string() &&
jj["native_sync"].get<std::string>() == "pending";
}
bool MachineObject::ams_filament_connector_only(const nlohmann::json& jj)
{
return jj.contains("native_sync") && jj["native_sync"].is_string() &&
jj["native_sync"].get<std::string>() == "connector_only";
}
void MachineObject::fill_ams_filament_ack_tray(DevAmsTray& tray, const nlohmann::json& jj)
{
// Every field is optional: `jj` is a mutable copy, so a bare operator[]
// would insert null and the get<> below would throw type_error.
if (jj.contains("nozzle_temp_max") && jj["nozzle_temp_max"].is_number())
tray.nozzle_temp_max = std::to_string(jj["nozzle_temp_max"].get<int>());
if (jj.contains("nozzle_temp_min") && jj["nozzle_temp_min"].is_number())
tray.nozzle_temp_min = std::to_string(jj["nozzle_temp_min"].get<int>());
if (jj.contains("tray_color") && jj["tray_color"].is_string())
tray.color = jj["tray_color"].get<std::string>();
if (jj.contains("tray_info_idx") && jj["tray_info_idx"].is_string())
tray.setting_id = jj["tray_info_idx"].get<std::string>();
if (jj.contains("tray_type") || jj.contains("tray_info_idx")) {
const std::string type = (jj.contains("tray_type") && jj["tray_type"].is_string()) ? jj["tray_type"].get<std::string>() : tray.m_fila_type;
tray.m_fila_type = setting_id_to_type(tray.setting_id, type);
}
tray.set_hold_count();
}
PrinterArch MachineObject::get_printer_arch() const
{
return DevPrinterConfigUtil::get_printer_arch(printer_type);
@@ -622,8 +679,7 @@ MachineObject::MachineObject(DeviceManager* manager, NetworkAgent* agent, std::s
has_ipcam = true; // default true
auto vslot = DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID));
vt_slot.push_back(vslot);
// vt_slot is seeded by reset() above; do not push a second copy here.
{
m_lamp = new DevLamp(this);
@@ -750,6 +806,7 @@ DevAmsTray *MachineObject::get_curr_tray()
{
const std::string& cur_ams_id = m_extder_system->GetCurrentAmsId();
if (cur_ams_id.compare(std::to_string(VIRTUAL_TRAY_MAIN_ID)) == 0) {
if (vt_slot.empty()) return nullptr;
return &vt_slot[0];
}
@@ -1682,8 +1739,8 @@ int MachineObject::command_ams_change_filament(bool load, std::string ams_id, st
if (atoi(ams_id.c_str()) < 16) {
tray_id = atoi(ams_id.c_str()) * 4 + atoi(slot_id.c_str());
}
// TODO: Orca hack
if (ams_id == "254")
// Preserve the legacy alias outside Orca; OrcaSonar uses both ids for tool identity.
if (ams_id == "254" && !is_orca_agent())
ams_id = "255";
j["print"]["command"] = "ams_change_filament";
@@ -1765,8 +1822,10 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
j["print"]["tray_id"] = tag_tray_id;
j["print"]["tray_info_idx"] = filament_id;
j["print"]["setting_id"] = setting_id;
// format "FFFFFFFF" RGBA
j["print"]["tray_color"] = tray_color;
// Always present: Bambu firmware echoes it into the tray handler, and a
// slim request would make that echo throw. An empty value becomes the
// protocol's own empty-tray color.
j["print"]["tray_color"] = tray_color.empty() ? std::string("00000000") : tray_color;
j["print"]["nozzle_temp_min"] = nozzle_temp_min;
j["print"]["nozzle_temp_max"] = nozzle_temp_max;
j["print"]["tray_type"] = tray_type;
@@ -2570,13 +2629,11 @@ void MachineObject::reset()
json empty_j;
print_json.diff2all_base_reset(empty_j);
for (auto i = 0; i < vt_slot.size(); i++) {
vt_slot[i].reset();
if (i == 1) {
vt_slot.erase(vt_slot.begin() + 1);
}
}
// Restore the constructor seed and clear Orca's per-connection retained IDs.
vt_slot.clear();
vt_slot.push_back(DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID)));
m_orca_seen_virtual_trays.clear();
ams_support_virtual_tray = true;
// why: reset reuses MachineObject, so release its lazy subtask
// before dropping the pointer to prevent reconnect leaks.
if (subtask_) {
@@ -2804,6 +2861,7 @@ int MachineObject::local_publish_json(std::string json_str, int qos, int flag)
std::string MachineObject::setting_id_to_type(std::string setting_id, std::string tray_type)
{
std::string type;
if (wxTheApp == nullptr) return tray_type;
PresetBundle* preset_bundle = GUI::wxGetApp().preset_bundle;
if (preset_bundle) {
for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) {
@@ -2881,6 +2939,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
try {
bool restored_json = false;
// msg=0 (or a LAN frame without msg) is parsed as a full snapshot;
// removal behavior remains field- and agent-specific.
bool full_snapshot = true;
json j;
if (!parse_ok)
j_pre = json::parse(payload);
@@ -2897,11 +2958,12 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
BOOST_LOG_TRIVIAL(trace) << "static: get push_all msg, dev_id=" << dev_id;
m_push_count++;
m_full_msg_count++;
full_snapshot = true;
if (!printer_type.empty())
print_json.load_compatible_settings(printer_type, "");
print_json.diff2all_base_reset(j_pre);
} else if (j_pre["print"]["msg"].get<int>() == 1) { //diff message
full_snapshot = false;
if (print_json.diff2all(j_pre, j) == 0) {
restored_json = true;
} else {
@@ -3129,7 +3191,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
}
if (!key_field_only) {
if (!m_manager->IsMultiMachineEnabled() && !is_support_agora) {
if ((!m_manager || !m_manager->IsMultiMachineEnabled()) && !is_support_agora) {
if (jj.contains("support_tunnel_mqtt")) {
if (jj["support_tunnel_mqtt"].is_boolean()) {
is_support_tunnel_mqtt = jj["support_tunnel_mqtt"].get<bool>();
@@ -3291,23 +3353,35 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
m_nozzle_mapping_ptr->ParseAutoNozzleMapping(jj);
if (jj["command"].get<std::string>() == "ams_change_filament") {
if (jj.contains("errno")) {
if (jj["errno"].is_number()) {
if (jj.contains("soft_temp")) {
int soft_temp = jj["soft_temp"].get<int>();
if (jj["errno"].get<int>() == -2) {
wxString text = wxString::Format(_L("The chamber temperature is too high, which may cause the filament to soften. Please wait until the chamber temperature drops below %d\u2103. You may open the front door or enable fans to cool down."), soft_temp);
GUI::wxGetApp().push_notification(this, text);
} else if (jj["errno"].get<int>() == -4) {
wxString text = wxString::Format(_L("AMS temperature is too high, which may cause the filament to soften. Please wait until the AMS temperature drops below %d\u2103."), soft_temp);
GUI::wxGetApp().push_notification(this, text);
}
} else {
if (jj["errno"].get<int>() == -2) {
wxString text = _L("The current chamber temperature or the target chamber temperature exceeds 45\u2103. In order to avoid extruder clogging, low temperature filament(PLA/PETG/TPU) is not allowed to be loaded.");
GUI::wxGetApp().push_notification(this, text);
}
}
const int ack_errno = jj.contains("errno") && jj["errno"].is_number()
? jj["errno"].get<int>() : 0;
bool temperature_failure_notified = false;
if (jj.contains("soft_temp") && jj["soft_temp"].is_number()) {
const int soft_temp = jj["soft_temp"].get<int>();
if (ack_errno == -2) {
wxString text = wxString::Format(_L("The chamber temperature is too high, which may cause the filament to soften. Please wait until the chamber temperature drops below %d\u2103. You may open the front door or enable fans to cool down."), soft_temp);
GUI::wxGetApp().push_notification(this, text);
temperature_failure_notified = true;
} else if (ack_errno == -4) {
wxString text = wxString::Format(_L("AMS temperature is too high, which may cause the filament to soften. Please wait until the AMS temperature drops below %d\u2103."), soft_temp);
GUI::wxGetApp().push_notification(this, text);
temperature_failure_notified = true;
}
} else if (ack_errno == -2) {
wxString text = _L("The current chamber temperature or the target chamber temperature exceeds 45\u2103. In order to avoid extruder clogging, low temperature filament(PLA/PETG/TPU) is not allowed to be loaded.");
GUI::wxGetApp().push_notification(this, text);
temperature_failure_notified = true;
}
if (is_orca_agent() && !temperature_failure_notified) {
std::string ack_reason;
bool ack_failed = ams_filament_ack_failed(jj, ack_reason);
ack_failed = ack_failed || ack_errno != 0;
if (ack_failed) {
wxString text = _L("Failed to change filament");
if (!ack_reason.empty())
text += wxString::FromUTF8(": ") + wxString::FromUTF8(ack_reason);
GUI::wxGetApp().push_notification(this, text);
}
}
}
@@ -4037,42 +4111,120 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
catch (...) {
;
}
update_printer_preset_name();
update_filament_list();
// Both read the GUI preset bundle; skip them in a headless
// process (unit tests), where wxTheApp is null.
if (wxTheApp != nullptr) {
update_printer_preset_name();
update_filament_list();
}
if (jj.contains("ams")) {
DevFilaSystemParser::ParseV1_0(jj, this, m_fila_system.get(), key_field_only);
}
/* vitrual tray*/
/* virtual tray */
if (!key_field_only) {
try {
if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) {
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) {
auto it = std::next(vt_slot.begin(), 0);
if (it != vt_slot.end()) {
vt_slot[0] = vslot;
}
else {
vt_slot.push_back(vslot);
}
if (is_orca_agent()) {
auto clear_unseen_seed = [&]() {
if (m_orca_seen_virtual_trays.empty()) {
vt_slot.clear();
ams_support_virtual_tray = false;
} else {
ams_support_virtual_tray = true;
}
else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) {
auto it = std::next(vt_slot.begin(), 1);
if (it != vt_slot.end()) {
vt_slot[1] = vslot;
};
auto merge_orca_tray = [&](DevAmsTray vslot) {
if (vslot.id != std::to_string(VIRTUAL_TRAY_MAIN_ID)
&& vslot.id != std::to_string(VIRTUAL_TRAY_DEPUTY_ID))
return;
m_orca_seen_virtual_trays.insert(vslot.id);
auto held = std::find_if(vt_slot.begin(), vt_slot.end(), [&](const DevAmsTray& tray) {
return tray.id == vslot.id;
});
if (held != vt_slot.end()) {
*held = std::move(vslot);
} else if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) {
vt_slot.insert(vt_slot.begin(), std::move(vslot));
} else {
auto main = std::find_if(vt_slot.begin(), vt_slot.end(), [](const DevAmsTray& tray) {
return tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID);
});
if (main == vt_slot.end()) {
vt_slot.insert(vt_slot.begin(), DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID)));
main = vt_slot.begin();
}
else {
vt_slot.push_back(vslot);
auto deputy = std::find_if(vt_slot.begin(), vt_slot.end(), [](const DevAmsTray& tray) {
return tray.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID);
});
if (deputy == vt_slot.end())
vt_slot.insert(std::next(main), std::move(vslot));
}
ams_support_virtual_tray = true;
};
if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) {
if (jj["vir_slot"].empty()) {
if (full_snapshot)
clear_unseen_seed();
} else {
for (const auto& entry : jj["vir_slot"]) {
if (!entry.is_object() || !entry.contains("id") || !entry["id"].is_string())
continue;
const std::string id = entry["id"].get<std::string>();
if (id == std::to_string(VIRTUAL_TRAY_MAIN_ID)
|| id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID))
merge_orca_tray(parse_vt_tray(entry.get<json>()));
}
if (full_snapshot)
clear_unseen_seed();
}
} else if (jj.contains("vt_tray")) {
auto main_slot = parse_vt_tray(jj["vt_tray"].get<json>());
main_slot.id = std::to_string(VIRTUAL_TRAY_MAIN_ID);
merge_orca_tray(std::move(main_slot));
} else if (full_snapshot) {
clear_unseen_seed();
}
} else if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) {
if (jj["vir_slot"].empty()) {
if (full_snapshot) {
vt_slot.clear();
ams_support_virtual_tray = false;
}
} else {
ams_support_virtual_tray = true;
if (full_snapshot) {
std::vector<DevAmsTray> fresh;
for (const auto& entry : jj["vir_slot"]) {
auto vslot = parse_vt_tray(entry.get<json>());
if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) {
if (fresh.empty())
fresh.push_back(vslot);
else
fresh[0] = vslot;
} else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID) && !fresh.empty()) {
if (fresh.size() > 1)
fresh[1] = vslot;
else
fresh.push_back(vslot);
}
}
vt_slot = std::move(fresh);
} else {
for (const auto& entry : jj["vir_slot"]) {
auto vslot = parse_vt_tray(entry.get<json>());
for (auto& held : vt_slot) {
if (held.id == vslot.id) {
held = vslot;
break;
}
}
}
}
}
}
else if (jj.contains("vt_tray")) {
} else if (jj.contains("vt_tray")) {
ams_support_virtual_tray = true;
auto main_slot = parse_vt_tray(jj["vt_tray"].get<json>());
main_slot.id = std::to_string(VIRTUAL_TRAY_MAIN_ID);
@@ -4085,9 +4237,8 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
vt_slot.push_back(main_slot);
}
}
else {
ams_support_virtual_tray = false;
}
// For Bambu and legacy agents, missing keys do not change
// the current tray state.
}
catch (...) {
;
@@ -4126,47 +4277,42 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
// BBS trigger ams UI update
ams_version = -1;
if (jj["ams_id"].is_number()) {
// OPCP acks a rejected/failed write with result "fail" and an
// errno (-19 validation, -4 persist). Without this the failure
// looked like success and left the old spool on screen.
std::string ack_reason;
const bool ack_failed = ams_filament_ack_failed(jj, ack_reason);
if (ack_failed) {
BOOST_LOG_TRIVIAL(warning) << "ams_filament_setting rejected: errno="
<< (jj.contains("errno") ? jj["errno"].dump() : "?")
<< ", reason=" << ack_reason;
wxString text = _L("Failed to set AMS filament");
if (!ack_reason.empty())
text += wxString::FromUTF8(": ") + wxString::FromUTF8(ack_reason);
GUI::wxGetApp().push_notification(this, text);
} else if (ams_filament_native_sync_pending(jj)) {
GUI::wxGetApp().push_notification(this, _L("Filament settings are saved and will be applied when the printer reconnects."));
} else if (ams_filament_connector_only(jj)) {
GUI::wxGetApp().push_notification(this, _L("Filament settings were saved by OrcaSonar but could not be applied to the printer."));
}
if (!ack_failed && jj["ams_id"].is_number()) {
int ams_id = jj["ams_id"].get<int>();
int tray_id = 0;
if (jj.contains("tray_id")) {
tray_id = jj["tray_id"].get<int>();
}
if (ams_id == 255 && tray_id == VIRTUAL_TRAY_MAIN_ID) {
if (ams_id == 255 && tray_id == VIRTUAL_TRAY_MAIN_ID && !vt_slot.empty()) {
BOOST_LOG_TRIVIAL(info) << "ams_filament_setting, parse tray info";
vt_slot[0].nozzle_temp_max = std::to_string(jj["nozzle_temp_max"].get<int>());
vt_slot[0].nozzle_temp_min = std::to_string(jj["nozzle_temp_min"].get<int>());
vt_slot[0].color = jj["tray_color"].get<std::string>();
vt_slot[0].setting_id = jj["tray_info_idx"].get<std::string>();
//vt_tray.type = jj["tray_type"].get<std::string>();
vt_slot[0].m_fila_type = setting_id_to_type(vt_slot[0].setting_id, jj["tray_type"].get<std::string>());
// delay update
vt_slot[0].set_hold_count();
fill_ams_filament_ack_tray(vt_slot[0], jj);
vt_slot[0].is_exists = true;
} else {
auto ams = m_fila_system->GetAmsById(std::to_string(ams_id));
if (ams) {
tray_id = jj["tray_id"].get<int>();
auto tray_it = ams->GetTrays().find(std::to_string(tray_id));
if (tray_it != ams->GetTrays().end()) {
BOOST_LOG_TRIVIAL(trace) << "ams_filament_setting, parse tray info";
tray_it->second->nozzle_temp_max = std::to_string(jj["nozzle_temp_max"].get<int>());
tray_it->second->nozzle_temp_min = std::to_string(jj["nozzle_temp_min"].get<int>());
//tray_it->second->type = jj["tray_type"].get<std::string>();
tray_it->second->color = jj["tray_color"].get<std::string>();
/*tray_it->second->cols.clear();
if (jj.contains("cols")) {
if (jj["cols"].is_array()) {
for (auto it = jj["cols"].begin(); it != jj["cols"].end(); it++) {
tray_it->second->cols.push_back(it.value().get<std::string>());
}
}
}*/
tray_it->second->setting_id = jj["tray_info_idx"].get<std::string>();
tray_it->second->m_fila_type = setting_id_to_type(tray_it->second->setting_id, jj["tray_type"].get<std::string>());
// delay update
tray_it->second->set_hold_count();
fill_ams_filament_ack_tray(*tray_it->second, jj);
} else {
BOOST_LOG_TRIVIAL(warning) << "ams_filament_setting, can not find in trayList, tray_id=" << tray_id;
}
@@ -4285,7 +4431,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
;
}
}
if (tray_id == VIRTUAL_TRAY_MAIN_ID) {
if (tray_id == VIRTUAL_TRAY_MAIN_ID && !vt_slot.empty()) {
if (jj.contains("k_value"))
vt_slot[0].k = jj["k_value"].get<float>();
if (jj.contains("n_coef"))
@@ -4673,7 +4819,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
if (diff.count() > 10.0f) {
BOOST_LOG_TRIVIAL(trace) << "parse_json timeout = " << diff.count();
}
DeviceManager::update_local_machine(*this, m_manager ? m_manager->get_app_config() : GUI::wxGetApp().app_config);
AppConfig* app_config = m_manager ? m_manager->get_app_config()
: (wxTheApp ? GUI::wxGetApp().app_config : nullptr);
DeviceManager::update_local_machine(*this, app_config);
return 0;
}
@@ -5075,6 +5223,9 @@ bool MachineObject::is_firmware_info_valid()
DevAmsTray MachineObject::parse_vt_tray(json vtray)
{
auto vt_tray = DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID));
// OrcaSonar emits a virtual slot only when the layout has it: the user's
// configuration wins, so it is present even with no material loaded.
vt_tray.is_exists = true;
if (vtray.contains("id"))
vt_tray.id = vtray["id"].get<std::string>();
@@ -5102,7 +5253,7 @@ DevAmsTray MachineObject::parse_vt_tray(json vtray)
//std::string type = vtray["tray_type"].get<std::string>();
std::string type = setting_id_to_type(vt_tray.setting_id, vtray["tray_type"].get<std::string>());
// vt_tray.setting_id is our OF id (translated on the way in); the two support ids below are the printer's own.
auto* agent = GUI::wxGetApp().getAgent();
auto* agent = wxTheApp != nullptr ? GUI::wxGetApp().getAgent() : nullptr;
const std::string printer_filament_id = agent ? agent->from_orca_filament_id(vt_tray.setting_id) : vt_tray.setting_id;
if (printer_filament_id == "GFS00") {
vt_tray.m_fila_type = "PLA-S";
@@ -5197,6 +5348,7 @@ DevAmsTray MachineObject::parse_vt_tray(json vtray)
else {
vt_tray.remain = -1;
}
vt_tray.UpdateEmptyState(vtray.contains("tray_info_idx") && vtray.contains("tray_type"));
}
return vt_tray;
@@ -5521,7 +5673,7 @@ void MachineObject::parse_new_info2(const json& info)
parse_bool("support_build_plate_marker_detect", is_support_build_plate_marker_detect);
parse_bool("support_nozzle_blob_detect", is_support_nozzle_blob_detection);
if (!m_manager->IsMultiMachineEnabled() && !is_support_agora)
if ((!m_manager || !m_manager->IsMultiMachineEnabled()) && !is_support_agora)
parse_bool("support_tunnel_mqtt", is_support_tunnel_mqtt);
const auto bed_leveling_it = flags.find("support_bed_leveling");
@@ -5686,7 +5838,9 @@ int MachineObject::get_flag_bits(int num, int start, int count, int base) const
void MachineObject::update_filament_list()
{
if (wxTheApp == nullptr) return;
PresetBundle *preset_bundle = Slic3r::GUI::wxGetApp().preset_bundle;
if (preset_bundle == nullptr) return;
// custom filament
typedef std::map<std::string, std::pair<int, int>> map_pair;
@@ -5756,6 +5910,7 @@ void MachineObject::update_filament_list()
void MachineObject::update_printer_preset_name()
{
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << __LINE__ << "start update preset_name";
if (wxTheApp == nullptr) return;
PresetBundle * preset_bundle = Slic3r::GUI::wxGetApp().preset_bundle;
if (!preset_bundle) return;
auto printer_model = DevPrinterConfigUtil::get_printer_display_name(this->printer_type);
@@ -5830,14 +5985,11 @@ void MachineObject::check_ams_filament_valid()
if (curr_tray->setting_id.size() == 8 && curr_tray->setting_id[0] == 'P' && filament_list.find(curr_tray->setting_id) == filament_list.end()) {
if (checked_filament.find(curr_tray->setting_id) != checked_filament.end()) {
need_checked_filament_id[nozzle_diameter_str].insert(curr_tray->setting_id);
wxColour color = *wxWHITE;
char col_buf[10];
sprintf(col_buf, "%02X%02X%02XFF", (int) color.Red(), (int) color.Green(), (int) color.Blue());
try {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << __LINE__ << " ams settings_id is not exist in filament_list and reset, ams_id: " << ams_id << " tray_id"
<< slot_id << "filament_id: " << curr_tray->setting_id;
command_ams_filament_settings(std::stoi(ams_id), std::stoi(slot_id), "", "", std::string(col_buf), "", 0, 0);
command_ams_filament_settings(std::stoi(ams_id), std::stoi(slot_id), "", "", "00000000", "", 0, 0);
continue;
} catch (...) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << __LINE__ << " stoi error and ams_id: " << ams_id << " tray_id" << slot_id;
@@ -5892,12 +6044,9 @@ void MachineObject::check_ams_filament_valid()
if (vt_tray.setting_id.size() == 8 && vt_tray.setting_id[0] == 'P' && filament_list.find(vt_tray.setting_id) == filament_list.end()) {
if (checked_filament.find(vt_tray.setting_id) != checked_filament.end()) {
need_checked_filament_id[nozzle_diameter_str].insert(vt_tray.setting_id);
wxColour color = *wxWHITE;
char col_buf[10];
sprintf(col_buf, "%02X%02X%02XFF", (int) color.Red(), (int) color.Green(), (int) color.Blue());
try {
BOOST_LOG_TRIVIAL(info) << "vt_tray.setting_id is not exist in filament_list and reset vt_tray and the filament_id is: " << vt_tray.setting_id;
command_ams_filament_settings(vt_id, 0, "", "", std::string(col_buf), "", 0, 0);
command_ams_filament_settings(vt_id, 0, "", "", "00000000", "", 0, 0);
continue;
} catch (...) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << __LINE__ << " stoi error and tray_id" << vt_tray.id;
+15
View File
@@ -113,6 +113,7 @@ private:
std::string dev_name;
std::string dev_ip;
std::string access_code;
std::unordered_set<std::string> m_orca_seen_virtual_trays;
// type, time stamp, delay
std::vector<std::tuple<std::string, uint64_t, uint64_t>> message_delay;
@@ -161,6 +162,20 @@ public:
void set_agent(NetworkAgent* agent) { m_agent = agent; }
NetworkAgent* get_agent() const { return m_agent; } // Orca: needed by DeviceCore modules (DevAxisCtrl)
// Orca: true only for devices managed by Bambu's own agent, where a
// non-zero tag_uid is an RFID lock. Other agents report it as metadata.
bool is_bbl_agent() const;
bool is_orca_agent() const;
void reset_orca_virtual_trays_for_reconnect();
// Orca: an OPCP failure ack (result != "success"). Fills reason when present.
static bool ams_filament_ack_failed(const nlohmann::json& jj, std::string& reason);
static bool ams_filament_native_sync_pending(const nlohmann::json& jj);
static bool ams_filament_connector_only(const nlohmann::json& jj);
// Orca: apply one ams_filament_setting echo to a tray. A slim ack omits
// fields; an unguarded get here would throw and drop the whole push frame.
static void fill_ams_filament_ack_tray(DevAmsTray& tray, const nlohmann::json& jj);
// Orca: these DeviceCore module accessors are unwired on the read side — axis/chamber/status
// are fed every MQTT push but no GUI consumer reads them yet, and for calib/upgrade the inline
// parse in DeviceManager.cpp remains authoritative. Do not wire DevUpgrade naively: its
@@ -943,7 +943,11 @@ int ReselectMachineDialog::CaculateSwitcherDistribution(MachineObject* obj, cons
const auto& can = ams.cans[j];
auto id = getTrayID(obj, ams.ams_id, can.can_id);
auto material = can.material_name;
if (can.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND ||
if (can.is_empty || can.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY)
{
material = L("Empty");
}
else if (can.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND ||
can.material_state == AMSCanType::AMS_CAN_TYPE_BRAND ||
can.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL)
{
@@ -952,11 +956,6 @@ int ReselectMachineDialog::CaculateSwitcherDistribution(MachineObject* obj, cons
material = L("?");
}
}
if (can.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY)
{
material = "Empty";
}
auto itOK = std::find_if(posOK.begin(), posOK.end(), [&](const trayHelper& tray){
auto amsID = std::get<0>(tray);
auto slotID = std::get<1>(tray);
@@ -1043,4 +1042,4 @@ ReselectMachineDialog::~ReselectMachineDialog()
} // namespace GUI
} // namespace Slic3r
} // namespace Slic3r
+9 -2
View File
@@ -2173,6 +2173,7 @@ void GUI_App::init_networking_callbacks()
/* request_pushing */
MachineObject* obj = m_device_manager->get_my_machine(tunnel ? dev_id.substr(7) : dev_id);
if (obj) {
obj->reset_orca_virtual_trays_for_reconnect();
obj->is_tunnel_mqtt = tunnel;
obj->command_request_push_all(true);
obj->command_get_version();
@@ -2215,6 +2216,7 @@ void GUI_App::init_networking_callbacks()
if (obj->is_lan_mode_printer()) {
if (state == ConnectStatus::ConnectStatusOk) {
obj->reset_orca_virtual_trays_for_reconnect();
obj->command_request_push_all(true);
obj->command_get_version();
event.SetInt(0);
@@ -2336,8 +2338,13 @@ void GUI_App::init_networking_callbacks()
if (MachineObject* obj = m_device_manager->get_my_machine(dev_id)) {
obj->parse_json("lan", msg);
// Orca: skip it if it doesn't support subscription based filament sync
if (this->m_device_manager->get_selected_machine() == obj &&
// Orca: skip it if it doesn't support subscription based filament
// sync, and skip frames that carry no filament state at all
// (progress, temps, hms) so the AMS list is not rebuilt every push.
const bool has_filament_state = msg.find("\"vir_slot\"") != std::string::npos ||
msg.find("\"ams\":") != std::string::npos;
if (has_filament_state &&
this->m_device_manager->get_selected_machine() == obj &&
m_agent->get_filament_sync_mode() == FilamentSyncMode::subscription) {
GUI::wxGetApp().sidebar().load_ams_list(obj);
}
+39 -10
View File
@@ -737,6 +737,7 @@ struct Sidebar::priv
ScalableButton * m_bpButton_add_filament;
ScalableButton * m_bpButton_del_filament;
ScalableButton * m_bpButton_ams_filament;
bool m_ams_sync_button_show{true}; // last applied AMS sync button visibility
ScalableButton * m_bpButton_set_filament;
int m_menu_filament_id = -1;
@@ -3452,6 +3453,22 @@ void Sidebar::remove_unused_filament_combos(const size_t current_extruder_count)
}
}
void Sidebar::update_ams_sync_button()
{
if (!p->m_bpButton_ams_filament || !wxGetApp().preset_bundle)
return;
// BBL printers always advertise AMS sync; other agents follow the active
// agent's filament sync mode, which flips to subscription only after the
// printer's get_capabilities reply arrives.
const bool show = wxGetApp().preset_bundle->use_bbl_network() ||
(wxGetApp().getAgent() && wxGetApp().getAgent()->get_filament_sync_mode() != FilamentSyncMode::none);
if (p->m_ams_sync_button_show == show)
return;
p->m_ams_sync_button_show = show;
p->m_bpButton_ams_filament->Show(show);
p->m_bpButton_ams_filament->GetParent()->Layout();
}
void Sidebar::update_all_preset_comboboxes()
{
PresetBundle &preset_bundle = *wxGetApp().preset_bundle;
@@ -3469,7 +3486,7 @@ void Sidebar::update_all_preset_comboboxes()
//p->btn_connect_printer->Hide();
p->m_printer_connect->Hide();
//only show sync-ams button for BBL printer
p->m_bpButton_ams_filament->Show();
update_ams_sync_button();
//update print button default value for bbl or third-party printer
p_mainframe->set_print_button_to_default(MainFrame::PrintSelectType::ePrintPlate);
} else {
@@ -3478,11 +3495,7 @@ void Sidebar::update_all_preset_comboboxes()
p->m_printer_connect->Show(!use_printer_agents);
// ORCA: show/hide sync-ams button based on filament sync mode
auto agent = wxGetApp().getAgent();
if (agent && agent->get_filament_sync_mode() != FilamentSyncMode::none)
p->m_bpButton_ams_filament->Show();
else
p->m_bpButton_ams_filament->Hide();
update_ams_sync_button();
// Orca: with "Support 3MF as gcode" (use_3mf) the local export is a .gcode.3mf bundle, so when no
// printer host/IP is configured the default action is "Export plate sliced file" (mirrors the
@@ -5693,10 +5706,20 @@ std::map<int, DynamicPrintConfig> Sidebar::build_filament_ams_list(MachineObject
}
auto build_tray_config = [](DevAmsTray const &tray, std::string const &name, std::string ams_id, std::string slot_id) {
// Type-only agents (OrcaSonar/Qidi Box) report the material but not the
// slicer's preset id. Resolve it from the type so AMS sync can match a
// preset instead of skipping every tray as "unknown".
std::string filament_id = tray.setting_id;
if (filament_id.empty() && !tray.m_fila_type.empty() && tray.is_exists) {
// Strict lookup: an unmatched material leaves filament_id empty so
// the sync flow reports it as unknown instead of binding Generic PLA.
if (auto *bundle = wxGetApp().preset_bundle)
bundle->filaments.filament_id_by_type(tray.m_fila_type, filament_id);
}
BOOST_LOG_TRIVIAL(info) << boost::format("build_filament_ams_list: name %1% setting_id %2% type %3% color %4%")
% name % tray.setting_id % tray.m_fila_type % tray.color;
% name % filament_id % tray.m_fila_type % tray.color;
DynamicPrintConfig tray_config;
tray_config.set_key_value("filament_id", new ConfigOptionStrings{tray.setting_id});
tray_config.set_key_value("filament_id", new ConfigOptionStrings{filament_id});
tray_config.set_key_value("tag_uid", new ConfigOptionStrings{tray.tag_uid});
tray_config.set_key_value("ams_id", new ConfigOptionStrings{ams_id});
tray_config.set_key_value("slot_id", new ConfigOptionStrings{slot_id});
@@ -5709,7 +5732,7 @@ std::map<int, DynamicPrintConfig> Sidebar::build_filament_ams_list(MachineObject
tray_config.set_key_value("filament_slot_placeholder", new ConfigOptionBools{tray.is_slot_placeholder});
std::optional<FilamentBaseInfo> info;
if (wxGetApp().preset_bundle) {
info = wxGetApp().preset_bundle->get_filament_by_filament_id(tray.setting_id);
info = wxGetApp().preset_bundle->get_filament_by_filament_id(filament_id);
}
tray_config.set_key_value("filament_is_support", new ConfigOptionBools{ info.has_value() ? info->is_support : false});
for (int i = 0; i < tray.cols.size(); ++i) {
@@ -5903,7 +5926,7 @@ void Sidebar::sync_ams_list(bool is_from_big_sync_btn)
auto & list = wxGetApp().preset_bundle->filament_ams_list;
if (list.empty()) {
auto printer_name = p->plater->get_selected_printer_name_in_combox();
p->plater->pop_warning_and_go_to_device_page(printer_name, Plater::PrinterWarningType::NOT_CONNECTED, _L("Sync printer information"));
p->plater->pop_warning_and_go_to_device_page(printer_name, Plater::PrinterWarningType::AMS_UNAVAILABLE, _L("Sync printer information"));
return;
}
bool exist_at_list_one_filament =false;
@@ -21143,6 +21166,8 @@ void Plater::pop_warning_and_go_to_device_page(wxString printer_name, PrinterWar
content = _L("There are no filaments on the printer. Please load the filaments on the printer first.");
} else if (type == PrinterWarningType::EMPTY_FILAMENT) {
content = _L("The filaments on the printer are all unknown types. Please go to the printer screen or software device page to set the filament type.");
} else if (type == PrinterWarningType::AMS_UNAVAILABLE) {
content = _L("No AMS filament data is available for this printer. Make sure the printer's material system is detected and connected, then try again.");
}
MessageDialog dlg(this, content, title, wxOK | wxFORWARD | wxICON_WARNING, _L("Device Page"));
auto result = dlg.ShowModal();
@@ -22455,10 +22480,14 @@ void Plater::update_machine_sync_status()
DeviceManager *dev_maneger = wxGetApp().getDeviceManager();
if (!dev_maneger) {
GUI::wxGetApp().sidebar().update_sync_status(nullptr);
GUI::wxGetApp().sidebar().update_ams_sync_button();
return;
}
MachineObject *obj = wxGetApp().getDeviceManager()->get_selected_machine();
GUI::wxGetApp().sidebar().update_sync_status(obj);
// The agent's sync mode flips once the printer's get_capabilities reply
// arrives; re-evaluate the filament-sync button on every device update.
GUI::wxGetApp().sidebar().update_ams_sync_button();
}
bool Plater::get_machine_sync_status()
+4 -1
View File
@@ -169,6 +169,8 @@ public:
void init_filament_combo(PlaterPresetComboBox **combo, const int filament_idx);
void remove_unused_filament_combos(const size_t current_extruder_count);
void update_all_preset_comboboxes();
// Show/hide the AMS filament-sync button from the active agent's sync mode.
void update_ams_sync_button();
//void update_partplate(PartPlateList& list);
void update_presets(Slic3r::Preset::Type preset_type);
//BBS
@@ -646,7 +648,8 @@ public:
NOT_CONNECTED,
INCONSISTENT,
UNINSTALL_FILAMENT,
EMPTY_FILAMENT
EMPTY_FILAMENT,
AMS_UNAVAILABLE
};
void pop_warning_and_go_to_device_page(wxString printer_name, PrinterWarningType type, const wxString &title);
bool check_printer_initialized(MachineObject *obj, bool only_warning = false,bool popup_warning = true);
+24 -11
View File
@@ -4350,12 +4350,12 @@ void StatusPanel::on_ams_load_curr()
vt_slot_idx = 1;
}
if (vt_slot_idx < 0 || vt_slot_idx >= (int)obj->vt_slot.size()) return;
int old_temp = -1;
int new_temp = -1;
DevAmsTray* curr_tray = &obj->vt_slot[vt_slot_idx];
if (!curr_tray) return;
try {
if (!curr_tray->nozzle_temp_max.empty() && !curr_tray->nozzle_temp_min.empty())
old_temp = (atoi(curr_tray->nozzle_temp_min.c_str()) + atoi(curr_tray->nozzle_temp_max.c_str())) / 2;
@@ -4373,7 +4373,8 @@ void StatusPanel::on_ams_load_curr()
}
} catch (...) {}
} else {
obj->command_ams_change_filament(true, "254", "0", old_temp, new_temp, extruder_id);
const std::string external_ams_id = obj->is_orca_agent() ? curr_ams_id : "254";
obj->command_ams_change_filament(true, external_ams_id, "0", old_temp, new_temp, extruder_id);
}
}
@@ -4592,7 +4593,7 @@ void StatusPanel::on_filament_edit(wxCommandEvent &event)
m_filament_setting_dlg->set_ctype(tray->ctype);
m_filament_setting_dlg->ams_filament_id = tray->setting_id;
if (m_filament_setting_dlg->ams_filament_id.empty())
if (tray->is_empty)
{
m_filament_setting_dlg->set_empty_color(color);
}
@@ -4602,15 +4603,25 @@ void StatusPanel::on_filament_edit(wxCommandEvent &event)
m_filament_setting_dlg->set_colors(cols);
}
m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(tray->tag_uid);
// A non-zero tag_uid is an RFID lock only on Bambu's own agent.
// Agent-managed printers (Orca/Qidi/Snapmaker) send it as
// metadata, and the spec says it must never be interpreted — so
// the tag alone must not make their trays read-only.
m_filament_setting_dlg->m_is_third = !obj->is_bbl_agent() || !DevFilaSystem::IsBBL_Filament(tray->tag_uid);
temp_max = tray->nozzle_temp_max;
temp_min = tray->nozzle_temp_min;
if (!m_filament_setting_dlg->m_is_third)
{
sn_number = tray->uuid;
filament = tray->sub_brands;
temp_max = tray->nozzle_temp_max;
temp_min = tray->nozzle_temp_min;
}
}
else
{
// No tray state for this slot: never show the previous slot's.
m_filament_setting_dlg->set_empty_color(AMS_MATERIALS_SETTING_DEF_COLOUR);
m_filament_setting_dlg->ams_filament_id = "";
}
m_filament_setting_dlg->Move(wxPoint(current_position_x, current_position_y));
m_filament_setting_dlg->Popup(filament, sn_number, temp_min, temp_max, k_val, n_val);
@@ -4642,6 +4653,8 @@ void StatusPanel::on_ext_spool_edit(wxCommandEvent &event)
m_filament_setting_dlg->slot_id = slot_id;
int nozzle_index = ams_id == VIRTUAL_TRAY_MAIN_ID ? 0 : 1;
if (nozzle_index < 0 || nozzle_index >= (int)obj->vt_slot.size()) return;
try {
std::string sn_number;
std::string filament;
@@ -4660,7 +4673,7 @@ void StatusPanel::on_ext_spool_edit(wxCommandEvent &event)
}
m_filament_setting_dlg->set_ctype(obj->vt_slot[nozzle_index].ctype);
if (m_filament_setting_dlg->ams_filament_id.empty()) {
if (obj->vt_slot[nozzle_index].is_empty) {
m_filament_setting_dlg->set_empty_color(color);
}
else {
@@ -4668,12 +4681,12 @@ void StatusPanel::on_ext_spool_edit(wxCommandEvent &event)
m_filament_setting_dlg->set_colors(cols);
}
m_filament_setting_dlg->m_is_third = !DevFilaSystem::IsBBL_Filament(obj->vt_slot[nozzle_index].tag_uid);
m_filament_setting_dlg->m_is_third = !obj->is_bbl_agent() || !DevFilaSystem::IsBBL_Filament(obj->vt_slot[nozzle_index].tag_uid);
temp_max = obj->vt_slot[nozzle_index].nozzle_temp_max;
temp_min = obj->vt_slot[nozzle_index].nozzle_temp_min;
if (!m_filament_setting_dlg->m_is_third) {
sn_number = obj->vt_slot[nozzle_index].uuid;
filament = obj->vt_slot[nozzle_index].sub_brands;
temp_max = obj->vt_slot[nozzle_index].nozzle_temp_max;
temp_min = obj->vt_slot[nozzle_index].nozzle_temp_min;
}
m_filament_setting_dlg->Move(wxPoint(current_position_x,current_position_y));
+9 -7
View File
@@ -72,6 +72,7 @@ bool AMSinfo::parse_ams_info(MachineObject *obj, DevAms *ams, bool remain_flag,
Caninfo info;
// tray is exists
if (it != ams->GetTrays().end() && it->second->is_exists) {
info.is_empty = it->second->is_empty;
if (it->second->is_tray_info_ready()) {
info.can_id = it->second->id;
info.ctype = it->second->ctype;
@@ -139,6 +140,7 @@ void AMSinfo::parse_ext_info(MachineObject* obj, DevAmsTray tray) {
this->ams_type = AMSModel::EXT_AMS;
Caninfo info;
info.can_id = std::to_string(0);
info.is_empty = tray.is_empty;
this->cans.clear();
if (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID))
@@ -1101,9 +1103,9 @@ void AMSLib::render_lite_text(wxDC& dc)
dc.SetTextForeground(temp_text_colour);
auto libsize = GetSize();
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND
if (!m_info.is_empty && (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_BRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL) {
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL)) {
if (m_info.material_name.empty()) {
auto tsize = dc.GetMultiLineTextExtent("?");
@@ -1151,7 +1153,7 @@ void AMSLib::render_lite_text(wxDC& dc)
}
}
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY) {
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY || m_info.is_empty) {
auto tsize = dc.GetMultiLineTextExtent(_L("/"));
auto pot = wxPoint((libsize.x - tsize.x) / 2 + FromDIP(2), (libsize.y - tsize.y) / 2 + FromDIP(3));
dc.DrawText(_L("/"), pot);
@@ -1202,9 +1204,9 @@ void AMSLib::render_generic_text(wxDC &dc)
}
auto libsize = GetSize();
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND
if (!m_info.is_empty && (m_info.material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_BRAND
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL) {
|| m_info.material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL)) {
if (m_info.material_name.empty() /*&& m_info.material_state != AMSCanType::AMS_CAN_TYPE_VIRTUAL*/) {
auto tsize = dc.GetMultiLineTextExtent("?");
@@ -1284,7 +1286,7 @@ void AMSLib::render_generic_text(wxDC &dc)
}
}
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY) {
if (m_info.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY || m_info.is_empty) {
auto tsize = dc.GetMultiLineTextExtent(_L("Empty"));
auto pot = wxPoint((libsize.x - tsize.x) / 2, (libsize.y - tsize.y) / 2 + FromDIP(3));
dc.DrawText(_L("Empty"), pot);
@@ -2781,7 +2783,7 @@ void AMSPreview::doRender(wxDC &dc)
}
else {
wxRect rect(left, (size.y - AMS_ITEM_CUBE_SIZE.y) / 2, AMS_ITEM_CUBE_SIZE.x, AMS_ITEM_CUBE_SIZE.y);
if (iter->material_state == AMSCanType::AMS_CAN_TYPE_EMPTY) {
if (iter->material_state == AMSCanType::AMS_CAN_TYPE_EMPTY || iter->is_empty) {
dc.SetPen(wxPen(wxColor(0, 0, 0)));
dc.DrawLine(rect.GetRight() - FromDIP(1), rect.GetTop() + FromDIP(1), rect.GetLeft() + FromDIP(1), rect.GetBottom() - FromDIP(1));
}
+2
View File
@@ -183,6 +183,7 @@ struct Caninfo
wxString material_name;
wxColour material_colour = {*wxWHITE};
AMSCanType material_state;
bool is_empty = false;
int ctype=0;
int material_remain = 100;
int cali_idx = -1;
@@ -198,6 +199,7 @@ public:
material_name == other.material_name &&
material_colour == other.material_colour &&
material_state == other.material_state &&
is_empty == other.is_empty &&
ctype == other.ctype &&
material_remain == other.material_remain &&
cali_idx == other.cali_idx &&
+620
View File
@@ -0,0 +1,620 @@
#include "AmsPayload.hpp"
#include "Http.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/DeviceCore/DevFilaSystem.h"
#include "slic3r/GUI/DeviceCore/DevManager.h"
#include "slic3r/GUI/DeviceCore/DevStorage.h"
#include "slic3r/GUI/DeviceCore/DevFirmware.h"
#include <boost/algorithm/string.hpp>
#include <boost/log/trivial.hpp>
#include <wx/thread.h>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <map>
#include <mutex>
#include <sstream>
#include <utility>
namespace Slic3r {
std::string map_filament_type_to_generic_id(const std::string& filament_type)
{
std::string upper = filament_type;
boost::trim(upper);
std::transform(upper.begin(), upper.end(), upper.begin(),
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
// Normalize reported material names to an OrcaFilamentLibrary generic family. The
// family's filament_id is resolved from the loaded system presets below rather than
// hardcoded, so profile id re-mints never require touching this table.
// scripts/test_moonraker_lane_data.py parses this initializer; keep the {"A", "B"} format.
static const std::map<std::string, std::string> type_to_ofl_family = {
// PLA variants
{"PLA", "PLA"},
{"PLA-CF", "PLA-CF"},
{"PLA SILK", "PLA Silk"},
{"PLA-SILK", "PLA Silk"},
{"PLA HIGH SPEED", "PLA High Speed"},
{"PLA-HS", "PLA High Speed"},
{"PLA HS", "PLA High Speed"},
// ABS/ASA variants
{"ABS", "ABS"},
{"ASA", "ASA"},
// PETG/PET variants
{"PETG", "PETG"},
{"PET", "PETG"},
{"PCTG", "PCTG"},
// PA/Nylon variants
{"PA", "PA"},
{"NYLON", "PA"},
{"PA-CF", "PA-CF"},
{"PPA", "PPA-CF"},
{"PPA-CF", "PPA-CF"},
{"PPA-GF", "PPA-GF"},
// PC variants
{"PC", "PC"},
// PP/PE variants
{"PE", "PE"},
{"PP", "PP"},
// Support materials
{"PVA", "PVA"},
{"HIPS", "HIPS"},
{"BVOH", "BVOH"},
// TPU variants
{"TPU", "TPU"},
// Other materials
{"EVA", "EVA"},
{"PHA", "PHA"},
{"COPE", "CoPE"},
{"SBS", "SBS"},
};
auto it = type_to_ofl_family.find(upper);
if (it == type_to_ofl_family.end())
return UNKNOWN_FILAMENT_ID;
auto* bundle = wxTheApp != nullptr ? GUI::wxGetApp().preset_bundle : nullptr;
if (bundle) {
const Preset* preset = bundle->filaments.find_preset("Generic " + it->second + " @System");
if (preset != nullptr && preset->is_system && !preset->filament_id.empty())
return preset->filament_id;
}
// Unknown material, or no loaded preset data to resolve against
return UNKNOWN_FILAMENT_ID;
}
std::string normalize_ams_color(const std::string& color)
{
std::string value = color;
boost::trim(value);
// Remove 0x or 0X prefix if present
if (value.size() >= 2 && (value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0)) {
value = value.substr(2);
}
// Remove # prefix if present
if (!value.empty() && value[0] == '#') {
value = value.substr(1);
}
// Extract only hex digits
std::string normalized;
for (char c : value) {
if (std::isxdigit(static_cast<unsigned char>(c))) {
normalized.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(c))));
}
}
// If 6 hex digits, add FF alpha
if (normalized.size() == 6) {
normalized += "FF";
}
// Validate length - return default if invalid
if (normalized.size() != 8) {
return "00000000";
}
return normalized;
}
bool parse_moonraker_lane_data(const nlohmann::json& body,
std::vector<AmsTrayData>& trays,
int& max_lane_index)
{
// Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } }
if (!body.is_object() || !body.contains("result") || !body["result"].is_object() ||
!body["result"].contains("value") || !body["result"]["value"].is_object()) {
BOOST_LOG_TRIVIAL(warning) << "AmsPayload: unexpected lane_data response structure";
return false;
}
const auto& value = body["result"]["value"];
trays.clear();
max_lane_index = 0;
for (const auto& lane_item : value.items()) {
const auto& lane_obj = lane_item.value();
if (!lane_obj.is_object()) {
continue;
}
// Extract lane index from the "lane" field (tool number, 0-based)
int lane_index = -1;
const auto lane_it = lane_obj.find("lane");
if (lane_it != lane_obj.end() && lane_it->is_string()) {
try {
lane_index = std::stoi(lane_it->get<std::string>());
} catch (...) {
lane_index = -1;
}
}
if (lane_index < 0) {
continue;
}
AmsTrayData tray;
tray.slot_index = lane_index;
if (const auto it = lane_obj.find("color"); it != lane_obj.end() && it->is_string())
tray.tray_color = it->get<std::string>();
if (const auto it = lane_obj.find("material"); it != lane_obj.end() && it->is_string())
tray.tray_type = it->get<std::string>();
if (const auto it = lane_obj.find("bed_temp"); it != lane_obj.end() && it->is_number())
tray.bed_temp = it->get<int>();
if (const auto it = lane_obj.find("nozzle_temp"); it != lane_obj.end() && it->is_number())
tray.nozzle_temp = it->get<int>();
// Presence is independent of declared material: a physically loaded lane
// with no user-declared material must not read as empty. Prefer an
// explicit signal, fall back to the legacy "has a material type" rule.
const auto has_it = lane_obj.find("has_filament");
const auto loaded_it = lane_obj.find("loaded");
if (has_it != lane_obj.end() && has_it->is_boolean())
tray.has_filament = has_it->get<bool>();
else if (loaded_it != lane_obj.end() && loaded_it->is_boolean())
tray.has_filament = loaded_it->get<bool>();
else
tray.has_filament = !tray.tray_type.empty();
max_lane_index = std::max(max_lane_index, lane_index);
trays.push_back(tray);
}
if (trays.empty()) {
BOOST_LOG_TRIVIAL(info) << "AmsPayload: no lanes found";
return false;
}
return true;
}
LaneDataFetch read_moonraker_lane_data(const std::string& origin,
const std::string& api_key,
std::vector<AmsTrayData>& trays,
int& max_lane_index)
{
trays.clear();
max_lane_index = 0;
std::string base = origin;
while (!base.empty() && base.back() == '/')
base.pop_back();
if (base.empty())
return LaneDataFetch::unknown;
unsigned http_status = 0;
std::string response_body;
std::string http_error;
auto http = Http::get(base + "/server/database/item?namespace=lane_data");
if (!api_key.empty())
http.header("X-Api-Key", api_key);
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
http_status = status;
if (status == 200)
response_body = std::move(body);
else
http_error = "HTTP error: " + std::to_string(status);
})
.on_error([&](std::string, std::string err, unsigned status) {
// Http routes every >=400 response through here, so a 404 arrives as
// a real HTTP status, not a transport failure (REQ-STS-007 §7.7).
http_status = status;
http_error = err;
if (status > 0)
http_error += " (HTTP " + std::to_string(status) + ")";
})
.perform_sync();
if (http_status == 404) {
BOOST_LOG_TRIVIAL(info) << "AmsPayload: lane_data not served yet (unknown topology)";
return LaneDataFetch::unknown;
}
if (http_status != 200) {
BOOST_LOG_TRIVIAL(info) << "AmsPayload: lane_data fetch failed: " << http_error;
return LaneDataFetch::error;
}
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
BOOST_LOG_TRIVIAL(warning) << "AmsPayload: invalid lane_data JSON";
return LaneDataFetch::error;
}
const bool empty_value = json.is_object() && json.contains("result") && json["result"].is_object() &&
json["result"].contains("value") && json["result"]["value"].is_object() &&
json["result"]["value"].empty();
if (empty_value)
return LaneDataFetch::none;
if (!parse_moonraker_lane_data(json, trays, max_lane_index))
return LaneDataFetch::error;
return LaneDataFetch::synced;
}
void resolve_tray_info_idx(std::vector<AmsTrayData>& trays)
{
auto* bundle = wxTheApp != nullptr ? GUI::wxGetApp().preset_bundle : nullptr;
for (auto& tray : trays) {
// Absent lanes render as placeholders; resolving an empty type is busy
// work at best and could bind a bogus id.
if (!tray.has_filament)
continue;
// A vendor-aware resolver may already have matched this lane; only fill
// what is still empty so the generic fallback cannot overwrite it.
if (!tray.tray_info_idx.empty())
continue;
if (bundle) {
std::string filament_id;
if (bundle->filaments.filament_id_by_type(tray.tray_type, filament_id))
tray.tray_info_idx = std::move(filament_id);
} else {
const std::string filament_id = map_filament_type_to_generic_id(tray.tray_type);
if (filament_id != UNKNOWN_FILAMENT_ID)
tray.tray_info_idx = filament_id;
}
}
}
nlohmann::json build_bbl_ams_json(const std::vector<AmsTrayData>& trays,
int ams_count,
int max_lane_index)
{
nlohmann::json ams_array = nlohmann::json::array();
// ams_exist_bits marks the units; tray_exist_bits marks the occupied
// slots. uint64_t: unsigned long is 32-bit on MSVC, so 1UL << 32+ is UB.
// BBL's fields are 64-bit; a lane past 63 cannot be represented at all.
uint64_t ams_exist_bits = 0;
uint64_t tray_exist_bits = 0;
auto set_exist_bit = [](uint64_t& bits, int index) {
if (index < 0)
return;
if (index >= 64) {
BOOST_LOG_TRIVIAL(warning) << "AmsPayload: lane index " << index << " exceeds the 64-bit exist-bits field; bit dropped";
return;
}
bits |= (uint64_t{1} << index);
};
for (int ams_id = 0; ams_id < ams_count; ++ams_id) {
set_exist_bit(ams_exist_bits, ams_id);
nlohmann::json ams_unit = nlohmann::json::object();
ams_unit["id"] = std::to_string(ams_id);
ams_unit["info"] = "0002"; // treat as AMS_LITE
nlohmann::json tray_array = nlohmann::json::array();
int max_slot_in_this_ams = std::min(3, max_lane_index - ams_id * 4);
for (int slot_id = 0; slot_id <= max_slot_in_this_ams; ++slot_id) {
int slot_index = ams_id * 4 + slot_id;
// Find tray with matching slot_index
const AmsTrayData* tray = nullptr;
for (const auto& t : trays) {
if (t.slot_index == slot_index) {
tray = &t;
break;
}
}
nlohmann::json tray_json = nlohmann::json::object();
tray_json["id"] = std::to_string(slot_id);
tray_json["tag_uid"] = "0000000000000000";
if (tray && tray->has_filament) {
set_exist_bit(tray_exist_bits, slot_index);
// Present lanes never carry tray_slot_placeholder: a loaded but
// undeclared lane stays occupied with an empty type, which is
// what distinguishes it from an absent slot.
tray_json["tray_info_idx"] = tray->tray_info_idx;
tray_json["tray_type"] = tray->tray_type;
tray_json["tray_color"] = normalize_ams_color(tray->tray_color);
// Add temperature data if provided
if (tray->bed_temp > 0) {
tray_json["bed_temp"] = std::to_string(tray->bed_temp);
}
if (tray->nozzle_temp > 0) {
tray_json["nozzle_temp_max"] = std::to_string(tray->nozzle_temp);
}
} else {
tray_json["tray_info_idx"] = "";
tray_json["tray_type"] = "";
tray_json["tray_color"] = "00000000";
tray_json["tray_slot_placeholder"] = "1";
}
tray_array.push_back(tray_json);
}
ams_unit["tray"] = tray_array;
ams_array.push_back(ams_unit);
}
// Format as hex strings (matching BBL protocol)
std::ostringstream ams_exist_ss;
ams_exist_ss << std::hex << std::uppercase << ams_exist_bits;
std::ostringstream tray_exist_ss;
tray_exist_ss << std::hex << std::uppercase << tray_exist_bits;
nlohmann::json ams_json = nlohmann::json::object();
ams_json["ams"] = ams_array;
ams_json["ams_exist_bits"] = ams_exist_ss.str();
ams_json["tray_exist_bits"] = tray_exist_ss.str();
return ams_json;
}
// --- Removal/absence state and op capability ---------------------------------
static std::mutex g_ams_state_mutex;
// One device's declaration from its get_capabilities reply. ops_known separates
// "no reply yet" (never gate) from "answered without ops" (gate every write).
struct AmsDeviceCaps
{
std::vector<std::string> ops;
bool ops_known = false;
bool has_ams = false;
bool filament_slots = false;
FilamentMetadataValues metadata;
};
static std::map<std::string, AmsDeviceCaps> g_ams_caps;
void register_ams_ops(const std::string& dev_id, const std::vector<std::string>& ops)
{
if (dev_id.empty())
return;
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
AmsDeviceCaps& caps = g_ams_caps[dev_id];
caps.ops = ops;
caps.ops_known = true;
}
bool ams_op_supported(const std::string& dev_id, const std::string& op)
{
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
auto it = g_ams_caps.find(dev_id);
if (it == g_ams_caps.end() || !it->second.ops_known)
return true; // no capability reply yet: never gate
return std::find(it->second.ops.begin(), it->second.ops.end(), op) != it->second.ops.end();
}
void register_ams_capability(const std::string& dev_id, bool has_ams)
{
if (dev_id.empty())
return;
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
g_ams_caps[dev_id].has_ams = has_ams;
}
bool has_ams_capability(const std::string& dev_id)
{
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
auto it = g_ams_caps.find(dev_id);
return it != g_ams_caps.end() && it->second.has_ams;
}
bool ams_caps_known(const std::string& dev_id)
{
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
auto it = g_ams_caps.find(dev_id);
return it != g_ams_caps.end() && it->second.ops_known;
}
void register_filament_slots(const std::string& dev_id, bool has_slots)
{
if (dev_id.empty())
return;
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
g_ams_caps[dev_id].filament_slots = has_slots;
}
bool has_filament_slots(const std::string& dev_id)
{
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
auto it = g_ams_caps.find(dev_id);
return it != g_ams_caps.end() && it->second.filament_slots;
}
void register_filament_metadata(const std::string& dev_id, const FilamentMetadataValues& values)
{
if (dev_id.empty())
return;
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
g_ams_caps[dev_id].metadata = values;
}
FilamentMetadataValues filament_metadata_values(const std::string& dev_id)
{
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
auto it = g_ams_caps.find(dev_id);
if (it == g_ams_caps.end())
return {};
return it->second.metadata;
}
static bool material_prefix(const std::string& value, const char* family)
{
const std::string prefix(family);
if (value == prefix)
return true;
if (value.size() <= prefix.size() || value.compare(0, prefix.size(), prefix) != 0)
return false;
const char next = value[prefix.size()];
return next == ' ' || next == '+' || next == '-' || next == '/';
}
static bool qidi_pa_material(const std::string& value)
{
return material_prefix(value, "PA") || material_prefix(value, "PA12") ||
material_prefix(value, "PAHT") || material_prefix(value, "ULTRAPA");
}
// Keep distinct material families separate; only known variants share a stem.
std::string normalize_qidi_material_family(const std::string& value)
{
const std::string upper = boost::algorithm::to_upper_copy(boost::algorithm::trim_copy(value));
if (material_prefix(upper, "PLA-CF")) return "PLA-CF";
if (material_prefix(upper, "PPA-CF")) return "PPA-CF";
if (material_prefix(upper, "PC-ABS")) return "PC";
if (material_prefix(upper, "PCTG")) return "PCTG";
if (material_prefix(upper, "PPA")) return "PPA";
if (material_prefix(upper, "PLA")) return "PLA";
if (material_prefix(upper, "ABS")) return "ABS";
if (material_prefix(upper, "PETG")) return "PETG";
if (material_prefix(upper, "TPU")) return "TPU";
if (material_prefix(upper, "ASA")) return "ASA";
if (qidi_pa_material(upper) || material_prefix(upper, "NYLON")) return "PA";
if (material_prefix(upper, "PC")) return "PC";
if (material_prefix(upper, "PVA")) return "PVA";
return upper;
}
bool filament_material_compatible(const std::string& dev_id, const std::string& preset_type)
{
const FilamentMetadataValues values = filament_metadata_values(dev_id);
if (!values.constrained)
return true;
if (boost::algorithm::trim_copy(preset_type).empty())
return true; // a reply that never carried a type must not hide presets
std::vector<std::string> parts;
boost::algorithm::split(parts, preset_type, boost::algorithm::is_any_of(";"), boost::token_compress_on);
for (const std::string& part : parts) {
const std::string type = boost::algorithm::trim_copy(part);
if (type.empty())
continue;
const std::string category = normalize_qidi_material_family(type);
for (const std::string& declared : values.materials) {
if (boost::algorithm::iequals(boost::algorithm::trim_copy(declared), type))
return true;
if (normalize_qidi_material_family(declared) == category)
return true;
}
}
return false;
}
void clear_ams_caps(const std::string& dev_id)
{
if (dev_id.empty())
return;
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
g_ams_caps.erase(dev_id);
}
void build_ams_payload_for_device(const std::string& dev_id,
const std::optional<std::string>& printer_type,
int ams_count,
int max_lane_index,
const std::vector<AmsTrayData>& trays,
const QueueOnMainFn& queue_fn,
const TrayInfoResolver& vendor_resolver)
{
// A caller on the GUI thread must mutate DeviceManager inline: invoking
// queue_fn (CallAfter) would defer the work until after the caller has
// already read DevFilaSystem. Background callers route through queue_fn.
const bool on_main = wxIsMainThread();
auto apply = [dev_id, printer_type, ams_count, max_lane_index, trays, vendor_resolver]() {
// Look up MachineObject via DeviceManager
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
if (!dev_manager) {
return;
}
MachineObject* obj = dev_manager->get_my_machine(dev_id);
if (!obj) {
return;
}
// Resolve preset ids here: reads the GUI preset bundle, which is only safe
// on the main thread. trays is a copy, so mutating it is fine. The optional
// vendor resolver runs first; the generic resolver fills the rest.
std::vector<AmsTrayData> resolved = trays;
if (vendor_resolver)
vendor_resolver(resolved);
resolve_tray_info_idx(resolved);
// Wrap in the expected structure for ParseV1_0
nlohmann::json print_json = nlohmann::json::object();
print_json["ams"] = build_bbl_ams_json(resolved, ams_count, max_lane_index);
// Call the parser to populate DevFilaSystem
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
BOOST_LOG_TRIVIAL(info) << "AmsPayload: parsed " << resolved.size() << " trays";
// Set printer_type so update_sync_status() can match it against the preset's
// printer type. nullopt = leave it (the caller's push_status already carries
// it); a value = assign, including empty, preserving the old behavior.
if (printer_type.has_value()) {
obj->printer_type = *printer_type;
}
// Set push counters so is_info_ready() returns true for pull-mode agents.
if (obj->m_push_count == 0) {
obj->m_push_count = 1;
}
if (obj->m_full_msg_count == 0) {
obj->m_full_msg_count = 1;
}
obj->last_push_time = std::chrono::system_clock::now();
// Set storage state - Moonraker printers use virtual_sdcard, storage is always available.
// This is required for SelectMachineDialog to allow printing (otherwise it blocks with "No SD card").
obj->GetStorage()->set_sdcard_state(DevStorage::HAS_SDCARD_NORMAL);
// Populate module_vers so is_info_ready() passes the version check.
if (obj->module_vers.empty()) {
DevFirmwareVersionInfo ota_info;
ota_info.name = "ota";
ota_info.sw_ver = "1.0.0"; // Placeholder version
obj->module_vers.emplace("ota", ota_info);
}
};
if (queue_fn && !on_main) {
queue_fn(apply);
} else {
apply();
}
}
} // namespace Slic3r
+142
View File
@@ -0,0 +1,142 @@
#ifndef __AMS_PAYLOAD_HPP__
#define __AMS_PAYLOAD_HPP__
#include "bambu_networking.hpp"
#include <functional>
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
#include <vector>
namespace Slic3r {
// One lane of a multi-material system in the neutral shape every printer agent
// shares before it is rendered into the BBL AMS payload.
struct AmsTrayData {
int slot_index = 0; // 0-based global slot index
bool has_filament = false;
std::string tray_type; // Material type (e.g. "PLA", "ASA")
std::string tray_color; // Raw color (#RRGGBB, 0xRRGGBB, or RRGGBBAA)
std::string tray_info_idx; // OrcaFilamentLibrary preset id (optional)
int bed_temp = 0; // Optional
int nozzle_temp = 0; // Optional
};
// Normalize a driver color string to the BBL RRGGBBAA form; "00000000" when invalid.
std::string normalize_ams_color(const std::string& color);
// Map a reported material type to an OrcaFilamentLibrary generic family id.
std::string map_filament_type_to_generic_id(const std::string& filament_type);
// Parse a Moonraker `/server/database/item?namespace=lane_data` response body:
// result.value is keyed by lane, each entry carrying "lane", optional
// "material"/"color"/"bed_temp"/"nozzle_temp" and an optional presence signal
// ("has_filament" or "loaded"). Presence is preferred over material: a loaded
// lane with no declared material must not read as empty. Returns false when
// malformed or empty. Pure: tray_info_idx is left for resolve_tray_info_idx().
bool parse_moonraker_lane_data(const nlohmann::json& body,
std::vector<AmsTrayData>& trays,
int& max_lane_index);
// Outcome of one lane_data read. synced/none/unknown mirror OrcaSonar's
// REQ-STS-007 tri-state; error covers transport failure and unparsable bodies.
enum class LaneDataFetch { synced, none, unknown, error };
// Read and parse the Moonraker `lane_data` namespace from origin (OrcaSonar's
// façade or a real Moonraker — both emit the same shape). A non-empty api_key is
// sent as X-Api-Key. trays/max_lane_index are only populated on synced. The
// tri-state is preserved: 404 is unknown, an empty value object is none.
LaneDataFetch read_moonraker_lane_data(const std::string& origin,
const std::string& api_key,
std::vector<AmsTrayData>& trays,
int& max_lane_index);
// AMS units a flat lane range renders into (4 lanes per unit, rounding up);
// 0 when there are no lanes. max_lane_index is the highest index, or -1.
inline int ams_count_for_lanes(int max_lane_index)
{
return max_lane_index < 0 ? 0 : (max_lane_index + 4) / 4;
}
// Fill each tray's tray_info_idx from the loaded preset bundle (falling back to
// the generic family map). Reads GUI preset state, so it MUST run on the main
// thread. Ids already set by a vendor-aware resolver are left untouched.
void resolve_tray_info_idx(std::vector<AmsTrayData>& trays);
// Optional vendor-aware resolver, run on the main thread before the generic
// resolver. Agents with brand/color matching (Snapmaker, Creality) supply one
// so their results survive the shared path; the generic resolver only fills
// ids this leaves empty.
using TrayInfoResolver = std::function<void(std::vector<AmsTrayData>&)>;
// Assemble the BBL-format AMS JSON (ams[] units + ams_exist_bits/tray_exist_bits)
// that DevFilaSystemParser::ParseV1_0 consumes. Pure and GUI-free: the
// MachineObject mutation stays in build_ams_payload_for_device.
nlohmann::json build_bbl_ams_json(const std::vector<AmsTrayData>& trays,
int ams_count,
int max_lane_index);
// Render trays into the BBL AMS payload and populate the device's DevFilaSystem.
// The resolver and the MachineObject mutation both run on the main thread via
// queue_fn when set. printer_type: nullopt leaves obj->printer_type untouched
// (OrcaSonar, whose push_status already carries it); a value assigns it
// verbatim (Moonraker).
void build_ams_payload_for_device(const std::string& dev_id,
const std::optional<std::string>& printer_type,
int ams_count,
int max_lane_index,
const std::vector<AmsTrayData>& trays,
const QueueOnMainFn& queue_fn,
const TrayInfoResolver& vendor_resolver = {});
// Process-wide AMS capabilities parsed from info.get_capabilities. Before a
// reply, writes are permissive; afterward material operations require fms and
// their ams_ops token, while filament_setting uses filament_slots alone.
void register_ams_ops(const std::string& dev_id, const std::vector<std::string>& ops);
bool ams_op_supported(const std::string& dev_id, const std::string& op);
// Whether the device has a material system, from the get_capabilities reply's
// protocol.features.fms (falling back to a non-empty ams_ops). No record reads
// false: an unconfirmed printer must not advertise filament sync.
void register_ams_capability(const std::string& dev_id, bool has_ams);
bool has_ams_capability(const std::string& dev_id);
// Whether a valid protocol capability reply has been registered. Also separates
// the permissive pre-reply write behavior from an explicit no-FMS declaration.
bool ams_caps_known(const std::string& dev_id);
// Whether the device exposes the filament-slot model, from the
// get_capabilities reply's protocol.features.filament_slots. The slot model is
// connector state, independent of fms: a printer with no material hardware
// still has slots, so this alone enables filament sync (REQ-FMS-001).
void register_filament_slots(const std::string& dev_id, bool has_slots);
bool has_filament_slots(const std::string& dev_id);
// The values a fixed-vocabulary printer can hold for filament metadata, from
// protocol.filament_metadata.<driver>.values. Materials are the strings the
// status projection reports and colors are RRGGBBAA. `constrained` is true only
// when every writing driver declared values: one free-form driver keeps the
// full picker available.
struct FilamentMetadataValues
{
bool constrained = false;
std::vector<std::string> materials;
std::vector<std::string> colors;
};
void register_filament_metadata(const std::string& dev_id, const FilamentMetadataValues& values);
FilamentMetadataValues filament_metadata_values(const std::string& dev_id);
std::string normalize_qidi_material_family(const std::string& material);
// Whether a preset's filament type can be represented by the device's declared
// materials: an exact type, or the material category the two names share. An
// unconstrained or unanswered device accepts everything, and a type the reply
// never carried is kept rather than hidden.
bool filament_material_compatible(const std::string& dev_id, const std::string& preset_type);
// Forget a device's declared capabilities, so a reconnect starts from "no
// reply yet" instead of a stale declaration.
void clear_ams_caps(const std::string& dev_id);
} // namespace Slic3r
#endif
+18 -3
View File
@@ -150,11 +150,26 @@ void BBLPrinterAgent::set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud)
// Communication
// ============================================================================
std::string BBLPrinterAgent::ams_refresh_rfid_gcode(const std::string& slot_id)
{
return (boost::format("M620 R%1% \n") % slot_id).str();
}
std::string BBLPrinterAgent::ams_calibrate_gcode(int ams_id)
{
return (boost::format("M620 C%1% \n") % ams_id).str();
}
std::string BBLPrinterAgent::ams_select_tray_gcode(const std::string& tray_id)
{
return (boost::format("M620 P%1% \n") % tray_id).str();
}
int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode)
{
nlohmann::json j;
if (ams_id == -1) {
const std::string gcode = (boost::format("M620 R%1% \n") % slot_id).str();
const std::string gcode = ams_refresh_rfid_gcode(std::to_string(slot_id));
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
@@ -170,7 +185,7 @@ int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, in
int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode)
{
const std::string gcode = (boost::format("M620 C%1% \n") % ams_id).str();
const std::string gcode = ams_calibrate_gcode(ams_id);
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
@@ -180,7 +195,7 @@ int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int s
int BBLPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
const std::string gcode = (boost::format("M620 P%1% \n") % tray_id).str();
const std::string gcode = ams_select_tray_gcode(tray_id);
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
+3
View File
@@ -32,6 +32,9 @@ public:
int command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode) override;
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override;
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
static std::string ams_refresh_rfid_gcode(const std::string& slot_id);
static std::string ams_calibrate_gcode(int ams_id);
static std::string ams_select_tray_gcode(const std::string& tray_id);
int command_axis_control(std::string dev_id, std::string axis, double unit, double input_val, int speed,
bool is_core_xy, bool supports_mqtt_axis_control, int sequence_id, bool lan_mode) override;
std::string default_lan_username() const override { return "bblp"; }
+33 -371
View File
@@ -656,142 +656,27 @@ int MoonrakerPrinterAgent::set_queue_on_main_fn(QueueOnMainFn fn)
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays)
{
// This may be called from a background thread (e.g. run_status_stream's read loop,
// for subscription-mode agents) as well as from the GUI thread (Sidebar's pull-mode
// path). Everything below touches MachineObject/DevFilaSystem, which the GUI thread
// reads without locking — so the actual mutation must run on the main thread. Snapshot
// queue_on_main_fn and the two device_info fields we need up front, then defer the rest,
// mirroring dispatch_message's existing queue_fn ? queue_fn(x) : x() idiom.
// May run from the status-stream thread (subscription agents) or the GUI
// thread (Sidebar's pull path). Snapshot queue_on_main_fn here; the shared
// builder defers the preset lookup and MachineObject mutation to the main
// thread. A heavy-handed empty model_id must still be assigned, so the
// optional wrapper always carries the value here.
QueueOnMainFn queue_fn;
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
queue_fn = queue_on_main_fn;
}
build_ams_payload_for_device(device_info.dev_id, std::optional<std::string>(device_info.model_id), ams_count, max_lane_index, trays, queue_fn);
}
std::string dev_id = device_info.dev_id;
std::string model_id = device_info.model_id;
auto apply = [dev_id, model_id, ams_count, max_lane_index, trays]() {
// Look up MachineObject via DeviceManager
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
if (!dev_manager) {
return;
}
MachineObject* obj = dev_manager->get_my_machine(dev_id);
if (!obj) {
return;
}
// Build BBL-format JSON for DevFilaSystemParser::ParseV1_0
nlohmann::json ams_json = nlohmann::json::object();
nlohmann::json ams_array = nlohmann::json::array();
// Calculate ams_exist_bits and tray_exist_bits
unsigned long ams_exist_bits = 0;
unsigned long tray_exist_bits = 0;
for (int ams_id = 0; ams_id < ams_count; ++ams_id) {
ams_exist_bits |= (1 << ams_id);
nlohmann::json ams_unit = nlohmann::json::object();
ams_unit["id"] = std::to_string(ams_id);
ams_unit["info"] = "0002"; // treat as AMS_LITE
nlohmann::json tray_array = nlohmann::json::array();
int max_slot_in_this_ams = std::min(3, max_lane_index - ams_id * 4);
for (int slot_id = 0; slot_id <= max_slot_in_this_ams; ++slot_id) {
int slot_index = ams_id * 4 + slot_id;
// Find tray with matching slot_index
const AmsTrayData* tray = nullptr;
for (const auto& t : trays) {
if (t.slot_index == slot_index) {
tray = &t;
break;
}
}
nlohmann::json tray_json = nlohmann::json::object();
tray_json["id"] = std::to_string(slot_id);
tray_json["tag_uid"] = "0000000000000000";
if (tray && tray->has_filament) {
tray_exist_bits |= (1 << slot_index);
tray_json["tray_info_idx"] = tray->tray_info_idx;
tray_json["tray_type"] = tray->tray_type;
tray_json["tray_color"] = normalize_color_value(tray->tray_color);
// Add temperature data if provided
if (tray->bed_temp > 0) {
tray_json["bed_temp"] = std::to_string(tray->bed_temp);
}
if (tray->nozzle_temp > 0) {
tray_json["nozzle_temp_max"] = std::to_string(tray->nozzle_temp);
}
} else {
tray_json["tray_info_idx"] = "";
tray_json["tray_type"] = "";
tray_json["tray_color"] = "00000000";
tray_json["tray_slot_placeholder"] = "1";
}
tray_array.push_back(tray_json);
}
ams_unit["tray"] = tray_array;
ams_array.push_back(ams_unit);
}
// Format as hex strings (matching BBL protocol)
std::ostringstream ams_exist_ss;
ams_exist_ss << std::hex << std::uppercase << ams_exist_bits;
std::ostringstream tray_exist_ss;
tray_exist_ss << std::hex << std::uppercase << tray_exist_bits;
ams_json["ams"] = ams_array;
ams_json["ams_exist_bits"] = ams_exist_ss.str();
ams_json["tray_exist_bits"] = tray_exist_ss.str();
// Wrap in the expected structure for ParseV1_0
nlohmann::json print_json = nlohmann::json::object();
print_json["ams"] = ams_json;
// Call the parser to populate DevFilaSystem
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays";
// Set printer_type so update_sync_status() can match it against the preset's printer type.
// Without this, the comparison fails and all sync badges are cleared.
obj->printer_type = model_id;
// Set push counters so is_info_ready() returns true for pull-mode agents.
if (obj->m_push_count == 0) {
obj->m_push_count = 1;
}
if (obj->m_full_msg_count == 0) {
obj->m_full_msg_count = 1;
}
obj->last_push_time = std::chrono::system_clock::now();
// Set storage state - Moonraker printers use virtual_sdcard, storage is always available.
// This is required for SelectMachineDialog to allow printing (otherwise it blocks with "No SD card").
obj->GetStorage()->set_sdcard_state(DevStorage::HAS_SDCARD_NORMAL);
// Populate module_vers so is_info_ready() passes the version check.
// Moonraker printers don't have BBL-style version info, but we need a non-empty map.
if (obj->module_vers.empty()) {
DevFirmwareVersionInfo ota_info;
ota_info.name = "ota";
ota_info.sw_ver = "1.0.0"; // Placeholder version for Moonraker printers
obj->module_vers.emplace("ota", ota_info);
}
};
if (queue_fn) {
queue_fn(apply);
} else {
apply();
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, const TrayInfoResolver& vendor_resolver)
{
QueueOnMainFn queue_fn;
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
queue_fn = queue_on_main_fn;
}
build_ams_payload_for_device(device_info.dev_id, std::optional<std::string>(device_info.model_id), ams_count, max_lane_index, trays, queue_fn, vendor_resolver);
}
bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
@@ -802,29 +687,20 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
std::vector<AmsTrayData> trays;
int max_lane_index = 0;
// Try Moonraker filament data (more generic, supports any filament changer
// software that reports lane data to Moonraker like AFC and recent Happy
// Hare as of Feb 15, 2026)
if (fetch_moonraker_filament_data(trays, max_lane_index)) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Moonraker filament system with "
<< (max_lane_index + 1) << " lanes";
int ams_count = (max_lane_index + 4) / 4;
build_ams_payload(ams_count, max_lane_index, trays);
return true;
// Try Moonraker lane data first (generic; covers AFC and recent Happy Hare),
// then the Happy Hare object query.
const bool moonraker_lanes = fetch_moonraker_filament_data(trays, max_lane_index);
const bool happy_hare = !moonraker_lanes && fetch_hh_filament_info(trays, max_lane_index);
if (!moonraker_lanes && !happy_hare) {
// No MMU detected - normal for printers without one, not an error.
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: No MMU system detected (neither HH nor Moonraker)";
return false;
}
// Attempt Happy Hare first (more widely adopted, supports more filament changers)
if (fetch_hh_filament_info(trays, max_lane_index)) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Happy Hare MMU with "
<< (max_lane_index + 1) << " gates";
int ams_count = (max_lane_index + 4) / 4;
build_ams_payload(ams_count, max_lane_index, trays);
return true;
}
// No MMU detected - this is normal for printers without MMU, not an error
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: No MMU system detected (neither HH nor Moonraker)";
return false;
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected "
<< (moonraker_lanes ? "Moonraker filament system" : "Happy Hare MMU") << " with "
<< (max_lane_index + 1) << (moonraker_lanes ? " lanes" : " gates");
build_ams_payload(ams_count_for_lanes(max_lane_index), max_lane_index, trays);
return true;
}
CameraStreamMode MoonrakerPrinterAgent::get_camera_stream_mode() const
@@ -887,94 +763,7 @@ std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input)
return result;
}
std::string MoonrakerPrinterAgent::map_filament_type_to_generic_id(const std::string& filament_type)
{
const std::string upper = trim_and_upper(filament_type);
// Normalize reported material names (trimmed, uppercased) to an OrcaFilamentLibrary
// generic family. The family's filament_id is resolved from the loaded system presets
// below rather than hardcoded, so profile id re-mints never require touching this table.
// scripts/test_moonraker_lane_data.py parses this initializer; keep the {"A", "B"} format.
static const std::map<std::string, std::string> type_to_ofl_family = {
// PLA variants
{"PLA", "PLA"},
{"PLA-CF", "PLA-CF"},
{"PLA SILK", "PLA Silk"},
{"PLA-SILK", "PLA Silk"},
{"PLA HIGH SPEED", "PLA High Speed"},
{"PLA-HS", "PLA High Speed"},
{"PLA HS", "PLA High Speed"},
// ABS/ASA variants
{"ABS", "ABS"},
{"ASA", "ASA"},
// PETG/PET variants
{"PETG", "PETG"},
{"PET", "PETG"},
{"PCTG", "PCTG"},
// PA/Nylon variants
{"PA", "PA"},
{"NYLON", "PA"},
{"PA-CF", "PA-CF"},
{"PPA", "PPA-CF"},
{"PPA-CF", "PPA-CF"},
{"PPA-GF", "PPA-GF"},
// PC variants
{"PC", "PC"},
// PP/PE variants
{"PE", "PE"},
{"PP", "PP"},
// Support materials
{"PVA", "PVA"},
{"HIPS", "HIPS"},
{"BVOH", "BVOH"},
// TPU variants
{"TPU", "TPU"},
// Other materials
{"EVA", "EVA"},
{"PHA", "PHA"},
{"COPE", "CoPE"},
{"SBS", "SBS"},
};
auto it = type_to_ofl_family.find(upper);
if (it == type_to_ofl_family.end())
return UNKNOWN_FILAMENT_ID;
if (auto* bundle = GUI::wxGetApp().preset_bundle) {
const Preset* preset = bundle->filaments.find_preset("Generic " + it->second + " @System");
if (preset != nullptr && preset->is_system && !preset->filament_id.empty())
return preset->filament_id;
}
// Unknown material, or no loaded preset data to resolve against
return UNKNOWN_FILAMENT_ID;
}
// JSON helper methods - null-safe accessors
std::string MoonrakerPrinterAgent::safe_json_string(const nlohmann::json& obj, const char* key)
{
auto it = obj.find(key);
if (it != obj.end() && it->is_string())
return it->get<std::string>();
return "";
}
int MoonrakerPrinterAgent::safe_json_int(const nlohmann::json& obj, const char* key)
{
auto it = obj.find(key);
if (it != obj.end() && it->is_number())
return it->get<int>();
return 0;
}
std::string MoonrakerPrinterAgent::safe_array_string(const nlohmann::json& arr, int idx)
{
if (arr.is_array() && idx >= 0 && idx < static_cast<int>(arr.size()) && arr[idx].is_string())
@@ -989,137 +778,15 @@ int MoonrakerPrinterAgent::safe_array_int(const nlohmann::json& arr, int idx)
return 0;
}
std::string MoonrakerPrinterAgent::normalize_color_value(const std::string& color)
{
std::string value = color;
boost::trim(value);
// Remove 0x or 0X prefix if present
if (value.size() >= 2 && (value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0)) {
value = value.substr(2);
}
// Remove # prefix if present
if (!value.empty() && value[0] == '#') {
value = value.substr(1);
}
// Extract only hex digits
std::string normalized;
for (char c : value) {
if (std::isxdigit(static_cast<unsigned char>(c))) {
normalized.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(c))));
}
}
// If 6 hex digits, add FF alpha
if (normalized.size() == 6) {
normalized += "FF";
}
// Validate length - return default if invalid
if (normalized.size() != 8) {
return "00000000";
}
return normalized;
}
// Fetch filament info from moonraker database
bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index)
{
// Fetch lane data from Moonraker database
std::string url = join_url(device_info.base_url, "/server/database/item?namespace=lane_data");
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
if (!device_info.api_key.empty()) {
http.header("X-Api-Key", device_info.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
success = true;
} else {
http_error = "HTTP error: " + std::to_string(status);
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_error = err;
if (status > 0) {
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.perform_sync();
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Failed to fetch lane data: " << http_error;
return false;
}
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Invalid JSON response";
return false;
}
// Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } }
if (!json.contains("result") || !json["result"].contains("value") || !json["result"]["value"].is_object()) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Unexpected JSON structure or no lane_data found";
return false;
}
// Parse response into AmsTrayData
const auto& value = json["result"]["value"];
trays.clear();
max_lane_index = 0;
for (const auto& [lane_key, lane_obj] : value.items()) {
if (!lane_obj.is_object()) {
continue;
}
// Extract lane index from the "lane" field (tool number, 0-based)
std::string lane_str = safe_json_string(lane_obj, "lane");
int lane_index = -1;
if (!lane_str.empty()) {
try {
lane_index = std::stoi(lane_str);
} catch (...) {
lane_index = -1;
}
}
if (lane_index < 0) {
continue;
}
AmsTrayData tray;
tray.slot_index = lane_index;
tray.tray_color = safe_json_string(lane_obj, "color");
tray.tray_type = safe_json_string(lane_obj, "material");
tray.bed_temp = safe_json_int(lane_obj, "bed_temp");
tray.nozzle_temp = safe_json_int(lane_obj, "nozzle_temp");
tray.has_filament = !tray.tray_type.empty();
auto* bundle = GUI::wxGetApp().preset_bundle;
tray.tray_info_idx = bundle
? bundle->filaments.filament_id_by_type(tray.tray_type)
: map_filament_type_to_generic_id(tray.tray_type);
max_lane_index = std::max(max_lane_index, lane_index);
trays.push_back(tray);
}
if (trays.empty()) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: No lanes found";
return false;
}
return true;
// Only the synced outcome matters here: a missing namespace, an empty one,
// a transport error and an unreadable body all fall through to the Happy
// Hare query. tray_info_idx is resolved later, on the main thread, inside
// build_ams_payload_for_device.
return read_moonraker_lane_data(device_info.base_url, device_info.api_key, trays, max_lane_index) ==
LaneDataFetch::synced;
}
// Fetch filament info from Happy Hare MMU
@@ -1233,11 +900,6 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector<AmsTrayData>& tra
tray.bed_temp = 0; // HH doesn't provide bed temp in gate arrays
tray.has_filament = true;
auto* bundle = GUI::wxGetApp().preset_bundle;
tray.tray_info_idx = bundle
? bundle->filaments.filament_id_by_type(tray.tray_type)
: map_filament_type_to_generic_id(tray.tray_type);
max_lane_index = std::max(max_lane_index, gate_idx);
trays.push_back(tray);
}
+6 -18
View File
@@ -3,6 +3,7 @@
#include "IPrinterAgent.hpp"
#include "ICloudServiceAgent.hpp"
#include "AmsPayload.hpp"
#include <memory>
#include <mutex>
@@ -114,19 +115,12 @@ protected:
bool use_ssl = false;
} device_info;
// Tray data for AMS payload building
struct AmsTrayData {
int slot_index = 0; // 0-based slot index
bool has_filament = false;
std::string tray_type; // Material type (e.g., "PLA", "ASA")
std::string tray_color; // Raw color (#RRGGBB, 0xRRGGBB, or RRGGBBAA)
std::string tray_info_idx; // Setting ID (optional)
int bed_temp = 0; // Optional
int nozzle_temp = 0; // Optional
};
// Build ams JSON and call parser
// Build ams JSON and call parser (AmsTrayData is shared; see AmsPayload.hpp).
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
// Overload for agents with vendor-aware matching (Snapmaker): the resolver
// runs on the main thread inside the shared builder, preserving the match
// without touching preset state on the fetch worker.
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, const TrayInfoResolver& vendor_resolver);
// Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
@@ -151,9 +145,6 @@ protected:
// Trim whitespace and convert to uppercase
static std::string trim_and_upper(const std::string& input);
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
static std::string map_filament_type_to_generic_id(const std::string& filament_type);
// Send a G-code script via Moonraker (/printer/gcode/script)
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
bool send_gcode(const std::string& dev_id, const std::string& gcode,
@@ -225,11 +216,8 @@ private:
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index);
// JSON helper methods
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
static int safe_json_int(const nlohmann::json& obj, const char* key);
static std::string safe_array_string(const nlohmann::json& arr, int idx);
static int safe_array_int(const nlohmann::json& arr, int idx);
static std::string normalize_color_value(const std::string& color);
std::string ssdp_announced_host;
std::string ssdp_announced_id;
+454 -112
View File
@@ -1,11 +1,10 @@
#include "OrcaPrinterAgent.hpp"
#include "AmsPayload.hpp"
#include "IPrinterAgent.hpp"
#include "Http.hpp"
#include "NetworkAgentFactory.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "bambu_networking.hpp"
#include <boost/algorithm/string.hpp>
#include <boost/asio.hpp>
#include <boost/filesystem.hpp>
@@ -42,6 +41,8 @@ const std::string OrcaPrinterAgent_VERSION = "0.0.1";
namespace {
namespace fs = boost::filesystem;
constexpr int kVirtualTrayDeputyId = 254;
constexpr int kVirtualTrayMainId = 255;
// params.filename is normally the exported .3mf archive; the sliced G-code sits
// beside it with the same stem (".12345.0.3mf" -> ".12345.0.gcode"). params.dst_file,
@@ -114,6 +115,18 @@ std::string next_gcode_file_sequence_id()
return std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
}
// pushall is replay-cached per (namespace, command, sequence_id), so a refresh
// must not reuse the connect-band ids (20001..20004). Seed from the wall clock
// like next_gcode_file_sequence_id and bump once per call within a run.
std::string next_filament_refresh_sequence_id()
{
static std::atomic<uint64_t> counter{[] {
const auto now = std::chrono::system_clock::now().time_since_epoch();
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(now).count()) + 100000;
}()};
return std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
}
static constexpr const char* ORCASONAR_FALLBACK = "orcasonar";
bool fetch_orcasonar_body(const std::string& url, std::string& body)
@@ -539,7 +552,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
// removes its storage too. Process-wide and keyed by the globally-unique
// dev_id, with its own mutex; C++11 makes the one-time init thread-safe.
static std::unordered_map<std::string, double> nozzle_diameter_cache;
static std::mutex nozzle_diameter_cache_mutex;
static std::mutex nozzle_diameter_cache_mutex;
bool modified = false;
@@ -555,7 +568,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
// ("N/A" -> NozzleType::ntUndefine, as MoonrakerPrinterAgent
// does; Klipper has no Bambu nozzle type) onto later push_status
// frames that carry no real nozzle data.
const auto info_it = envelope.find("info");
const auto info_it = envelope.find("info");
const bool is_capabilities_reply = info_it != envelope.end() && info_it->is_object() &&
info_it->value("command", "") == "get_capabilities";
@@ -588,13 +601,10 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
nozzle_diameter_cache[dev_id] = nozzle_dia;
}
// The capabilities reply itself is forwarded unchanged.
}
else {
} else {
const auto print_it = envelope.find("print");
if (print_it != envelope.end() && print_it->is_object() &&
print_it->value("command", "") == "push_status" && !print_it->contains("nozzle_diameter")) {
if (print_it != envelope.end() && print_it->is_object() && print_it->value("command", "") == "push_status" &&
!print_it->contains("nozzle_diameter")) {
double nozzle_dia = 0.0;
{
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
@@ -605,7 +615,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
if (nozzle_dia > 0.0) {
(*print_it)["nozzle_diameter"] = nozzle_dia;
(*print_it)["nozzle_type"] = "N/A";
modified = true;
modified = true;
}
}
}
@@ -614,9 +624,112 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
return modified ? envelope.dump() : payload;
}
// One get_capabilities reply's AMS declaration. Not a dialect shim: it feeds
// get_filament_sync_mode() and the ams_ops write gate, so it must outlive
// merge_capabilities.
void OrcaPrinterAgent::register_ams_capabilities(const std::string& dev_id, const std::string& payload)
{
if (dev_id.empty() || payload.find("get_capabilities") == std::string::npos)
return;
const nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false);
if (!envelope.is_object())
return;
const auto info_it = envelope.find("info");
if (info_it == envelope.end() || !info_it->is_object() || info_it->value("command", "") != "get_capabilities")
return;
const auto caps_it = info_it->find("capabilities");
// A reply that is not a complete capabilities answer is ignored: treating a
// transient malformed reply as "no capabilities" would gate every AMS write
// until a good one arrives. Stale state is cleared on disconnect instead.
if (caps_it == info_it->end() || !caps_it->is_object())
return;
const auto proto_it = caps_it->find("protocol");
if (proto_it == caps_it->end() || !proto_it->is_object())
return;
// ams_ops is the write-op union the resolved drivers implement. Register it
// on every reply — empty when the key is absent — so "answered without ops"
// gates every AMS write (OPCP §7.8) and a later reply clears stale ops.
std::vector<std::string> ops;
const auto ops_it = proto_it->find("ams_ops");
if (ops_it != proto_it->end() && ops_it->is_array()) {
for (const auto& op : *ops_it)
if (op.is_string())
ops.push_back(op.get<std::string>());
}
register_ams_ops(dev_id, ops);
// features.fms is the authoritative "has a material system" flag
// (topology.material_units non-empty). Payloads without it fall back to a
// non-empty ams_ops.
bool has_ams = false;
bool fms_known = false;
const auto features_it = proto_it->find("features");
if (features_it != proto_it->end() && features_it->is_object()) {
const auto fms_it = features_it->find("fms");
if (fms_it != features_it->end() && fms_it->is_boolean()) {
has_ams = fms_it->get<bool>();
fms_known = true;
}
}
if (!fms_known)
has_ams = !ops.empty();
register_ams_capability(dev_id, has_ams);
// features.filament_slots is the connector's slot model (REQ-FMS-001),
// independent of fms: a printer with no material hardware still has slots.
bool has_slots = false;
if (features_it != proto_it->end() && features_it->is_object()) {
const auto slots_it = features_it->find("filament_slots");
if (slots_it != features_it->end() && slots_it->is_boolean())
has_slots = slots_it->get<bool>();
}
register_filament_slots(dev_id, has_slots);
// filament_metadata.<driver>.values publishes what a fixed-vocabulary
// driver can hold. It constrains the picker only when every writing driver
// declared values: one free-form driver (Happy Hare, AFC) keeps the full
// color picker and preset list available. Registered on every reply so a
// later one without the key clears a stale palette.
FilamentMetadataValues metadata;
const auto meta_it = proto_it->find("filament_metadata");
if (meta_it != proto_it->end() && meta_it->is_object() && !meta_it->empty()) {
const auto collect = [](std::vector<std::string>& out, const nlohmann::json& values, const char* key) {
const auto arr = values.find(key);
if (arr == values.end() || !arr->is_array())
return;
for (const auto& v : *arr) {
if (!v.is_string())
continue;
const std::string entry = v.get<std::string>();
if (std::find(out.begin(), out.end(), entry) == out.end())
out.push_back(entry);
}
};
bool all_constrained = true;
for (auto driver = meta_it->begin(); driver != meta_it->end() && all_constrained; ++driver) {
const auto values_it = driver.value().find("values");
if (values_it == driver.value().end() || !values_it->is_object() || values_it->empty()) {
all_constrained = false;
break;
}
collect(metadata.materials, *values_it, "material");
collect(metadata.colors, *values_it, "color");
}
if (all_constrained && (!metadata.materials.empty() || !metadata.colors.empty()))
metadata.constrained = true;
else
metadata = FilamentMetadataValues{};
}
register_filament_metadata(dev_id, metadata);
}
void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local)
{
parse_ipcam_info(dev_id, payload);
register_ams_capabilities(dev_id, payload);
maybe_refresh_filament_capabilities(dev_id, payload, local);
std::string merged_payload = merge_capabilities(dev_id, payload);
OnMessageFn fn;
@@ -627,10 +740,11 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str
q = queue_on_main_fn;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering " << (local ? "local" : "cloud") << " report payload dev_id=" << dev_id
<< " payload=" << merged_payload
<< " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null");
<< " payload=" << merged_payload << " callback=" << (fn ? "set" : "null")
<< " queue_on_main=" << (q ? "set" : "null");
if (!fn) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping " << (local ? "local" : "cloud") << " message because on_message_fn is not set"
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping " << (local ? "local" : "cloud")
<< " message because on_message_fn is not set"
<< " dev_id=" << dev_id;
return;
}
@@ -652,6 +766,7 @@ void OrcaPrinterAgent::dispatch_local_connect(int state, const std::string& dev_
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection callback state=" << state << " dev_id=" << dev_id << " message=" << message
<< " callback=" << (callback ? "set" : "null") << " queue_on_main=" << (queue ? "set" : "null");
if (!callback)
return;
@@ -703,26 +818,45 @@ int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int
int OrcaPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, int tray_id, int sequence_id, bool lan_mode)
{
(void) ams_id;
// int tray_number = 0;
// if (!parse_nonnegative_command_id(tray_id, tray_number)) {
// BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid RFID tray id=" << tray_id;
// return BAMBU_NETWORK_ERR_INVALID_HANDLE;
// }
return route_send(lan_mode, dev_id, build_ams_refresh_rfid_body(ams_id, tray_id, sequence_id));
}
int OrcaPrinterAgent::command_ams_calibrate(std::string /*dev_id*/, int /*ams_id*/, int /*sequence_id*/, bool /*lan_mode*/)
{
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: AMS calibration is not part of the OrcaSonar API";
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
}
std::string OrcaPrinterAgent::build_ams_change_filament_body(int tray_number, int sequence_id)
{
nlohmann::json j;
j["print"]["command"] = "ams_change_filament";
j["print"]["sequence_id"] = std::to_string(sequence_id);
// tray_id is the BBL tray id (ams_id*4 + tray). Send the coordinates, not a
// fabricated flat lane: the server's one resolver maps wide and sparse
// boxes correctly (REQ-STS-008 §7.8).
j["print"]["selector"] = "lane";
if (tray_number == kVirtualTrayMainId || tray_number == kVirtualTrayDeputyId) {
j["print"]["ams_id"] = tray_number;
j["print"]["slot_id"] = 0;
} else {
j["print"]["ams_id"] = tray_number / 4;
j["print"]["slot_id"] = tray_number % 4;
}
return j.dump();
}
std::string OrcaPrinterAgent::build_ams_refresh_rfid_body(int ams_id, int tray_or_slot_id, int sequence_id)
{
const int tray_id = ams_id == -1
? tray_or_slot_id
: (ams_id >= 240 && ams_id <= 255 ? ams_id : ams_id * 4 + tray_or_slot_id);
nlohmann::json j;
j["print"]["command"] = "ams_get_rfid";
j["print"]["sequence_id"] = std::to_string(sequence_id);
j["print"]["tray_id"] = tray_id;
return route_send(lan_mode, dev_id, j.dump());
}
int OrcaPrinterAgent::command_ams_calibrate(std::string /*dev_id*/, int /*ams_id*/, int /*sequence_id*/, bool /*lan_mode*/)
{
// OrcaSonar has no ams_calibrate command. Do not send the Bambu M620 C
// dialect through the vendor-neutral OrcaSonar gcode_line command.
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: AMS calibration is not part of the OrcaSonar API";
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
return j.dump();
}
int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
@@ -733,51 +867,7 @@ int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tr
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
nlohmann::json j;
j["print"]["command"] = "ams_change_filament";
j["print"]["sequence_id"] = std::to_string(sequence_id);
j["print"]["target"] = tray_number;
return route_send(lan_mode, dev_id, j.dump());
}
int OrcaPrinterAgent::command_start_camera(std::string /*dev_id*/)
{
// OrcaSonar exposes camera.ipcam_* controls, not the legacy start_camera
// operation used by the Bambu agent.
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: camera start is not part of the OrcaSonar API";
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
}
int OrcaPrinterAgent::command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode)
{
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = "G90\n";
j["print"]["sequence_id"] = std::to_string(sequence_id);
return route_send(lan_mode, dev_id, j.dump());
}
int OrcaPrinterAgent::command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode)
{
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = "G29\n";
j["print"]["sequence_id"] = std::to_string(sequence_id);
return route_send(lan_mode, dev_id, j.dump());
}
int OrcaPrinterAgent::command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode)
{
nlohmann::json j;
j["print"]["sequence_id"] = std::to_string(sequence_id);
if (supports_mqtt_homing) {
j["print"]["command"] = "back_to_center";
} else {
// Preserve the existing safety behavior: never home Z/Y during a print.
j["print"]["command"] = "gcode_line";
j["print"]["param"] = is_printing ? "G28 X\n" : "G28\n";
}
return route_send(lan_mode, dev_id, j.dump());
return route_send(lan_mode, dev_id, build_ams_change_filament_body(tray_number, sequence_id));
}
int OrcaPrinterAgent::command_set_bed(std::string dev_id, int temp, bool /*supports_mqtt_bed_ctrl*/, int sequence_id, bool lan_mode)
@@ -834,6 +924,58 @@ int OrcaPrinterAgent::command_axis_control(std::string dev_id,
return route_send(lan_mode, dev_id, j.dump());
}
FilamentSyncMode OrcaPrinterAgent::get_filament_sync_mode() const
{
std::string dev_id;
{
std::lock_guard<std::mutex> lock(state_mutex);
dev_id = (m_current_connection == LAN) ? m_lan_dev_id : selected_machine;
}
if (!dev_id.empty() && (has_ams_capability(dev_id) || has_filament_slots(dev_id))) {
return FilamentSyncMode::subscription;
}
return FilamentSyncMode::none;
}
// Moonraker's client bootstrap: /access/api_key hands the façade key to a
// trusted source (the default LAN ranges include the slicer). Cache it per
// connection generation. If the request is refused — a hardened trusted_clients
// list, or no façade — fall back to the access code, which is what earlier
// builds sent, so behavior degrades to the status quo rather than breaking.
std::string OrcaPrinterAgent::lan_api_key(const std::string& origin)
{
if (origin.empty())
return {};
std::string fallback;
uint64_t gen;
{
std::lock_guard<std::mutex> lock(state_mutex);
if (!m_lan_api_key.empty() && m_lan_api_key_gen == m_lan_generation.load())
return m_lan_api_key;
fallback = m_lan_password;
gen = m_lan_generation.load();
}
std::string resolved;
if (std::string body; fetch_orcasonar_body(origin + "/access/api_key", body)) {
auto j = nlohmann::json::parse(body, nullptr, false, true);
if (!j.is_discarded() && j.contains("result") && j["result"].is_string())
resolved = j["result"].get<std::string>();
}
if (resolved.empty()) {
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: /access/api_key unavailable; using the access code as X-Api-Key";
resolved = fallback;
}
std::lock_guard<std::mutex> lock(state_mutex);
if (m_lan_generation.load() == gen && !resolved.empty()) {
m_lan_api_key = resolved;
m_lan_api_key_gen = gen;
}
return resolved;
}
bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port)
{
std::string s = dev_ip;
@@ -986,6 +1128,38 @@ void OrcaPrinterAgent::emit_connect_sequence(const std::string& dev_id,
request(build_get_capabilities(seq(4)));
}
void OrcaPrinterAgent::request_filament_capabilities(const std::string& dev_id, bool local)
{
if (dev_id.empty())
return;
OrcaMqttConnection* conn = get_appropriate_mqtt_connection(local);
if (!conn)
return;
conn->send_request(dev_id, build_get_capabilities(next_filament_refresh_sequence_id()));
conn->send_request(dev_id, build_pushall(next_filament_refresh_sequence_id()));
}
void OrcaPrinterAgent::maybe_refresh_filament_capabilities(const std::string& dev_id, const std::string& payload, bool local)
{
if (dev_id.empty() || ams_caps_known(dev_id))
return;
if (payload.find("push_status") == std::string::npos)
return;
// Only filament state is a reason to ask: ams_exist_bits/tray_exist_bits or
// the vir_slot array. A status without either cannot resolve topology.
if (payload.find("\"vir_slot\"") == std::string::npos && payload.find("\"ams\":") == std::string::npos)
return;
{
std::lock_guard<std::mutex> l(state_mutex);
const auto now = std::chrono::steady_clock::now();
const auto it = m_filament_caps_refresh_at.find(dev_id);
if (it != m_filament_caps_refresh_at.end() && now - it->second < std::chrono::seconds(5))
return;
m_filament_caps_refresh_at[dev_id] = now;
}
request_filament_capabilities(dev_id, local);
}
void OrcaPrinterAgent::on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation)
{
// Called from both connect paths with whichever epoch that path captured; the two
@@ -1012,6 +1186,8 @@ int OrcaPrinterAgent::connect_printer(const PrinterConnectionParams& params)
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: connect_printer rejected unparsable LAN endpoint dev_ip=" << params.host;
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
if (!params.port.empty())
port = params.port;
disconnect_printer();
const uint64_t gen = ++m_lan_generation;
@@ -1031,9 +1207,12 @@ int OrcaPrinterAgent::connect_printer(const PrinterConnectionParams& params)
CurrentConn previous_connection;
{
std::lock_guard<std::mutex> l(state_mutex);
previous_connection = m_current_connection;
m_lan_dev_id = params.dev_id;
m_lan_url = cfg.url;
previous_connection = m_current_connection;
m_lan_dev_id = params.dev_id;
m_lan_url = cfg.url;
m_lan_password = params.password; // access code; the façade key is bootstrapped lazily
m_lan_api_key.clear();
m_lan_api_key_gen = gen;
m_camera_stream_mode = CameraStreamMode::none;
m_camera_url.clear();
m_current_connection = LAN;
@@ -1107,6 +1286,7 @@ int OrcaPrinterAgent::disconnect_printer()
doomed = std::move(lan_mqtt_connection);
prev_dev = m_lan_dev_id;
m_lan_dev_id.clear();
m_lan_api_key.clear();
if (m_current_connection == LAN) {
m_current_connection = NONE;
m_camera_stream_mode = CameraStreamMode::none;
@@ -1119,6 +1299,10 @@ int OrcaPrinterAgent::disconnect_printer()
<< " connected=" << (doomed && doomed->is_connected() ? "yes" : "no")
<< " transport=" << connection_type_name(previous_connection) << "->"
<< connection_type_name(current_connection);
// Drop the device's declared capabilities so a reconnect starts from "no
// reply yet" instead of a stale declaration from the previous session.
if (!prev_dev.empty())
clear_ams_caps(prev_dev);
// Tell the printer to stop pushing and drop the report topic before the socket
// goes away (§3.2/§5.4: deselect issues pushing.stop on both transports).
if (doomed && !prev_dev.empty() && doomed->is_connected()) {
@@ -1136,11 +1320,150 @@ int OrcaPrinterAgent::disconnect_printer()
int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int /*qos*/, int /*flag*/)
{ return route_send(/*is_lan=*/true, dev_id, json_str); }
// Rewrite recognized Bambu-coordinate requests to the canonical OrcaSonar
// bodies (OPCP spec §7.8) and enforce the device's declared capabilities.
// Explicit selectors and legacy target requests retain their wire semantics.
std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id, const std::string& json_str, bool* unsupported)
{
if (unsupported)
*unsupported = false;
try {
auto envelope = nlohmann::json::parse(json_str, nullptr, false);
if (envelope.is_discarded() || !envelope.is_object() || !envelope.contains("print") || !envelope["print"].is_object())
return json_str;
auto& print = envelope["print"];
const std::string cmd = print.value("command", std::string());
if (cmd.empty() || (cmd.rfind("ams_", 0) != 0 && cmd != "auto_stop_ams_dry"))
return json_str;
// filament_setting is exempt from the ams_ops union: filament_slots
// advertises the canonical slot write, while native metadata support is
// reported separately by the driver (OrcaSonar OPCP §7.8).
auto op_allowed = [&dev_id](const std::string& o) {
if (o == "filament_setting")
return !ams_caps_known(dev_id) || has_filament_slots(dev_id);
return o.empty() || ams_op_supported(dev_id, o);
};
const bool fms_allowed = !ams_caps_known(dev_id) || has_ams_capability(dev_id);
if (cmd == "ams_change_filament" && print.contains("selector")) {
// Already canonical (e.g. command_ams_select_tray): gate the op,
// but never rewrite the body.
const std::string sel = print.value("selector", std::string());
bool lane_has_coordinates = false;
bool coordinate_is_external = false;
if (sel == "lane") {
const auto ams_it = print.find("ams_id");
const auto slot_it = print.find("slot_id");
lane_has_coordinates = ams_it != print.end() && ams_it->is_number_integer()
&& slot_it != print.end() && slot_it->is_number_integer();
if (lane_has_coordinates) {
const int ams = ams_it->get<int>();
coordinate_is_external = ams >= 240 && ams <= 255;
}
}
const bool selector_op_allowed = sel == "lane"
? (lane_has_coordinates
? op_allowed(coordinate_is_external ? "external" : "change_filament")
: (op_allowed("change_filament") || op_allowed("external")))
: op_allowed(sel);
const bool allowed = fms_allowed && selector_op_allowed;
if (!allowed && unsupported)
*unsupported = true;
return json_str;
}
auto int_or = [&print](const char* key, int fallback) {
const auto it = print.find(key);
return (it != print.end() && it->is_number_integer()) ? it->get<int>() : fallback;
};
std::string op;
if (cmd == "ams_change_filament") {
const int target = int_or("target", -1);
const int slot = int_or("slot_id", -1);
const int ams = int_or("ams_id", -1);
const bool has_target = target >= 0 && target <= 255 && print.contains("target");
const bool legacy_unload = target == 255 && slot == 255;
const bool has_coordinates = ams >= 0 && slot >= 0;
// The old request path retains macro semantics. Only translate the
// Slicer-generated Bambu form when its coordinates identify a slot.
const bool ambiguous_external_target = target == 255 && ams < 240;
if (!has_target || legacy_unload || ambiguous_external_target || !has_coordinates || ams > 255) {
const bool legacy_op_allowed = op_allowed("change_filament")
|| op_allowed("external") || op_allowed("unload");
if ((!fms_allowed || !legacy_op_allowed) && unsupported)
*unsupported = true;
return json_str;
}
print.erase("target");
print.erase("tray_id");
print["selector"] = "lane";
print["ams_id"] = ams;
print["slot_id"] = slot;
op = ams >= 240 ? "external" : "change_filament";
} else if (cmd == "ams_filament_setting") {
op = "filament_setting";
const int ams = int_or("ams_id", -1);
const int slot = int_or("slot_id", -1);
if (ams >= 0 && slot >= 0) {
// Coordinates are the server resolver's own form (REQ-FMS-001
// §6.2.13). Forward them unchanged for box and external spools;
// never fabricate a flat lane (tray id != layout lane).
print["ams_id"] = ams;
print["slot_id"] = slot;
print.erase("tray_id");
}
// A lane-only or coordinate-less body is left as sent so the server
// applies the dual-form rule and its own -19, never -5.
} else if (cmd == "ams_control") {
std::string action = print.value("action", print.value("param", std::string()));
op = action; // only "pause" is ever declared; the rest gate out
} else if (cmd == "ams_user_setting") {
op = "user_setting";
} else if (cmd == "ams_get_rfid") {
op = "get_rfid";
if (!print.contains("tray_id")) {
const int ams = int_or("ams_id", -1);
const int slot = int_or("slot_id", -1);
if (ams == -1 && slot >= 0) {
print["tray_id"] = slot;
print.erase("ams_id");
print.erase("slot_id");
} else if (ams >= 240 && ams <= 255 && slot >= 0) {
print["tray_id"] = ams;
print.erase("ams_id");
print.erase("slot_id");
} else if (ams >= 0 && ams < 240 && slot >= 0) {
print["tray_id"] = ams * 4 + slot; // legacy ams+slot call shape
print.erase("ams_id");
print.erase("slot_id");
}
}
} else if (cmd == "auto_stop_ams_dry") {
op = "stop_dry";
}
const bool needs_fms = cmd != "ams_filament_setting" && !op.empty();
if (((needs_fms && !fms_allowed) || !op_allowed(op)) && unsupported)
*unsupported = true;
return envelope.dump();
} catch (const std::exception&) {
return json_str;
}
}
int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str)
{
bool unsupported = false;
const std::string canonical = canonicalize_ams_payload(dev_id, json_str, &unsupported);
if (unsupported) {
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send: ams op not declared by device capabilities, dev_id=" << dev_id;
return ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE;
}
std::string command = "<unparsed>";
try {
const nlohmann::json envelope = nlohmann::json::parse(json_str);
const nlohmann::json envelope = nlohmann::json::parse(canonical);
for (const char* namespace_name : {"pushing", "info", "print", "system", "camera", "xcam", "upgrade", "event", "files"}) {
const auto namespace_it = envelope.find(namespace_name);
if (namespace_it != envelope.end() && namespace_it->is_object()) {
@@ -1162,7 +1485,7 @@ int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const s
OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan);
if (!conn)
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
const bool queued = conn->send_request(dev_id, json_str);
const bool queued = conn->send_request(dev_id, canonical);
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send command=" << command << " queued=" << queued << " is_lan=" << is_lan
<< " dev_id=" << dev_id;
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
@@ -1262,11 +1585,13 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id)
{
auto* cloud = get_orca_cloud_agent();
std::string previous;
std::string lan_dev_id;
CurrentConn previous_connection;
CurrentConn current_connection;
{
std::lock_guard<std::mutex> lock(state_mutex);
previous_connection = m_current_connection;
lan_dev_id = m_lan_dev_id;
// An empty cloud selection must not clear an independently active LAN
// selection. Conversely, selecting a cloud machine with the same id
// while LAN is active is still a transport switch and must proceed.
@@ -1295,6 +1620,10 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id)
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous << " new=" << dev_id
<< " cloud=" << (cloud ? "set" : "<null>") << " transport=" << connection_type_name(previous_connection) << "->"
<< connection_type_name(current_connection);
// Forget the deselected cloud device's declaration so a later session starts
// from "no reply yet". A LAN feed for the same id keeps its capabilities.
if (!previous.empty() && previous != dev_id && previous != lan_dev_id)
clear_ams_caps(previous);
if (!cloud) {
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no Orca cloud agent";
return BAMBU_NETWORK_SUCCESS;
@@ -1336,9 +1665,9 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id)
auto state_handler = [this](bool connected, bool initial) {
if (!connected || initial)
return;
auto* current_cloud = get_orca_cloud_agent();
auto* current_cloud = get_orca_cloud_agent();
OrcaMqttConnection* current_conn = current_cloud ? current_cloud->get_mqtt_connection() : nullptr;
const std::string selected = get_user_selected_machine();
const std::string selected = get_user_selected_machine();
if (current_conn && !selected.empty())
on_connected(selected, current_conn, m_cloud_generation.load());
};
@@ -1361,7 +1690,8 @@ AgentInfo OrcaPrinterAgent::get_agent_info_static()
// Print Job Operations - All Stubs
// ============================================================================
// Simply uploads the file to the printer via HTTP (cloud) then sends a HTTP request to start print. In the future, this might be a MQTT command to start print instead of HTTP.
// Simply uploads the file to the printer via HTTP (cloud) then sends a HTTP request to start print. In the future, this might be a MQTT
// command to start print instead of HTTP.
int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
(void) wait_fn;
@@ -1468,37 +1798,49 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
std::string http_error;
std::string response_body;
// One façade key for the whole upload exchange (directory probe + POST).
// If the bootstrapped key is unavailable (and there was no stored access
// code), fall back to the job's own password rather than sending none.
std::string api_key = lan_api_key(origin);
if (api_key.empty())
api_key = params.password;
// check if printer has enough storage
Http::get(origin + "/server/files/directory?path=gcodes")
.on_complete([&](std::string body, unsigned status) {
if (body.empty()) {
http_status = 400;
http_error = "Failed to get gcodes directory.";
}
int free = 0;
nlohmann::json json = nlohmann::json::parse(body);
if (json.contains("result")) {
json = json["result"];
if (json.contains("disk_usage")) {
json = json["disk_usage"];
if (json.contains("free"))
free = json["free"].get<int>();
{
auto dir_req = Http::get(origin + "/server/files/directory?path=gcodes");
if (!api_key.empty())
dir_req.header("X-Api-Key", api_key);
dir_req
.on_complete([&](std::string body, unsigned status) {
if (body.empty()) {
http_status = 400;
http_error = "Failed to get gcodes directory.";
}
}
if (free < file_size) {
http_status = 507;
http_error = "Not enough storage on the printer.";
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_status = status;
http_error = std::move(err);
response_body = std::move(body);
})
.perform_sync();
int free = 0;
nlohmann::json json = nlohmann::json::parse(body);
if (json.contains("result")) {
json = json["result"];
if (json.contains("disk_usage")) {
json = json["disk_usage"];
if (json.contains("free"))
free = json["free"].get<int>();
}
}
if (free < file_size) {
http_status = 507;
http_error = "Not enough storage on the printer.";
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_status = status;
http_error = std::move(err);
response_body = std::move(body);
})
.perform_sync();
}
if (http_status >= 400) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " failed with error code: " << http_status << ", " << http_error;
@@ -1506,8 +1848,8 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
}
auto http = Http::post(origin + "/server/files/upload");
if (!params.password.empty())
http.header("X-Api-Key", params.password); // trusted LAN facades may not require it; harmless when they do not
if (!api_key.empty())
http.header("X-Api-Key", api_key); // bootstrapped façade key, or the access code fallback
http.form_add("root", "gcodes")
.form_add("print", "false")
.form_add_file("file", source, upload_name)
+54 -10
View File
@@ -6,12 +6,14 @@
#include "OrcaCloudServiceAgent.hpp"
#include "OrcaMqttConnection.hpp"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
#include <string>
#include <mutex>
#include <memory>
#include <thread>
#include <unordered_map>
namespace Slic3r {
@@ -82,10 +84,6 @@ public:
int command_ams_refresh_rfid(std::string dev_id, int ams_id, int tray_id, int sequence_id, bool lan_mode) override;
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override;
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
int command_start_camera(std::string dev_id) override;
int command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode) override;
int command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode) override;
int command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode) override;
int command_set_bed(std::string dev_id, int temp, bool supports_mqtt_bed_ctrl, int sequence_id, bool lan_mode) override;
int command_set_nozzle(std::string dev_id, int temp, int sequence_id, bool lan_mode) override;
int command_axis_control(std::string dev_id,
@@ -98,6 +96,13 @@ public:
int sequence_id,
bool lan_mode) override;
// Filament sync (subscription): `subscription` when the selected printer
// reports a material system (get_capabilities protocol.features.fms) or the
// filament-slot model (protocol.features.filament_slots), `none` otherwise.
// The AMS view comes from the pushed `ams`/`vir_slot` in push_status, so
// there is no fetch to keep fresh and the mode is transport-agnostic.
FilamentSyncMode get_filament_sync_mode() const override;
// Test-only: drive emit_connect_sequence directly (no socket).
void run_connect_sequence_for_test(const std::string& dev_id)
{
@@ -106,10 +111,19 @@ public:
// Test-only: advance the LAN connection epoch without a connect/disconnect cycle.
void bump_lan_generation_for_test() { ++m_lan_generation; }
// Test-only: the same for the (independent) cloud selection epoch.
void bump_cloud_generation_for_test() { ++m_cloud_generation; }
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; }
// Test-only: Bambu ams_* wire JSON -> canonical OrcaSonar bodies (§7.8).
// Returns the (possibly rewritten) payload; *unsupported is set when the
// device's declared ams_ops exclude the operation (CAP_NOT_AVAILABLE in
// the live route_send path).
static std::string canonicalize_ams_payload(const std::string& dev_id, const std::string& json_str, bool* unsupported);
// Test-only: the canonical ams_change_filament body for a BBL tray id
// (ams_id*4 + tray). command_ams_select_tray routes this exact body.
static std::string build_ams_change_filament_body(int tray_number, int sequence_id);
// Build an RFID request from coordinates or a legacy flat tray id.
static std::string build_ams_refresh_rfid_body(int ams_id, int tray_or_slot_id, int sequence_id);
protected:
// Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI
@@ -128,6 +142,12 @@ protected:
// per-rule detail. Returns the payload unchanged when no rule applies.
std::string merge_capabilities(const std::string& dev_id, const std::string& payload);
// Register one get_capabilities reply's AMS declaration: ams_ops (empty when
// the reply omits it, so "answered without ops" gates every write) and
// protocol.features.fms. Kept separate from merge_capabilities so dropping
// that shim cannot silently drop the capability registry.
void register_ams_capabilities(const std::string& dev_id, const std::string& payload);
// Report the asynchronous LAN connection state using the same callback contract as
// the other printer agents. The transport result cannot be returned by
// connect_printer(), which only starts the worker.
@@ -151,6 +171,17 @@ protected:
virtual void emit_connect_sequence(const std::string& dev_id,
std::function<void(const std::string&)> subscribe,
std::function<void(const std::string&)> request);
// Re-ask a device for its capabilities and a full status. Sent when a
// filament frame arrives before the capabilities reply (the topology
// bootstrapped after connect, or Klipper restarted), so a session that
// latched FilamentSyncMode::none can still reach subscription mode.
virtual void request_filament_capabilities(const std::string& dev_id, bool local);
// deliver_to_sink hook: re-request capabilities on a filament frame while
// the device's topology is still unconfirmed, throttled per device.
void maybe_refresh_filament_capabilities(const std::string& dev_id, const std::string& payload, bool local);
static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band
static std::string build_pushing_start(const std::string& sequence_id);
static std::string build_pushing_stop(const std::string& sequence_id);
@@ -189,10 +220,23 @@ private:
std::unique_ptr<OrcaSonarDiscovery> m_discovery;
std::string m_lan_dev_id; // guarded by state_mutex
std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session
std::string m_lan_dev_id; // guarded by state_mutex
std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session
std::string m_lan_password; // guarded by state_mutex — MQTT access code; X-Api-Key fallback
std::string m_lan_api_key; // guarded by state_mutex — cached Moonraker-façade key, "" = unresolved
uint64_t m_lan_api_key_gen = 0; // m_lan_generation the cached key belongs to
CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex
std::string m_camera_url; // guarded by state_mutex
std::string m_camera_url; // guarded by state_mutex
// Last capability re-request per device; bounds the refresh to one per
// device while a filament frame keeps arriving without a reply.
std::unordered_map<std::string, std::chrono::steady_clock::time_point> m_filament_caps_refresh_at; // guarded by state_mutex
// The Moonraker-façade X-Api-Key, bootstrapped from /access/api_key (trusted
// clients only) and cached per connection generation; falls back to the
// access code when the endpoint is unavailable (untrusted/hardened config).
// Used by the LAN upload path.
std::string lan_api_key(const std::string& origin);
OrcaCloudServiceAgent* get_orca_cloud_agent();
+2 -20
View File
@@ -1,6 +1,7 @@
#include "QidiPrinterAgent.hpp"
#include "Http.hpp"
#include "IPrinterAgent.hpp"
#include "slic3r/Utils/AmsPayload.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp"
@@ -507,26 +508,7 @@ std::string QidiPrinterAgent::infer_series_id(const std::string& model_id, const
std::string QidiPrinterAgent::normalize_filament_type(const std::string& filament_type)
{
const std::string upper = trim_and_upper(filament_type);
if (upper.find("PLA") != std::string::npos)
return "PLA";
if (upper.find("ABS") != std::string::npos)
return "ABS";
if (upper.find("PETG") != std::string::npos)
return "PETG";
if (upper.find("TPU") != std::string::npos)
return "TPU";
if (upper.find("ASA") != std::string::npos)
return "ASA";
if (upper.find("PA") != std::string::npos || upper.find("NYLON") != std::string::npos)
return "PA";
if (upper.find("PC") != std::string::npos)
return "PC";
if (upper.find("PVA") != std::string::npos)
return "PVA";
return upper;
return normalize_qidi_material_family(filament_type);
}
} // namespace Slic3r
+35 -20
View File
@@ -239,25 +239,6 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
tray.tray_color = safe_at(filament_color, i, default_color);
auto* bundle = GUI::wxGetApp().preset_bundle;
// Try to find a matching preset for this filament based on vendor, type and color.
// If not found, default to traditional search by type only or generic type mapping.
if (bundle) {
std::string vendor = safe_at(filament_vendor, i, empty_str);
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
tray.tray_color);
if (!filament_id.empty()) {
tray.tray_info_idx = filament_id;
BOOST_LOG_TRIVIAL(warning)
<< "Filament sync: Found manufacturer-specific profile for slot " << i << ": " << filament_id;
} else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
}
// Extract NFC temperature data if available
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
auto& nfc_slot = nfc_info[i];
@@ -272,12 +253,46 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
trays.emplace_back(std::move(tray));
}
build_ams_payload(1, slot_count - 1, trays);
// Preset matching (vendor + closest color) reads GUI preset state, so it
// runs on the main thread via this resolver, not on the fetch worker.
std::vector<std::string> vendors;
vendors.reserve(slot_count);
for (int i = 0; i < slot_count; ++i)
vendors.push_back(safe_at(filament_vendor, i, empty_str));
build_ams_payload(1, slot_count - 1, trays,
[vendors](std::vector<AmsTrayData>& resolved) { resolve_snapmaker_tray_info(resolved, vendors); });
}).detach();
return true;
}
void SnapmakerPrinterAgent::resolve_snapmaker_tray_info(std::vector<AmsTrayData>& trays,
const std::vector<std::string>& vendors)
{
auto* bundle = GUI::wxGetApp().preset_bundle;
for (auto& tray : trays) {
if (!tray.has_filament)
continue;
const std::string vendor = (tray.slot_index >= 0 && tray.slot_index < static_cast<int>(vendors.size()))
? vendors[tray.slot_index]
: std::string();
if (bundle) {
// Try a matching preset by vendor, type and color; fall back to
// type only, then to the generic family map.
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type, tray.tray_color);
if (!filament_id.empty()) {
tray.tray_info_idx = filament_id;
BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << tray.slot_index << ": " << filament_id;
} else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
}
}
}
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
{
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
@@ -6,6 +6,7 @@
#include <atomic>
#include <cstdint>
#include <string>
#include <vector>
namespace Slic3r {
@@ -28,6 +29,12 @@ private:
// Combine filament_type + filament_sub_type into a unified type string
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
// Resolve tray_info_idx for the collected trays. Reads the GUI preset
// bundle, so it is invoked on the main thread from within the shared
// build_ams_payload_for_device() (never on the fetch worker).
static void resolve_snapmaker_tray_info(std::vector<AmsTrayData>& trays,
const std::vector<std::string>& vendors);
void start_camera_monitor();
void on_status_loop_tick(const std::string& dev_id) override;
@@ -1407,6 +1407,21 @@ TEST_CASE("Filaments offered for a machine follow the app's compatibility rule",
}
}
TEST_CASE("Printer model ids resolve to vendor display names", "[Preset][Bundle]")
{
PresetBundle bundle;
VendorProfile qidi("Qidi");
qidi.name = "Qidi";
VendorProfile::PrinterModel model;
model.model_id = "Qidi-Q1Pro";
model.name = "Qidi Q1 Pro";
qidi.models.push_back(model);
bundle.vendors.emplace(qidi.id, qidi);
CHECK(bundle.get_printer_model_display_name("Qidi-Q1Pro") == "Qidi Q1 Pro");
CHECK(bundle.get_printer_model_display_name("unknown-model").empty());
}
namespace {
@@ -5482,3 +5497,35 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th
CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape"));
}
}
// OrcaSonar and the Qidi Box report a material type but no slicer preset id.
// The Orca agent's AMS sync path fills one with filament_id_by_type, whose
// modifier stripping ("PETG Basic" -> the PETG family) this locks in.
TEST_CASE("filament_id_by_type resolves a modifier type to its generic family", "[Preset][Filament]")
{
PresetBundle bundle;
Preset &pla = add_inmemory_preset(bundle.filaments, "Generic PLA @System");
pla.is_system = true;
pla.is_visible = true;
pla.is_compatible = true;
pla.filament_id = "GFL99";
pla.config.option<ConfigOptionStrings>("filament_type")->values = {"PLA"};
Preset &petg = add_inmemory_preset(bundle.filaments, "Generic PETG @System");
petg.is_system = true;
petg.is_visible = true;
petg.is_compatible = true;
petg.filament_id = "GFG99";
petg.config.option<ConfigOptionStrings>("filament_type")->values = {"PETG"};
CHECK(bundle.filaments.filament_id_by_type("PETG Basic") == "GFG99");
CHECK(bundle.filaments.filament_id_by_type("PLA") == "GFL99");
// The strict overload reports no match instead of binding a fallback.
std::string out = "unchanged";
CHECK_FALSE(bundle.filaments.filament_id_by_type("Kevlar", out));
CHECK(out == "unchanged");
CHECK(bundle.filaments.filament_id_by_type("PETG Basic", out));
CHECK(out == "GFG99");
}
+2
View File
@@ -6,6 +6,8 @@ add_executable(${_TEST_NAME}_tests
test_dev_mapping.cpp
test_filament_bitmap_utils.cpp
test_device_progress.cpp
test_device_manager.cpp
test_ams_item.cpp
test_device_manager_integration.cpp
test_web_media_controller.cpp
test_network_versions.cpp
+78
View File
@@ -0,0 +1,78 @@
// why: match the GUI include order to avoid rpcndr.h byte/std::byte
// ambiguity in the Windows COM headers.
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include <wx/timer.h>
#include "slic3r/GUI/Widgets/AMSItem.hpp"
#include "slic3r/GUI/DeviceCore/DevFilaSystem.h"
using namespace Slic3r;
using namespace Slic3r::GUI;
TEST_CASE("Configured empty and partial AMS trays remain distinct from unknown trays", "[AMSItem]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"ams":{
"ams_exist_bits":"1","tray_exist_bits":"7","ams":[
{"id":"0","info":"0001","tray":[
{"id":"0","tag_uid":"0000000000000000","tray_info_idx":"","tray_type":"","tray_color":"00000000"},
{"id":"1"},
{"id":"2","tag_uid":"0000000000000000","tray_info_idx":"","tray_type":"PLA","tray_color":"FF0000FF"}
]}
]
}}})", false);
const auto& ams_list = machine.GetFilaSystem()->GetAmsList();
const auto ams_it = ams_list.find("0");
REQUIRE(ams_it != ams_list.end());
auto* ams = ams_it->second;
REQUIRE(ams != nullptr);
REQUIRE(ams->GetTray("0") != nullptr);
CHECK(ams->GetTray("0")->is_exists);
AMSinfo info;
REQUIRE(info.parse_ams_info(&machine, ams));
REQUIRE(info.cans.size() == 3);
CHECK(info.cans[0].is_empty);
CHECK(info.cans[0].material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND);
CHECK_FALSE(info.cans[1].is_empty);
CHECK(info.cans[1].material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND);
CHECK_FALSE(info.cans[2].is_empty);
CHECK(info.cans[2].material_state == AMSCanType::AMS_CAN_TYPE_THIRDBRAND);
}
TEST_CASE("Empty external slots remain distinct from unknown slots", "[AMSItem]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[
{"id":"255","tag_uid":"0000000000000000","tray_info_idx":"","tray_type":"","tray_color":"00000000"},
{"id":"254"}
]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].is_exists);
AMSinfo empty_info;
empty_info.parse_ext_info(&machine, machine.vt_slot[0]);
CHECK(empty_info.cans[0].is_empty);
CHECK(empty_info.cans[0].material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL);
AMSinfo unknown_info;
unknown_info.parse_ext_info(&machine, machine.vt_slot[1]);
CHECK_FALSE(unknown_info.cans[0].is_empty);
CHECK(unknown_info.cans[0].material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL);
}
+354
View File
@@ -0,0 +1,354 @@
// why: match the GUI include order to avoid rpcndr.h byte/std::byte
// ambiguity in the Windows COM headers.
// why: wx/timer.h must precede DeviceManager.hpp because
// DeviceErrorDialog.hpp uses wxTimerEvent.
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include <stdexcept>
#include <wx/timer.h>
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/DeviceCore/DevFilaSystem.h"
#include <nlohmann/json.hpp>
using json = nlohmann::json;
using namespace Slic3r;
// Bambu virtual trays follow full-snapshot removals; Orca virtual trays are
// retained after first observation until reconnect.
// Contract: an authoritative empty vir_slot ([] = "known, no virtual slots")
// clears the seeded virtual trays. The consumers were guarded so an empty
// vector is safe.
TEST_CASE("An empty vir_slot clears the virtual trays", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
REQUIRE(machine.vt_slot.size() == 1);
REQUIRE(machine.vt_slot[0].id == "255");
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false);
CHECK(machine.vt_slot.empty());
CHECK_FALSE(machine.ams_support_virtual_tray);
}
// A frame that omits vir_slot must leave the seeded virtual tray untouched.
TEST_CASE("A missing vir_slot keeps the virtual trays", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
REQUIRE(machine.vt_slot.size() == 1);
machine.parse_json("lan", R"({"print":{"command":"push_status"}})", false);
CHECK(machine.vt_slot.size() == 1);
CHECK(machine.ams_support_virtual_tray);
}
// Repopulating after a clear must not rely on the constructor's seed, and must
// re-enable virtual-tray support even though the clear turned the flag off.
TEST_CASE("Virtual trays repopulate after an authoritative clear", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false);
REQUIRE(machine.vt_slot.empty());
REQUIRE_FALSE(machine.ams_support_virtual_tray);
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].id == "255");
CHECK(machine.vt_slot[1].id == "254");
CHECK(machine.ams_support_virtual_tray);
}
// A deputy with no main is not an index-1 write into an empty vector.
TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false);
REQUIRE(machine.vt_slot.empty());
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"254"}]}})", false);
CHECK(machine.vt_slot.empty());
}
// OrcaSonar emits a virtual slot only when the layout has it, so it counts as
// present even with no filament: the user's configuration wins.
TEST_CASE("A configured vir_slot is present even with no material", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].is_exists);
CHECK(machine.vt_slot[1].is_exists);
}
// A full vir_slot snapshot is authoritative: an id it omits is removed, so a
// tool-count shrink does not leave a stale deputy tray.
TEST_CASE("A populated vir_slot prunes virtual trays it omits", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false);
REQUIRE(machine.vt_slot.size() == 1);
CHECK(machine.vt_slot[0].id == "255");
}
// A delta frame (msg=1) is not authoritative for removals: an entry it omits
// stays held, and an entry it names is updated in place (spec §7.3).
TEST_CASE("A delta vir_slot does not prune virtual trays it omits", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
// Delta names only the deputy: the main must survive, the deputy updates.
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":1,"vir_slot":[{"id":"254","tag_uid":"ABCDEF0123456789"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].id == "255");
CHECK(machine.vt_slot[1].id == "254");
CHECK(machine.vt_slot[1].tag_uid == "ABCDEF0123456789");
}
// An empty delta is not authoritative and must preserve the known layout.
TEST_CASE("An empty delta vir_slot keeps the virtual trays", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":1,"vir_slot":[]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].id == "255");
CHECK(machine.vt_slot[1].id == "254");
CHECK(machine.ams_support_virtual_tray);
}
TEST_CASE("Orca retains seen virtual trays across full snapshots", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255","tag_uid":"0123456789ABCDEF"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].tag_uid == "0123456789ABCDEF");
CHECK(machine.vt_slot[1].id == "254");
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].id == "255");
CHECK(machine.vt_slot[1].id == "254");
CHECK(machine.ams_support_virtual_tray);
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0}})", false);
CHECK(machine.vt_slot.size() == 2);
}
TEST_CASE("Orca forgets retained virtual trays at reconnect", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
machine.reset_orca_virtual_trays_for_reconnect();
REQUIRE(machine.vt_slot.size() == 1);
CHECK(machine.vt_slot[0].id == "255");
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[]}})", false);
CHECK(machine.vt_slot.empty());
CHECK_FALSE(machine.ams_support_virtual_tray);
}
TEST_CASE("Orca stores a deputy virtual tray at its stable index", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "orca";
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].id == "255");
CHECK_FALSE(machine.vt_slot[0].is_exists);
CHECK(machine.vt_slot[1].id == "254");
CHECK(machine.vt_slot[1].is_exists);
}
TEST_CASE("Capability flags parse without a DeviceManager", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan",
R"({"info":{"command":"get_capabilities","capabilities":{"flags":{"support_tunnel_mqtt":true}}}})", false);
CHECK(machine.is_support_tunnel_mqtt);
}
// A filament setting landing on the virtual tray also marks it present.
TEST_CASE("A virtual tray setting marks the tray present", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false);
REQUIRE(machine.vt_slot.size() == 1);
machine.vt_slot[0].is_exists = false;
machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":255,"tray_id":255,"tray_color":"FF0000FF","tray_type":"PLA","tray_info_idx":"GFA00","nozzle_temp_min":190,"nozzle_temp_max":230}})", false);
CHECK(machine.vt_slot[0].is_exists);
}
// acks that target the virtual tray must survive an emptied vt_slot.
TEST_CASE("Virtual tray acks are safe with no virtual tray", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[]}})", false);
REQUIRE(machine.vt_slot.empty());
machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":255,"tray_id":255}})", false);
machine.parse_json("lan", R"({"print":{"command":"extrusion_cali_set","tray_id":255,"k_value":0.02}})", false);
CHECK(machine.vt_slot.empty());
}
// Only Bambu's own agent treats a non-zero tag_uid as an RFID lock; agent-managed
// printers send it as metadata and must keep their trays editable.
TEST_CASE("Only the BBL agent is RFID-locking", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
CHECK(machine.is_bbl_agent());
CHECK_FALSE(machine.is_orca_agent());
machine.printer_agent_id = "orca";
CHECK_FALSE(machine.is_bbl_agent());
CHECK(machine.is_orca_agent());
machine.printer_agent_id = "";
CHECK(machine.is_bbl_agent());
}
// An OPCP error ack carries result "fail" and a reason; a success ack (or one
// without result) must not be mistaken for a failure.
TEST_CASE("AMS filament setting acks expose OPCP failures", "[DeviceManager]")
{
std::string reason;
const json failure = json::parse(R"({"result":"fail","errno":-19,"reason":"unknown slot"})");
CHECK(MachineObject::ams_filament_ack_failed(failure, reason));
CHECK(reason == "unknown slot");
reason = "stale";
const json success = json::parse(R"({"result":"success","errno":0})");
CHECK_FALSE(MachineObject::ams_filament_ack_failed(success, reason));
CHECK(reason.empty());
const json plain = json::parse(R"({"ams_id":1,"tray_id":2})");
CHECK_FALSE(MachineObject::ams_filament_ack_failed(plain, reason));
}
TEST_CASE("AMS filament acknowledgements report native sync state", "[DeviceManager]")
{
CHECK(MachineObject::ams_filament_native_sync_pending(json::parse(R"({"native_sync":"pending"})")));
CHECK_FALSE(MachineObject::ams_filament_native_sync_pending(json::parse(R"({"native_sync":"applied"})")));
CHECK(MachineObject::ams_filament_connector_only(json::parse(R"({"native_sync":"connector_only"})")));
CHECK_FALSE(MachineObject::ams_filament_connector_only(json::parse(R"({"native_sync":"not_applicable"})")));
}
// Every tray field in the echo is optional: an unguarded get would throw
// type_error into parse_json's frame-wide catch and drop the rest of the push.
TEST_CASE("A slim AMS filament acknowledgement leaves the tray untouched", "[DeviceManager]")
{
DevAmsTray tray("0");
tray.color = "FF0000FF";
tray.nozzle_temp_min = "190";
tray.m_fila_type = "PLA";
CHECK_NOTHROW(MachineObject::fill_ams_filament_ack_tray(
tray, json::parse(R"({"command":"ams_filament_setting","result":"success","errno":0})")));
CHECK(tray.color == "FF0000FF");
CHECK(tray.nozzle_temp_min == "190");
CHECK(tray.m_fila_type == "PLA");
CHECK_NOTHROW(MachineObject::fill_ams_filament_ack_tray(
tray, json::parse(R"({"tray_color":"0000FFFF","tray_type":"ABS","tray_info_idx":"GFA01","nozzle_temp_min":210,"nozzle_temp_max":240})")));
CHECK(tray.color == "0000FFFF");
CHECK(tray.setting_id == "GFA01");
CHECK(tray.nozzle_temp_min == "210");
CHECK(tray.nozzle_temp_max == "240");
CHECK(tray.m_fila_type == "ABS");
}
// An echo that carries no tray fields at all must leave the frame alive: the
// frame-wide catch would silently swallow every later message in the push.
TEST_CASE("An AMS filament acknowledgement without tray fields does not abort the frame", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
const json ams = json::parse(R"({"ams":{"tray_exist_bits":"1","ams":[
{ "id": "0", "info": "00000001", "tray": [ { "id": "0" } ] } ]}})");
DevFilaSystemParser::ParseV1_0(ams, &machine, machine.GetFilaSystem().get(), false);
REQUIRE(machine.GetFilaSystem()->GetAmsTray("0", "0") != nullptr);
CHECK_NOTHROW(machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","result":"success","errno":0,"ams_id":0,"tray_id":0}})", false));
const DevAmsTray* tray = machine.GetFilaSystem()->GetAmsTray("0", "0");
REQUIRE(tray != nullptr);
CHECK(tray->color.empty());
}
// An ack that targets a tray but omits tray_id must not abort the frame: it
// falls back to slot 0 instead of an unguarded get on a missing key.
TEST_CASE("An AMS filament ack without a tray_id targets slot 0", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
const json ams = json::parse(R"({"ams":{"tray_exist_bits":"1","ams":[
{ "id": "0", "info": "00000001", "tray": [ { "id": "0" } ] } ]}})");
DevFilaSystemParser::ParseV1_0(ams, &machine, machine.GetFilaSystem().get(), false);
REQUIRE(machine.GetFilaSystem()->GetAmsTray("0", "0") != nullptr);
CHECK_NOTHROW(machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":0,"tray_color":"FF0000FF","tray_type":"PLA","tray_info_idx":"GFA00","nozzle_temp_min":190,"nozzle_temp_max":230}})", false));
const DevAmsTray* tray = machine.GetFilaSystem()->GetAmsTray("0", "0");
REQUIRE(tray != nullptr);
CHECK(tray->color == "FF0000FF");
}
@@ -139,3 +139,5 @@ TEST_CASE("Zero progress replaces active shared progress", "[DeviceManager][Prog
REQUIRE(machine.mc_print_percent == 0);
CHECK(current_subtask->task_progress == 0);
}
+451 -4
View File
@@ -1,4 +1,6 @@
#include <catch2/catch_test_macros.hpp>
#include <nlohmann/json.hpp>
#include <slic3r/Utils/AmsPayload.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
@@ -47,7 +49,7 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus
// is cached for the device.
agent.deliver_to_sink(
"dev-1",
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]}}}})",
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]},"protocol":{"ams_ops":["change_filament"]}}}})",
/*local=*/false);
CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos);
CHECK(last_payload.find("\"print\"") == std::string::npos);
@@ -66,6 +68,14 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","nozzle_diameter":0.6}})", /*local=*/false);
CHECK(last_payload.find("\"nozzle_diameter\":0.6") != std::string::npos);
CHECK(last_payload.find("N/A") == std::string::npos);
// The reply's declared ams_ops register on the device (OPCP §7.8): the one
// declared op passes the client gate, an undeclared one is rejected.
bool unsupported = false;
OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported);
CHECK_FALSE(unsupported);
OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported);
CHECK(unsupported);
}
TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") {
@@ -87,7 +97,7 @@ TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaP
TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
Slic3r::PrinterConnectionParams params{
"dev-1", "10.255.255.1", "orcasonar", "code", "", false, ""
"dev-1", "10.255.255.1", "", "orcasonar", "code", false, ""
};
const int rc = agent.connect_printer(params);
CHECK(rc == BAMBU_NETWORK_SUCCESS);
@@ -96,6 +106,94 @@ TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integrat
agent.disconnect_printer();
}
// why hidden: connect_printer starts a detached connect worker, like the LAN
// test above. The sync mode follows the printer's own AMS capability, not the
// transport: a connected printer stays `none` until its get_capabilities reply
// declares a material system. The registry is process-wide, so this test uses
// ids no other test feeds capabilities for.
TEST_CASE("filament sync follows the printer's AMS capability", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
REQUIRE(agent.connect_printer(Slic3r::PrinterConnectionParams{
"dev-ams-1", "10.255.255.1", "", "orcasonar", "code", false, ""
}) == BAMBU_NETWORK_SUCCESS);
// Connected, but no capability reply yet: still none.
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
// features.fms=true is the authoritative material-system flag.
agent.deliver_to_sink("dev-ams-1",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true},"ams_ops":["change_filament"]}}}})",
/*local=*/true);
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription);
// An explicit false clears it again.
agent.deliver_to_sink("dev-ams-1",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false}}}}})",
/*local=*/true);
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
// filament_slots alone enables sync: a standalone printer with no material
// system still has slots (REQ-FMS-001).
agent.deliver_to_sink("dev-ams-1",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false,"filament_slots":true}}}}})",
/*local=*/true);
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription);
// Older payloads without features.fms fall back to a non-empty ams_ops.
agent.deliver_to_sink("dev-ams-1",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"ams_ops":["change_filament"]}}}})",
/*local=*/true);
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription);
// A reply without a protocol block is not a capabilities answer: it is
// ignored, so a transient malformed reply cannot gate every write. The
// ams_ops fallback above still holds.
agent.deliver_to_sink("dev-ams-1",
R"({"info":{"command":"get_capabilities","capabilities":{}}})",
/*local=*/true);
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription);
// Disconnecting forgets the declaration rather than letting it go stale.
agent.disconnect_printer();
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
}
// OrcaSonar's contract: an absent ams_ops key means "no AMS controls" (app.go
// omits it when no driver declares a write op; Qidi is the shipped example).
// Answering get_capabilities without it must gate every AMS write client-side,
// not fall back to the base default's "no record -> never gate".
TEST_CASE("a capability reply without ams_ops gates AMS writes", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
agent.deliver_to_sink("dev-noops",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true}}}}})",
/*local=*/true);
bool unsupported = false;
OrcaPrinterAgent::canonicalize_ams_payload(
"dev-noops",
R"({"print":{"command":"ams_change_filament","target":0,"slot_id":0,"ams_id":0}})",
&unsupported);
CHECK(unsupported);
}
// A malformed get_capabilities reply must be ignored, not read as "no
// capabilities": doing so would set ops_known with an empty set and gate every
// AMS write until a good reply arrives.
TEST_CASE("a malformed capability reply does not gate AMS writes", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
agent.deliver_to_sink("dev-malformed",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"ams_ops":["change_filament"]}}}})",
/*local=*/true);
CHECK(Slic3r::ams_op_supported("dev-malformed", "change_filament"));
agent.deliver_to_sink("dev-malformed",
R"({"info":{"command":"get_capabilities","capabilities":{}}})",
/*local=*/true);
// The malformed reply changed nothing, rather than clearing the op set.
CHECK(Slic3r::ams_op_supported("dev-malformed", "change_filament"));
}
TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") {
struct SeqProbe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
@@ -178,7 +276,7 @@ TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrint
orca_mqtt_test::MockBroker broker;
OrcaPrinterAgent agent("/tmp");
const auto ep = broker.host_port();
agent.connect_printer(Slic3r::PrinterConnectionParams{"dev-1", ep.first + ":" + ep.second, "orcasonar", "code", "", false, ""});
agent.connect_printer(Slic3r::PrinterConnectionParams{"dev-1", ep.first + ":" + ep.second, "", "orcasonar", "code", false, ""});
for (int i = 0; i < 150 && broker.connect_count() == 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(20));
@@ -201,8 +299,357 @@ TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrint
TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinterAgent]") {
for (int i = 0; i < 20; ++i) {
auto agent = std::make_unique<OrcaPrinterAgent>("/tmp");
agent->connect_printer(Slic3r::PrinterConnectionParams{"dev-1", "127.0.0.1:1", "orcasonar", "code", "", false, ""}); // nothing listening: instant ECONNREFUSED
agent->connect_printer(Slic3r::PrinterConnectionParams{"dev-1", "127.0.0.1:1", "", "orcasonar", "code", false, ""}); // nothing listening: instant ECONNREFUSED
agent.reset(); // ~OrcaPrinterAgent must stop the conn, join the thread, and not hang/crash
}
SUCCEED();
}
// OPCP spec §7.8: Bambu wire conventions must be decoded at the agent funnel,
// never smuggled through as numeric lanes (an external-spool select once read
// as slot_id=0 and physically loaded gate 0).
TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical OrcaSonar bodies", "[OrcaPrinterAgent]") {
auto canon = [](const std::string& dev, const std::string& in, bool* unsupported = nullptr) {
bool local = false;
return OrcaPrinterAgent::canonicalize_ams_payload(dev, in, unsupported ? unsupported : &local);
};
auto out = nlohmann::json::parse(canon("dev-c1",
R"({"print":{"command":"ams_change_filament","sequence_id":"1","target":5,"slot_id":1,"ams_id":1,"curr_temp":210,"tar_temp":220}})"));
CHECK(out["print"]["selector"] == "lane");
// Coordinates are the resolver's own form; target (a BBL tray id) is dropped.
CHECK(out["print"]["ams_id"] == 1);
CHECK(out["print"]["slot_id"] == 1);
CHECK(!out["print"].contains("lane"));
CHECK(!out["print"].contains("target"));
CHECK(out["print"]["tar_temp"] == 220);
// External slots retain their tool identity through the coordinate address.
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":255,"target":255,"slot_id":0}})"));
CHECK(out["print"]["selector"] == "lane");
CHECK(out["print"]["ams_id"] == 255);
CHECK(out["print"]["slot_id"] == 0);
CHECK(!out["print"].contains("lane"));
CHECK(!out["print"].contains("target"));
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":254,"target":254,"slot_id":0}})"));
CHECK(out["print"]["selector"] == "lane");
CHECK(out["print"]["ams_id"] == 254);
const std::string legacy_unload = R"({"print":{"command":"ams_change_filament","ams_id":0,"target":255,"slot_id":255}})";
CHECK(canon("dev-c1", legacy_unload) == legacy_unload);
// Target-only requests keep the legacy macro path.
const std::string target_only = R"({"print":{"command":"ams_change_filament","target":6}})";
CHECK(canon("dev-c1", target_only) == target_only);
// Box coordinates are forwarded unchanged: a fabricated flat lane would be
// the BBL tray id, which is not the layout lane for wide/sparse boxes.
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":1,"slot_id":2,"tray_id":2,"tray_type":"PLA"}})"));
CHECK(out["print"]["ams_id"] == 1);
CHECK(out["print"]["slot_id"] == 2);
CHECK(!out["print"].contains("lane"));
CHECK(!out["print"].contains("tray_id"));
CHECK(out["print"]["tray_type"] == "PLA");
// tray_color is the one field this sync exists to carry: it must survive
// the funnel, both as a real color and as the empty-tray marker a reset
// sends.
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":1,"slot_id":2,"tray_id":2,"tray_type":"PLA","tray_color":"00FF00FF","tray_info_idx":"GFL01"}})"));
CHECK(out["print"]["tray_color"] == "00FF00FF");
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":1,"slot_id":2,"tray_id":2,"tray_type":"","tray_info_idx":"","tray_color":"00000000"}})"));
CHECK(out["print"]["tray_color"] == "00000000");
CHECK(out["print"]["tray_type"] == "");
// Wide-box dual form: both addressings resolve to one slot server-side.
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":1,"slot_id":5,"tray_type":"PLA"}})"));
CHECK(out["print"]["ams_id"] == 1);
CHECK(out["print"]["slot_id"] == 5);
CHECK(!out["print"].contains("lane"));
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":2,"slot_id":1,"tray_type":"PLA"}})"));
CHECK(out["print"]["ams_id"] == 2);
CHECK(out["print"]["slot_id"] == 1);
// A lane-only body must pass through untouched, never become lane = -5.
const std::string lane_only = R"({"print":{"command":"ams_filament_setting","lane":5,"tray_type":"PLA"}})";
CHECK(canon("dev-c1", lane_only) == lane_only);
// External/direct spool (Bambu 254/255): forwarded as the canonical
// ams_id address, never a fabricated lane (REQ-FMS-001).
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":255,"slot_id":0,"tray_id":0,"tray_type":"PLA"}})"));
CHECK(out["print"]["ams_id"] == 255);
CHECK(out["print"]["slot_id"] == 0);
CHECK(!out["print"].contains("lane"));
CHECK(!out["print"].contains("tray_id"));
// Legacy RFID call shape (ams_id+slot_id, no tray_id) flattens to tray_id.
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_get_rfid","ams_id":1,"slot_id":2}})"));
CHECK(out["print"]["tray_id"] == 6);
CHECK(!out["print"].contains("ams_id"));
const std::string canonical = R"({"print":{"command":"ams_change_filament","selector":"lane","lane":3}})";
CHECK(canon("dev-c1", canonical) == canonical);
const std::string combined = R"({"print":{"command":"ams_change_filament","target":3,"selector":"lane","lane":3}})";
CHECK(canon("dev-c1", combined) == combined);
CHECK(canon("dev-c1", R"({"print":{"command":"pause"}})") == R"({"print":{"command":"pause"}})");
CHECK(canon("dev-c1", "not json at all") == "not json at all");
// A legacy unload still uses the legacy macro path, which is covered by
// change_filament; selector unload requires its own token.
Slic3r::register_ams_ops("dev-c2", {"change_filament"});
Slic3r::register_ams_capability("dev-c2", true);
bool unsupported = false;
canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":255,"slot_id":255}})", &unsupported);
CHECK_FALSE(unsupported);
unsupported = false;
canon("dev-c2", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported);
CHECK(unsupported);
unsupported = false;
canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported);
CHECK(!unsupported);
// A selector-carrying canonical body is gated on its selector's op too.
unsupported = false;
canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"lane","lane":1}})", &unsupported);
CHECK(!unsupported);
unsupported = false;
canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &unsupported);
CHECK(unsupported);
unsupported = false;
canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":254,"ams_id":254,"slot_id":0}})", &unsupported);
CHECK(unsupported);
// A lane can resolve to an external slot server-side (§7.8), so a device
// that declares only external still admits a canonical lane write.
Slic3r::register_ams_ops("dev-c4", {"external"});
Slic3r::register_ams_capability("dev-c4", true);
unsupported = false;
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"lane","lane":1}})", &unsupported);
CHECK(!unsupported);
unsupported = false;
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"lane","ams_id":0,"slot_id":0}})", &unsupported);
CHECK(unsupported);
unsupported = false;
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"lane","ams_id":254,"slot_id":0}})", &unsupported);
CHECK_FALSE(unsupported);
unsupported = false;
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &unsupported);
CHECK(!unsupported);
// Other selectors still gate on their own token.
unsupported = false;
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"unload"}})", &unsupported);
CHECK(unsupported);
// A device with no capabilities record is never gated (server backstops).
unsupported = false;
canon("dev-c3", R"({"print":{"command":"ams_user_setting","ams_id":0}})", &unsupported);
CHECK(!unsupported);
}
// filament_setting is advertised by filament_slots alone, independently of fms
// and ams_ops (OPCP §7.8).
TEST_CASE("filament slot writes remain independent of FMS", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
agent.deliver_to_sink("dev-slots",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false,"filament_slots":true}}}}})",
/*local=*/true);
bool unsupported = false;
OrcaPrinterAgent::canonicalize_ams_payload(
"dev-slots",
R"({"print":{"command":"ams_filament_setting","ams_id":255,"slot_id":0,"tray_id":0,"tray_type":"PLA"}})",
&unsupported);
CHECK_FALSE(unsupported);
agent.deliver_to_sink("dev-slots",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false,"filament_slots":true},"ams_ops":["change_filament"]}}}}})",
/*local=*/true);
unsupported = false;
OrcaPrinterAgent::canonicalize_ams_payload(
"dev-slots",
R"({"print":{"command":"ams_change_filament","target":1,"slot_id":1,"ams_id":0}})",
&unsupported);
CHECK(unsupported);
agent.deliver_to_sink("dev-no-slots",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true,"filament_slots":false},"ams_ops":["filament_setting"]}}}})",
/*local=*/true);
unsupported = false;
OrcaPrinterAgent::canonicalize_ams_payload(
"dev-no-slots",
R"({"print":{"command":"ams_filament_setting","ams_id":0,"slot_id":0,"tray_type":"PLA"}})",
&unsupported);
CHECK(unsupported);
}
// A fixed-vocabulary printer publishes the values it can hold, so the dialog
// offers only those: an exact type and its variants pass the filter, a material
// the printer lacks does not. A free-form driver and an unanswered device stay
// unconstrained.
TEST_CASE("A printer publishes its filament metadata values", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
agent.deliver_to_sink("dev-qidi",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{
"filament_metadata":{"qidi_box":{"read":["material","color"],"write":["material","color"],
"values":{"material":["PLA","PETG"],"color":["FF0000FF","00FF00FF"]}}}}}}})",
/*local=*/true);
const Slic3r::FilamentMetadataValues values = Slic3r::filament_metadata_values("dev-qidi");
CHECK(values.constrained);
CHECK(values.materials.size() == 2);
CHECK(values.colors.size() == 2);
CHECK(Slic3r::filament_material_compatible("dev-qidi", "PLA"));
CHECK(Slic3r::filament_material_compatible("dev-qidi", "PETG"));
CHECK(Slic3r::filament_material_compatible("dev-qidi", "PLA+"));
CHECK_FALSE(Slic3r::filament_material_compatible("dev-qidi", "HIPS"));
CHECK(Slic3r::normalize_qidi_material_family("PCTG") == "PCTG");
CHECK(Slic3r::normalize_qidi_material_family("PPA") == "PPA");
CHECK(Slic3r::normalize_qidi_material_family("PLA-CF") == "PLA-CF");
CHECK(Slic3r::normalize_qidi_material_family("PA12-CF") == "PA");
CHECK(Slic3r::normalize_qidi_material_family("PC-ABS-FR") == "PC");
agent.deliver_to_sink("dev-qidi-polymer",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{
"filament_metadata":{"qidi_box":{"read":["material","color"],"write":["material","color"],
"values":{"material":["PC","PA"],"color":["FF0000FF"]}}}}}}})",
/*local=*/true);
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PC"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PA Nylon"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PA-CF"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PA12-CF"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PAHT-CF"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PAHT-GF"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PAHT-S"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "UltraPA"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "UltraPA-CF25"));
CHECK(Slic3r::filament_material_compatible("dev-qidi-polymer", "PC-ABS-FR"));
CHECK_FALSE(Slic3r::filament_material_compatible("dev-qidi-polymer", "PCTG"));
CHECK_FALSE(Slic3r::filament_material_compatible("dev-qidi-polymer", "PPA"));
agent.deliver_to_sink("dev-happyhare",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{
"filament_metadata":{"happy_hare":{"read":["material","color"],"write":["material","color"]}}}}}})",
/*local=*/true);
CHECK_FALSE(Slic3r::filament_metadata_values("dev-happyhare").constrained);
CHECK(Slic3r::filament_material_compatible("dev-happyhare", "HIPS"));
CHECK(Slic3r::filament_material_compatible("dev-unanswered", "HIPS"));
}
TEST_CASE("An unknown lane material does not bind to an arbitrary filament id", "[OrcaPrinterAgent]") {
AmsTrayData tray;
tray.has_filament = true;
tray.tray_type = "Unlisted polymer family";
std::vector<AmsTrayData> trays{tray};
Slic3r::resolve_tray_info_idx(trays);
CHECK(trays[0].tray_info_idx.empty());
}
// A box wider than 4 slots is shown as several 4-tray units, so the BBL tray id
// a panel reports must split into that unit's own (ams_id, slot_id). A flat lane
// would be the tray id, which is not the layout lane for a wide box
// (REQ-STS-008 §7.8).
TEST_CASE("an AMS tray selection sends the tray's ams_id and slot_id", "[OrcaPrinterAgent]") {
// Unit 2 tray 1 is BBL tray 9: a 6-slot box's slot 5 addressed as (2, 1).
auto body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(9, 123));
CHECK(body["print"]["selector"] == "lane");
CHECK(body["print"]["ams_id"] == 2);
CHECK(body["print"]["slot_id"] == 1);
CHECK_FALSE(body["print"].contains("lane"));
// Unit 0 tray 3 is tray 3.
body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(3, 124));
CHECK(body["print"]["ams_id"] == 0);
CHECK(body["print"]["slot_id"] == 3);
body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(254, 125));
CHECK(body["print"]["ams_id"] == 254);
CHECK(body["print"]["slot_id"] == 0);
body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(255, 126));
CHECK(body["print"]["ams_id"] == 255);
CHECK(body["print"]["slot_id"] == 0);
}
TEST_CASE("RFID refresh maps coordinates and preserves flat tray ids", "[OrcaPrinterAgent]") {
auto body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_refresh_rfid_body(1, 2, 127));
CHECK(body["print"]["tray_id"] == 6);
body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_refresh_rfid_body(-1, 9, 128));
CHECK(body["print"]["tray_id"] == 9);
body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_refresh_rfid_body(254, 0, 129));
CHECK(body["print"]["tray_id"] == 254);
}
// A filament frame can arrive after the get_capabilities reply was missed (the
// topology resolved late, or Klipper restarted). While the device is still
// unconfirmed, one filament frame must re-ask, throttled, and stop once a reply
// arrives so the session can leave FilamentSyncMode::none.
TEST_CASE("a filament frame re-requests capabilities while the topology is unconfirmed", "[OrcaPrinterAgent]") {
struct RefreshProbe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
using OrcaPrinterAgent::deliver_to_sink;
std::vector<std::string> refreshes;
void request_filament_capabilities(const std::string& dev_id, bool /*local*/) override {
refreshes.push_back(dev_id);
}
} agent("/tmp");
agent.set_on_message_fn([](std::string, std::string) {});
const std::string frame = R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})";
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
REQUIRE(agent.refreshes.size() == 1);
// Throttled: a second frame immediately after does not ask again.
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
CHECK(agent.refreshes.size() == 1);
// A capability reply confirms the topology: no further re-request.
agent.deliver_to_sink("dev-refresh-1",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"filament_slots":true}}}}})",
/*local=*/true);
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
CHECK(agent.refreshes.size() == 1);
// A status without filament state never triggers a request.
agent.deliver_to_sink("dev-refresh-2", R"({"print":{"command":"push_status","mc_percent":10}})", /*local=*/true);
CHECK(agent.refreshes.size() == 1);
}
// Deselecting a cloud device forgets its declaration, so a later session starts
// from "no reply yet" instead of a stale one. With no record an undeclared op is
// admitted again, which is how a freshly selected device starts.
TEST_CASE("deselecting a cloud device forgets its capabilities", "[OrcaPrinterAgent]") {
OrcaPrinterAgent agent("/tmp");
Slic3r::register_ams_ops("dev-cloud-clear", {"change_filament"});
Slic3r::register_filament_slots("dev-cloud-clear", true);
CHECK_FALSE(Slic3r::ams_op_supported("dev-cloud-clear", "external"));
agent.set_user_selected_machine("dev-cloud-clear");
agent.set_user_selected_machine("");
CHECK(Slic3r::ams_op_supported("dev-cloud-clear", "external"));
CHECK_FALSE(Slic3r::has_filament_slots("dev-cloud-clear"));
}
// A LAN feed for the same device id must keep its declaration when the cloud
// selection is cleared: clearing it would strand an independently live session.
// why hidden: spawns the LAN connect worker, like the other connect tests.
TEST_CASE("deselecting a cloud device keeps a live LAN declaration", "[OrcaPrinterAgent][.integration]") {
OrcaPrinterAgent agent("/tmp");
Slic3r::register_ams_ops("dev-lan-keep", {"change_filament"});
Slic3r::register_filament_slots("dev-lan-keep", true);
REQUIRE(agent.connect_printer(Slic3r::PrinterConnectionParams{
"dev-lan-keep", "10.255.255.1", "", "orcasonar", "code", false, ""
}) == BAMBU_NETWORK_SUCCESS);
agent.set_user_selected_machine("dev-lan-keep");
agent.set_user_selected_machine("");
CHECK_FALSE(Slic3r::ams_op_supported("dev-lan-keep", "external"));
CHECK(Slic3r::has_filament_slots("dev-lan-keep"));
agent.disconnect_printer();
}
+160 -1
View File
@@ -68,12 +68,171 @@ TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missin
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f);
}
// why: the lane_data projection is shared by Moonraker and OrcaSonar; the
// parser is pure so it can be checked without a live printer or a GUI.
TEST_CASE("unit: lane_data projection maps lanes into AMS trays", "[unit][moonraker]")
{
const auto body = nlohmann::json::parse(R"({
"result": {
"namespace": "lane_data",
"value": {
"lane0": {"lane": "0", "material": "PLA", "color": "FF0000FF"},
"lane1": {"lane": "1"},
"lane2": {"lane": "not-a-number"},
"lane3": {"lane": "3", "material": "PETG", "color": "#00FF00", "nozzle_temp": 240}
}
}
})");
std::vector<AmsTrayData> trays;
int max_lane_index = -1;
REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index));
REQUIRE(trays.size() == 3);
CHECK(trays[0].slot_index == 0);
CHECK(trays[0].has_filament);
CHECK(trays[0].tray_type == "PLA");
CHECK(trays[0].tray_color == "FF0000FF");
CHECK(trays[1].slot_index == 1);
CHECK_FALSE(trays[1].has_filament);
CHECK(trays[1].tray_type.empty());
CHECK(trays[2].slot_index == 3);
CHECK(trays[2].nozzle_temp == 240);
CHECK(max_lane_index == 3);
}
TEST_CASE("unit: lane_data projection rejects malformed and empty responses", "[unit][moonraker]")
{
std::vector<AmsTrayData> trays;
int max_lane_index = -1;
CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::object(), trays, max_lane_index));
CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::parse(R"({"result":{"value":{}}})"), trays, max_lane_index));
CHECK_FALSE(parse_moonraker_lane_data(
nlohmann::json::parse(R"({"result":{"value":{"lane0":{"lane":"x"},"lane1":42}}})"), trays, max_lane_index));
}
// why: OrcaSonar projects sensed presence separately from declared material, so a
// loaded lane with no type must not collapse to "empty".
TEST_CASE("unit: lane_data presence is independent of declared material", "[unit][moonraker]")
{
const auto body = nlohmann::json::parse(R"({
"result": {"value": {
"lane0": {"lane": "0", "has_filament": true},
"lane1": {"lane": "1", "has_filament": false, "material": "PLA"},
"lane2": {"lane": "2", "loaded": true},
"lane3": {"lane": "3", "material": "ASA"}
}}
})");
std::vector<AmsTrayData> trays;
int max_lane_index = -1;
REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index));
REQUIRE(trays.size() == 4);
CHECK(trays[0].has_filament);
CHECK(trays[0].tray_type.empty());
CHECK_FALSE(trays[1].has_filament);
CHECK(trays[1].tray_type == "PLA");
CHECK(trays[2].has_filament);
CHECK(trays[3].has_filament);
CHECK(trays[3].tray_type == "ASA");
}
// why: the exist bits and per-slot placeholder flag drive the sidebar's occupied
// vs empty rendering; the builder is pure so this needs no GUI.
TEST_CASE("unit: AMS payload sets exist bits and omits placeholder for loaded lanes", "[unit][moonraker]")
{
std::vector<AmsTrayData> trays = {
{0, true, "PLA", "FF0000FF", "", 0, 0},
{2, true, "", "", "", 0, 0}, // loaded but undeclared
{5, true, "PETG", "00FF00", "", 0, 0},
};
const auto ams = build_bbl_ams_json(trays, 2, 5);
// Units 0 and 1 exist; slots 0, 2 and 5 hold filament (0b100101).
CHECK(ams["ams_exist_bits"].get<std::string>() == "3");
CHECK(ams["tray_exist_bits"].get<std::string>() == "25");
const auto& u0 = ams["ams"][0]["tray"];
CHECK_FALSE(u0[0].contains("tray_slot_placeholder"));
CHECK(u0[1].contains("tray_slot_placeholder"));
CHECK_FALSE(u0[2].contains("tray_slot_placeholder"));
CHECK(u0[2]["tray_type"].get<std::string>() == "");
CHECK(u0[2]["tray_color"].get<std::string>() == "00000000");
const auto& u1 = ams["ams"][1]["tray"];
CHECK(u1[0].contains("tray_slot_placeholder")); // slot 4 absent
CHECK_FALSE(u1[1].contains("tray_slot_placeholder")); // slot 5 present
CHECK(u1[1]["tray_color"].get<std::string>() == "00FF00FF"); // 6-hex padded
}
// why: the exist-bits fields are 64-bit; multi-box rigs past lane 31 would be
// corrupted by a 32-bit unsigned long shift on MSVC. Guards the 64-bit path.
TEST_CASE("unit: AMS payload sets exist bits beyond 31 lanes", "[unit][moonraker]")
{
std::vector<AmsTrayData> trays = {
{33, true, "PLA", "FF0000FF", "", 0, 0},
};
const auto ams = build_bbl_ams_json(trays, 9, 33);
CHECK(ams["ams_exist_bits"].get<std::string>() == "1FF"); // units 0..8
CHECK(ams["tray_exist_bits"].get<std::string>() == "200000000"); // 1 << 33
const auto& u8 = ams["ams"][8]["tray"];
CHECK_FALSE(u8[1].contains("tray_slot_placeholder")); // slot 33 present
}
// why: a flat lane range renders into 4-slot AMS units; both agents and the
// builder share this one formula so they cannot drift.
TEST_CASE("unit: AMS unit count rounds lane ranges up to 4-slot units", "[unit][moonraker]")
{
CHECK(ams_count_for_lanes(-1) == 0);
CHECK(ams_count_for_lanes(0) == 1);
CHECK(ams_count_for_lanes(3) == 1);
CHECK(ams_count_for_lanes(4) == 2);
CHECK(ams_count_for_lanes(6) == 2);
CHECK(ams_count_for_lanes(7) == 2);
CHECK(ams_count_for_lanes(8) == 3);
}
// why: the sync mode keys off the printer's declared material system; a device
// with no capability record must never read as AMS-capable.
TEST_CASE("unit: AMS capability registry reports only declared material systems", "[unit][moonraker]")
{
CHECK_FALSE(has_ams_capability("cap-none"));
register_ams_capability("cap-true", true);
CHECK(has_ams_capability("cap-true"));
register_ams_capability("cap-false", false);
CHECK_FALSE(has_ams_capability("cap-false"));
// Later replies overwrite: a removed material system must clear the flag.
register_ams_capability("cap-true", false);
CHECK_FALSE(has_ams_capability("cap-true"));
// filament_slots is a separate connector flag: no record reads false, and
// a later reply clears it.
CHECK_FALSE(has_filament_slots("cap-slot-none"));
register_filament_slots("cap-slots", true);
CHECK(has_filament_slots("cap-slots"));
register_filament_slots("cap-slots", false);
CHECK_FALSE(has_filament_slots("cap-slots"));
}
// why: these builders preserve the Bambu firmware dialect byte-for-byte, including its trailing space.
TEST_CASE("unit: BBL AMS gcode builders preserve command bytes", "[unit][bbl]")
{
CHECK(BBLPrinterAgent::ams_refresh_rfid_gcode("123") == "M620 R123 \n");
CHECK(BBLPrinterAgent::ams_calibrate_gcode(123) == "M620 C123 \n");
CHECK(BBLPrinterAgent::ams_select_tray_gcode("123") == "M620 P123 \n");
}
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.command_ams_refresh_rfid("dev", "123", 1, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_refresh_rfid("dev", -1, 123, 1, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}