mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-28 03:11:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78e5ecdcd7 | ||
|
|
5f589ff909 | ||
|
|
d69d14d6f8 | ||
|
|
d3003dfa5e | ||
|
|
37b7e70490 | ||
|
|
aa934409a7 | ||
|
|
4cc23e6492 | ||
|
|
6849979d60 | ||
|
|
a9d145cab9 | ||
|
|
5a4fcafb5b | ||
|
|
6fdf697bc7 | ||
|
|
ad440edfd8 | ||
|
|
6f5856f06c |
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user