Initial implementation of filament mapping on OrcaSonar side

This commit is contained in:
Lam Wei Lun
2026-09-23 15:34:02 +08:00
parent a9d145cab9
commit 6849979d60
21 changed files with 553 additions and 541 deletions
+19 -7
View File
@@ -3446,7 +3446,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;
@@ -3471,14 +3471,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
@@ -3486,6 +3489,15 @@ std::string PresetCollection::filament_id_by_type(const std::string& filament_ty
return preset(first_visible_idx_by_type(filament_type)).filament_id;
}
bool PresetCollection::filament_id_by_type(const std::string& filament_type, std::string& out) const
{
size_t idx = first_matching_filament_idx(filament_type);
if (idx == size_t(-1))
return false;
out = preset(idx).filament_id;
return true;
}
std::vector<std::string> PresetCollection::diameters_of_selected_printer()
{
std::set<std::string> diameters;
+9
View File
@@ -756,6 +756,11 @@ public:
size_t first_visible_idx_by_type(const std::string& filament_type) const;
// Return the filament_id of the best-matching visible preset for the given filament type.
std::string filament_id_by_type(const std::string& filament_type) const;
// As above, but returns false (and leaves `out` untouched) when no preset
// matches instead of falling back to first_visible_idx(). The fallback is a
// plain first-visible/PLA preset, which would bind an unknown material to an
// unrelated default.
bool filament_id_by_type(const std::string& filament_type, std::string& out) const;
// Return index of the first compatible preset. Certainly at least the '- default -' preset shall be compatible.
// If one of the prefered_alternates is compatible, select it.
template<typename PreferedCondition> size_t first_compatible_idx(PreferedCondition prefered_condition) const
@@ -889,6 +894,10 @@ protected:
void set_custom_preset_alias(Preset &preset);
private:
// Index of the first visible, compatible, system base preset matching
// filament_type (exact, then base type), or (size_t)-1 when none matches.
size_t first_matching_filament_idx(const std::string& filament_type) const;
std::string canonical_preset_name(const std::string &name, const PresetOrigin &load_origin = PresetOrigin()) const;
// Comparator that sorts "Generic " prefixed presets before others, then alphabetically within each group.
+4 -2
View File
@@ -162,8 +162,10 @@ void DevCalib::ExtrusionCalibSetParse(const json & jj){
int slot_id = tray_ams_slot_map.find(tray_id) != tray_ams_slot_map.end() ? tray_ams_slot_map[tray_id].second : -1;
if(tray_id == VIRTUAL_TRAY_MAIN_ID) {
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k = jj.value("k_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k);
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n = jj.value("n_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n);
if (GetOwner()->vt_slot.size() > MAIN_EXTRUDER_ID) {
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k = jj.value("k_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].k);
GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n = jj.value("n_value", GetOwner()->vt_slot[MAIN_EXTRUDER_ID].n);
}
}else{
auto tray_item = GetOwner()->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id));
if (tray_item) {
-150
View File
@@ -849,154 +849,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);
}
}
}
}
@@ -415,8 +415,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
+74 -21
View File
@@ -536,6 +536,23 @@ 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::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;
}
PrinterArch MachineObject::get_printer_arch() const
{
return DevPrinterConfigUtil::get_printer_arch(printer_type);
@@ -619,8 +636,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);
@@ -747,6 +763,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];
}
@@ -2568,13 +2585,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 ctor seed rather than only resetting what is left: an
// authoritative vir_slot:[] erases every tray, so reset must not leave
// vt_slot permanently empty.
vt_slot.clear();
vt_slot.push_back(DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID)));
// why: reset reuses MachineObject, so release its lazy subtask
// before dropping the pointer to prevent reconnect leaks.
if (subtask_) {
@@ -2749,6 +2764,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++) {
@@ -3968,8 +3984,12 @@ 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);
}
@@ -3979,6 +3999,19 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
try {
if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) {
if (jj["vir_slot"].empty()) {
// Authoritative empty: OrcaSonar pushes [] when the
// topology is known but has no slots; clear stale trays.
vt_slot.clear();
ams_support_virtual_tray = false;
}
else {
// A keyed, populated vir_slot means virtual trays
// are supported; without this a prior clear left
// the flag false and the trays were ignored.
ams_support_virtual_tray = true;
}
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
@@ -3992,11 +4025,13 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
}
}
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] is the deputy. Only the main
// branch creates index 0, so an orphan
// deputy (no main) is dropped, not indexed.
if (vt_slot.size() > 1) {
vt_slot[1] = vslot;
}
else {
else if (vt_slot.size() == 1) {
vt_slot.push_back(vslot);
}
}
@@ -4004,6 +4039,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
}
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);
@@ -4016,9 +4052,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
vt_slot.push_back(main_slot);
}
}
else {
ams_support_virtual_tray = false;
}
// No virtual-tray key at all: leave the flag as-is. An
// authoritative clear is an explicit vir_slot: [], and
// incremental frames must not hide existing trays.
}
catch (...) {
;
@@ -4057,13 +4093,28 @@ 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);
}
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>());
@@ -4076,7 +4127,6 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
} 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";
@@ -4216,7 +4266,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"))
@@ -5630,7 +5680,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;
@@ -5700,6 +5752,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);
+7
View File
@@ -161,6 +161,13 @@ 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;
// Orca: an OPCP failure ack (result != "success"). Fills reason when present.
static bool ams_filament_ack_failed(const nlohmann::json& jj, std::string& reason);
// 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
+7 -2
View File
@@ -2325,8 +2325,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);
}
+13 -3
View File
@@ -5703,10 +5703,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});
@@ -5719,7 +5729,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) {
+10 -4
View File
@@ -4350,12 +4350,12 @@ void StatusPanel::on_ams_load_curr()
vt_slot_idx = 1;
}
if (vt_slot_idx < 0 || vt_slot_idx >= (int)obj->vt_slot.size()) return;
int old_temp = -1;
int new_temp = -1;
DevAmsTray* curr_tray = &obj->vt_slot[vt_slot_idx];
if (!curr_tray) return;
try {
if (!curr_tray->nozzle_temp_max.empty() && !curr_tray->nozzle_temp_min.empty())
old_temp = (atoi(curr_tray->nozzle_temp_min.c_str()) + atoi(curr_tray->nozzle_temp_max.c_str())) / 2;
@@ -4602,7 +4602,11 @@ 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);
if (!m_filament_setting_dlg->m_is_third)
{
sn_number = tray->uuid;
@@ -4642,6 +4646,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;
@@ -4668,7 +4674,7 @@ 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);
if (!m_filament_setting_dlg->m_is_third) {
sn_number = obj->vt_slot[nozzle_index].uuid;
filament = obj->vt_slot[nozzle_index].sub_brands;
+26 -67
View File
@@ -378,29 +378,19 @@ nlohmann::json build_bbl_ams_json(const std::vector<AmsTrayData>& trays,
// --- Removal/absence state and op capability ---------------------------------
// Last ams_count rendered per device: clear_ams_payload_for_device walks the
// same unit set to mark them all absent.
static std::mutex g_ams_state_mutex;
static std::map<std::string, int> g_ams_last_count;
// 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 ops_known = false;
bool has_ams = false;
bool filament_slots = false;
};
static std::map<std::string, AmsDeviceCaps> g_ams_caps;
static void remember_ams_count(const std::string& dev_id, int ams_count)
{
if (dev_id.empty() || ams_count <= 0)
return;
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
g_ams_last_count[dev_id] = std::max(g_ams_last_count[dev_id], ams_count);
}
void register_ams_ops(const std::string& dev_id, const std::vector<std::string>& ops)
{
if (dev_id.empty())
@@ -435,6 +425,29 @@ bool has_ams_capability(const std::string& dev_id)
return it != g_ams_caps.end() && it->second.has_ams;
}
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 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,
@@ -443,7 +456,6 @@ void build_ams_payload_for_device(const std::string& dev_id,
const QueueOnMainFn& queue_fn,
const TrayInfoResolver& vendor_resolver)
{
remember_ams_count(dev_id, ams_count);
// 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.
@@ -511,58 +523,5 @@ void build_ams_payload_for_device(const std::string& dev_id,
}
}
void clear_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn)
{
int count = 0;
{
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
auto it = g_ams_last_count.find(dev_id);
if (it != g_ams_last_count.end())
count = it->second;
g_ams_last_count[dev_id] = 0;
}
if (count == 0)
return; // nothing was ever rendered: nothing to clear
const bool on_main = wxIsMainThread();
auto apply = [dev_id, count]() {
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
if (!dev_manager)
return;
MachineObject* obj = dev_manager->get_my_machine(dev_id);
if (!obj)
return;
// All units present-but-empty: exist bits 0 marks them absent while
// placeholder trays flush stale type/color data out of DevFilaSystem.
nlohmann::json units = nlohmann::json::array();
for (int ams_id = 0; ams_id < count; ++ams_id) {
nlohmann::json trays = nlohmann::json::array();
for (int slot_id = 0; slot_id < 4; ++slot_id) {
trays.push_back(nlohmann::json{
{"id", std::to_string(slot_id)},
{"tag_uid", "0000000000000000"},
{"tray_info_idx", ""},
{"tray_type", ""},
{"tray_color", "00000000"},
{"tray_slot_placeholder", "1"},
});
}
units.push_back(nlohmann::json{{"id", std::to_string(ams_id)}, {"info", "0002"}, {"tray", trays}});
}
nlohmann::json ams_json;
ams_json["ams"] = units;
ams_json["ams_exist_bits"] = "0";
ams_json["tray_exist_bits"] = "0";
nlohmann::json print_json;
print_json["ams"] = ams_json;
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
BOOST_LOG_TRIVIAL(info) << "AmsPayload: cleared " << count << " AMS units for " << dev_id;
};
if (queue_fn && !on_main)
queue_fn(apply);
else
apply();
}
} // namespace Slic3r
+11 -5
View File
@@ -90,11 +90,6 @@ void build_ams_payload_for_device(const std::string& dev_id,
const QueueOnMainFn& queue_fn,
const TrayInfoResolver& vendor_resolver = {});
// Clear the device's AMS view: render every previously-seen unit as absent
// with placeholder trays so a removed/absent material system never leaves
// stale filament data behind (lane_data read as authoritative empty).
void clear_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn);
// Process-wide canonical AMS write capability (OrcaSonar REQ-STS-008), parsed
// from the info.get_capabilities reply. A device with no record (no reply yet;
// non-OrcaSonar agents never register) reports every op supported: gating only
@@ -109,6 +104,17 @@ bool ams_op_supported(const std::string& dev_id, const std::string& op);
void register_ams_capability(const std::string& dev_id, bool has_ams);
bool has_ams_capability(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);
// 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
+4 -4
View File
@@ -658,10 +658,10 @@ int MoonrakerPrinterAgent::safe_array_int(const nlohmann::json& arr, int idx)
// Fetch filament info from moonraker database
bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index)
{
// Shared with OrcaPrinterAgent's lane_data read; only the synced outcome
// matters here, since a missing namespace or an empty one both fall through
// to the Happy Hare query. tray_info_idx is resolved later, on the main
// thread, inside build_ams_payload_for_device.
// 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;
}
+67 -190
View File
@@ -444,15 +444,6 @@ OrcaPrinterAgent::~OrcaPrinterAgent()
start_discovery(false, false);
++m_lan_generation; // fence any late worker callback
++m_cloud_generation;
{
std::lock_guard<std::mutex> lock(state_mutex);
m_shutting_down = true; // workers stop arming new HTTP fetches
}
// Drain the detached filament-refresh workers: the flag and generation bump
// above end their loops, so this waits at most one in-flight HTTP fetch.
while (m_filament_in_flight.load() > 0)
std::this_thread::sleep_for(std::chrono::milliseconds(20));
// Drop the cloud status callback before anything else: it holds `this`, and the
// cloud agent outlives the printer agent (NetworkAgent::set_printer_agent swaps
@@ -633,6 +624,9 @@ void OrcaPrinterAgent::register_ams_capabilities(const std::string& dev_id, cons
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");
@@ -667,15 +661,20 @@ void OrcaPrinterAgent::register_ams_capabilities(const std::string& dev_id, cons
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);
}
void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local)
{
// Subscription doorbell, on the raw payload before the UI marshal so the
// (possibly blocking) lane_data refresh never queues behind it.
if (local && filament_doorbell_needed(dev_id, payload))
request_filament_refresh(dev_id);
parse_ipcam_info(dev_id, payload);
register_ams_capabilities(dev_id, payload);
std::string merged_payload = merge_capabilities(dev_id, payload);
@@ -715,19 +714,6 @@ 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");
// Eager filament sync on every (re)connect, like the Moonraker agents: the
// cached DevFilaSystem may predate the drop. The doorbell cache resets too,
// so the post-connect frame re-arms if the lane content moved meanwhile.
// Runs before the callback check so it also fires when no GUI listener is
// installed yet.
if (state == ConnectStatusOk) {
{
std::lock_guard<std::mutex> lock(state_mutex);
m_material_hash.clear();
}
request_filament_refresh(dev_id);
}
if (!callback)
return;
@@ -803,9 +789,12 @@ int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tr
nlohmann::json j;
j["print"]["command"] = "ams_change_filament";
j["print"]["sequence_id"] = std::to_string(sequence_id);
// tray_id here is the flat global lane (DevFilaSystem slot index).
// 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";
j["print"]["lane"] = tray_number;
j["print"]["ams_id"] = tray_number / 4;
j["print"]["slot_id"] = tray_number % 4;
return route_send(lan_mode, dev_id, j.dump());
}
@@ -870,148 +859,12 @@ FilamentSyncMode OrcaPrinterAgent::get_filament_sync_mode() const
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)) {
if (!dev_id.empty() && (has_ams_capability(dev_id) || has_filament_slots(dev_id))) {
return FilamentSyncMode::subscription;
}
return FilamentSyncMode::none;
}
// Read the lane_data projection once and apply the REQ-STS-007 tri-state to
// DevFilaSystem. Only LaneDataState::error arms the retry latch: 404 means
// "not knowable yet" (pre-bootstrap or acknowledged-unknown topology) and {}
// means "authoritatively no lanes" — both are settled states, and a printer
// without a material system must not turn into a per-frame 404 poll.
OrcaPrinterAgent::LaneDataState OrcaPrinterAgent::fetch_lane_data(const std::string& dev_id)
{
std::string origin;
QueueOnMainFn queue_fn;
{
std::lock_guard<std::mutex> lock(state_mutex);
if (m_shutting_down || m_current_connection != LAN || m_lan_dev_id != dev_id)
return LaneDataState::unknown;
origin = m_lan_http_origin;
queue_fn = queue_on_main_fn;
}
if (origin.empty())
return LaneDataState::unknown;
const std::string api_key = lan_api_key(origin);
// OrcaSonar serves the canonical topology's lane projection on its
// Moonraker-compatible façade. Called on the refresh worker (subscription
// mode), so the payload mutation is marshalled onto the main thread through
// queue_fn; a GUI-thread caller reads DevFilaSystem inline.
std::vector<AmsTrayData> trays;
int max_lane_index = 0;
switch (read_moonraker_lane_data(origin, api_key, trays, max_lane_index)) {
case LaneDataFetch::synced:
break;
case LaneDataFetch::none:
// Authoritative empty: flush stale trays so a removed AMS does not
// linger in the device panel.
clear_ams_payload_for_device(dev_id, queue_fn);
return LaneDataState::none;
case LaneDataFetch::unknown:
// 404: not knowable yet (pre-bootstrap or acknowledged-unknown topology).
// Never latched; the next doorbell or reconnect retries.
return LaneDataState::unknown;
case LaneDataFetch::error:
return LaneDataState::error;
}
// printer_type stays unset: push_status already carries the OrcaSonar printer
// type, and overwriting it here would clear it. build_ams_payload_for_device
// marshals the DevFilaSystem mutation through queue_fn when set.
build_ams_payload_for_device(dev_id, std::nullopt, ams_count_for_lanes(max_lane_index), max_lane_index, trays, queue_fn);
return LaneDataState::synced;
}
// Subscription scheduler for filament sync. A single detached worker drains
// m_filament_wanted; extra requests arriving while it runs fold into its loop, so
// a burst of topology_state doorbells costs one extra fetch that picks up the
// trailing change. A failed fetch does not busy-retry: m_filament_failed makes
// the next inbound LAN frame request again (an idle printer is silent, but it
// cannot have changed lanes either), and a reconnect re-primes via the
// ConnectStatusOk path in dispatch_local_connect().
void OrcaPrinterAgent::request_filament_refresh(const std::string& dev_id)
{
uint64_t gen;
{
std::lock_guard<std::mutex> lock(state_mutex);
if (m_current_connection != LAN || m_lan_dev_id != dev_id)
return;
gen = m_lan_generation.load();
m_filament_wanted = true;
if (m_filament_working)
return; // the running worker will take the flag
m_filament_working = true;
}
m_filament_in_flight.fetch_add(1, std::memory_order_relaxed);
std::thread([this, dev_id, gen] {
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
} guard{m_filament_in_flight};
for (;;) {
bool needed;
{
std::lock_guard<std::mutex> lock(state_mutex);
needed = !m_shutting_down && m_filament_wanted && m_lan_generation.load() == gen && m_current_connection == LAN &&
m_lan_dev_id == dev_id;
if (needed)
m_filament_wanted = false;
else
m_filament_working = false; // same critical section that saw no work: no lost wake-up
}
if (!needed)
return;
const auto state = fetch_lane_data(dev_id);
std::lock_guard<std::mutex> lock(state_mutex);
m_filament_failed = state == LaneDataState::error; // latch read by filament_doorbell_needed()
}
}).detach();
}
// A LAN frame is a refresh trigger when it carries an OrcaSonar material
// change (a new print.topology_state.material_hash, spec REQ-STS-007 §7.7) or
// when the last fetch errored and this frame is the retry beat. The substring
// guard keeps the JSON parse off the steady temp-tick cadence, and hash
// equality absorbs the tick's topology_state re-emissions.
bool OrcaPrinterAgent::filament_doorbell_needed(const std::string& dev_id, const std::string& payload)
{
{
std::lock_guard<std::mutex> lock(state_mutex);
if (m_current_connection != LAN || m_lan_dev_id != dev_id)
return false;
if (m_filament_failed)
return true;
}
// The cheap guard is the §7.7 doorbell token itself, so the steady
// temperature-only tick never reaches the JSON parse.
if (payload.find("material_hash") == std::string::npos)
return false;
auto json = nlohmann::json::parse(payload, nullptr, false);
if (json.is_discarded() || !json.is_object())
return false;
const auto print_it = json.find("print");
if (print_it == json.end() || !print_it->is_object())
return false;
const auto topo_it = print_it->find("topology_state");
if (topo_it == print_it->end() || !topo_it->is_object())
return false;
const auto hash_it = topo_it->find("material_hash");
if (hash_it == topo_it->end() || !hash_it->is_string())
return false;
std::lock_guard<std::mutex> lock(state_mutex);
if (hash_it->get<std::string>() == m_material_hash)
return false; // content unchanged: not a doorbell
m_material_hash = hash_it->get<std::string>();
return true;
}
// 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
@@ -1251,7 +1104,6 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st
previous_connection = m_current_connection;
m_lan_dev_id = dev_id;
m_lan_url = cfg.url;
m_lan_http_origin = http_origin_from_lan_ws(cfg.url);
m_lan_password = password; // access code; the façade key is bootstrapped lazily
m_lan_api_key.clear();
m_lan_api_key_gen = gen;
@@ -1328,10 +1180,7 @@ int OrcaPrinterAgent::disconnect_printer()
doomed = std::move(lan_mqtt_connection);
prev_dev = m_lan_dev_id;
m_lan_dev_id.clear();
m_lan_http_origin.clear();
m_lan_api_key.clear();
m_filament_wanted = false; // a stale worker self-exits on the generation mismatch
m_filament_failed = false;
if (m_current_connection == LAN) {
m_current_connection = NONE;
m_camera_stream_mode = CameraStreamMode::none;
@@ -1344,6 +1193,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()) {
@@ -1381,8 +1234,23 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
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;
if (cmd == "ams_change_filament" && print.contains("selector"))
return json_str; // already canonical (e.g. command_ams_select_tray)
// filament_setting is exempt from the ams_ops union: it persists
// connector state through the filament-slot model, so filament_slots
// alone advertises it (OrcaSonar OPCP §7.8).
auto op_allowed = [&dev_id](const std::string& o) {
return o.empty() || ams_op_supported(dev_id, o) || (o == "filament_setting" && has_filament_slots(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());
const std::string sel_op = (sel == "lane") ? "change_filament" : sel;
if (!op_allowed(sel_op) && unsupported)
*unsupported = true;
return json_str;
}
auto int_or = [&print](const char* key, int fallback) {
const auto it = print.find(key);
@@ -1394,34 +1262,45 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
const int slot = int_or("slot_id", -1);
const int ams = int_or("ams_id", -1);
print.erase("target");
print.erase("slot_id");
print.erase("tray_id");
print.erase("ams_id");
if (target == 255 && slot == 255) {
print.erase("slot_id");
print.erase("ams_id");
print["selector"] = "unload";
op = "unload";
} else if (target == 255 || ams == 254 || ams == 255) {
print.erase("slot_id");
print.erase("ams_id");
print["selector"] = "external";
op = "external";
} else {
const int lane = target >= 0 ? target : (ams >= 0 ? ams * 4 + slot : slot);
// Box change: forward the wire coordinates unchanged. Erase
// both first so a half-present coordinate pair from the client
// cannot leak into the body (a lone lane alias is untouched). A
// coordinate-less body is left for the server's own -19.
print["selector"] = "lane";
print["lane"] = lane;
op = "change_filament";
print.erase("ams_id");
print.erase("slot_id");
if (ams >= 0 && slot >= 0) {
print["ams_id"] = ams;
print["slot_id"] = slot;
}
op = "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);
op = "filament_setting";
if (ams >= 254) {
if (unsupported)
*unsupported = true; // no lane for a virtual tray
return json_str;
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");
}
print["lane"] = ams * 4 + slot;
print.erase("slot_id");
print.erase("tray_id");
print.erase("ams_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
@@ -1441,10 +1320,8 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
} else if (cmd == "auto_stop_ams_dry") {
op = "stop_dry";
}
if (!op.empty() && !ams_op_supported(dev_id, op)) {
if (unsupported)
*unsupported = true;
}
if (!op_allowed(op) && unsupported)
*unsupported = true;
return envelope.dump();
} catch (const std::exception&) {
return json_str;
+5 -40
View File
@@ -94,10 +94,10 @@ public:
bool lan_mode) override;
// Filament sync (subscription): `subscription` when the selected printer
// reports a material system (get_capabilities protocol.features.fms),
// `none` otherwise. The mode is transport-agnostic; the lane_data fetch and
// topology_state doorbell that keep DevFilaSystem fresh are LAN-only, and
// cloud printers get their AMS view from the mirrored push_status.
// 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).
@@ -109,15 +109,6 @@ public:
// Test-only: advance the LAN connection epoch without a connect/disconnect cycle.
void bump_lan_generation_for_test() { ++m_lan_generation; }
// Test-only: observe the subscription doorbell policy without spawning the refresh worker.
bool filament_doorbell_needed_for_test(const std::string& dev_id, const std::string& payload)
{ return filament_doorbell_needed(dev_id, payload); }
// Test-only: arm the fetch-failure latch (retry-on-next-LAN-frame rule).
void set_filament_failed_for_test(bool v)
{
std::lock_guard<std::mutex> lock(state_mutex);
m_filament_failed = v;
}
// 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
@@ -210,7 +201,6 @@ private:
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_http_origin; // guarded by state_mutex — http(s) origin of the LAN façade
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
@@ -220,34 +210,9 @@ private:
// 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);
// Subscription-mode filament sync. One detached worker per burst drains
// m_filament_wanted by re-reading lane_data; doorbell frames (a pushed
// print.topology_state change) and the failure latch are the only triggers —
// no timer: an idle OrcaSonar emits no frames, and cannot change lanes either.
void request_filament_refresh(const std::string& dev_id);
// OPCP §7.7: the doorbell is a CHANGE in print.topology_state.material_hash
// (lane content only — tool temperature churn inside topology_state must not
// re-fetch); consuming a new value updates m_material_hash.
bool filament_doorbell_needed(const std::string& dev_id, const std::string& payload);
// Tri-state lane_data fetch outcome (REQ-STS-007 §7.7 read semantics):
// synced — 200 with lane entries, payload built;
// none — 200 with an empty value: authoritative "no lanes", AMS view cleared;
// unknown — 404: topology not bootstrapped yet or material_units unknown;
// never latched, the next doorbell or reconnect retries;
// error — transport/5xx: latched, retried on the next inbound frame.
enum class LaneDataState { synced, none, unknown, error };
LaneDataState fetch_lane_data(const std::string& dev_id);
bool m_filament_wanted = false; // guarded by state_mutex; a fetch is pending
bool m_filament_working = false; // guarded by state_mutex; a worker owns the queue
bool m_filament_failed = false; // guarded by state_mutex; retry-on-next-LAN-frame rule
bool m_shutting_down = false; // guarded by state_mutex; workers stop taking fetches
std::string m_material_hash; // guarded by state_mutex; last doorbell value seen
std::atomic<int> m_filament_in_flight{0}; // detached workers; drained by the destructor
OrcaCloudServiceAgent* get_orca_cloud_agent();
OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true);
@@ -5482,3 +5482,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<ConfigOptionString>("filament_type")->value = "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<ConfigOptionString>("filament_type")->value = "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");
}
+1
View File
@@ -6,6 +6,7 @@ add_executable(${_TEST_NAME}_tests
test_dev_mapping.cpp
test_filament_bitmap_utils.cpp
test_device_progress.cpp
test_device_manager.cpp
test_network_versions.cpp
test_action_source.cpp
test_plugin_host_api.cpp
+156
View File
@@ -0,0 +1,156 @@
// 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;
// DeviceManager's push_status contract for the OrcaSonar virtual tray: an
// authoritative empty clears, a populated key re-enables, and a frame that
// omits the key leaves both the trays and the support flag alone.
// 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");
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");
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.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.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());
}
// 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());
machine.printer_agent_id = "orca";
CHECK_FALSE(machine.is_bbl_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));
}
// 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);
}
+98 -44
View File
@@ -127,12 +127,28 @@ TEST_CASE("filament sync follows the printer's AMS capability", "[OrcaPrinterAge
/*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);
}
@@ -155,46 +171,21 @@ TEST_CASE("a capability reply without ams_ops gates AMS writes", "[OrcaPrinterAg
CHECK(unsupported);
}
// The subscription refresh is self-triggered: a pushed frame whose print block
// carries a CHANGED topology_state.material_hash (spec REQ-STS-007 §7.7) is
// the doorbell; repeat hashes, temperature-only frames and hash-less blocks
// are not (no per-frame polling regression).
TEST_CASE("a material_hash change is the filament-sync doorbell", "[OrcaPrinterAgent][.integration]") {
// 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");
REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
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"));
const std::string frame_a = R"({"print":{"command":"push_status","topology_state":{"material_hash":"sha256:aaaaaaaaaaaaaaaa","units":[]}}})";
CHECK(agent.filament_doorbell_needed_for_test("dev-1", frame_a));
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", frame_a));
CHECK(agent.filament_doorbell_needed_for_test("dev-1",
R"({"print":{"command":"push_status","topology_state":{"material_hash":"sha256:bbbbbbbbbbbbbbbb","units":[]}}})"));
// Topology block without the doorbell token (older/foreign payload): stays quiet.
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1",
R"({"print":{"command":"push_status","topology_state":{"units":[]}}})"));
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", R"({"print":{"command":"push_status","mc_percent":10}})"));
// Not the active LAN device: never a doorbell.
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-2", frame_a));
agent.disconnect_printer();
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", frame_a));
}
// After a failed lane_data fetch there is no retry timer: any next LAN frame
// re-arms the refresh, because a changing printer keeps pushing.
TEST_CASE("a failed filament fetch retries on the next LAN frame", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
const std::string telemetry = R"({"print":{"command":"push_status","mc_percent":10}})";
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", telemetry));
agent.set_filament_failed_for_test(true);
CHECK(agent.filament_doorbell_needed_for_test("dev-1", telemetry));
agent.disconnect_printer();
// disconnect clears the latch; the next connect eager-fetches instead.
REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", telemetry));
agent.disconnect_printer();
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]") {
@@ -320,10 +311,11 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
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");
CHECK(out["print"]["lane"] == 5);
// 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"].contains("slot_id"));
CHECK(!out["print"].contains("ams_id"));
CHECK(out["print"]["tar_temp"] == 220);
// External-spool selection ("254" arrives hacked to 255 with slot_id=0):
@@ -331,17 +323,48 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
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"] == "external");
CHECK(!out["print"].contains("lane"));
CHECK(!out["print"].contains("ams_id"));
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":0,"target":255,"slot_id":255}})"));
CHECK(out["print"]["selector"] == "unload");
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":1,"slot_id":2}})"));
CHECK(out["print"]["lane"] == 6);
// A coordinate-less body must not fabricate a flat lane from a BBL tray id;
// the server validates the address and answers -19.
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","target":6}})"));
CHECK(!out["print"].contains("lane"));
CHECK(!out["print"].contains("target"));
CHECK(out["print"]["selector"] == "lane");
// 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"]["lane"] == 6);
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");
// 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);
@@ -363,9 +386,40 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
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);
// 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 (OPCP §7.8), so a
// standalone printer with no ams_ops can still write slots, while the material
// writes stay gated.
TEST_CASE("a filament_slots reply admits ams_filament_setting without ams_ops", "[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);
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);
}
+8
View File
@@ -209,6 +209,14 @@ TEST_CASE("unit: AMS capability registry reports only declared material systems"
// 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.