fix(orca-agent): retain virtual trays and preserve external AMS ids

External spool ids 254/255 keep their identity through filament changes and RFID refresh
Orca virtual trays persist across push_status snapshots and reset on reconnect
AMS writes gate on the device's declared FMS and filament-slot capabilities
This commit is contained in:
Lam Wei Lun
2026-09-24 23:49:30 +08:00
parent 37b7e70490
commit d3003dfa5e
12 changed files with 372 additions and 155 deletions
+130 -68
View File
@@ -546,6 +546,21 @@ bool MachineObject::is_bbl_agent() const
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();
@@ -1700,8 +1715,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";
@@ -2588,11 +2603,11 @@ void MachineObject::reset()
json empty_j;
print_json.diff2all_base_reset(empty_j);
// 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.
// 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_) {
@@ -2898,9 +2913,8 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
try {
bool restored_json = false;
// A frame is authoritative for removals only when it is a full snapshot
// (msg=0, or a LAN frame with no msg). Delta frames (msg=1) merge into
// the stored state and MUST NOT remove entries they omit (spec §7.3).
// 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)
@@ -3313,23 +3327,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);
}
}
}
@@ -4069,60 +4095,99 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
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()) {
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;
}
};
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();
}
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()) {
// Only a full snapshot can authoritatively clear the layout.
if (full_snapshot) {
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.
} else {
ams_support_virtual_tray = true;
if (full_snapshot) {
// A full snapshot is authoritative: rebuild
// from it so an id absent from the list is
// removed, not left stale (spec §7.3).
std::vector<DevAmsTray> fresh;
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
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()) {
if (fresh.empty())
fresh.push_back(vslot);
}
else {
else
fresh[0] = vslot;
}
}
else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) {
// vt_slot[1] is the deputy. Only the main
// branch creates index 0, so an orphan
// deputy (no main) is dropped, not indexed.
if (!fresh.empty()) {
if (fresh.size() > 1) {
fresh[1] = vslot;
}
else {
fresh.push_back(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 {
// A delta only updates the entries it names;
// an omitted entry stays held (spec §7.3).
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
} 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;
@@ -4132,9 +4197,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
}
}
}
}
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);
@@ -4148,9 +4211,8 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
vt_slot.push_back(main_slot);
}
}
// 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.
// For Bambu and legacy agents, missing keys do not change
// the current tray state.
}
catch (...) {
;
+3
View File
@@ -113,6 +113,7 @@ private:
std::string dev_name;
std::string dev_ip;
std::string access_code;
std::unordered_set<std::string> m_orca_seen_virtual_trays;
// type, time stamp, delay
std::vector<std::tuple<std::string, uint64_t, uint64_t>> message_delay;
@@ -164,6 +165,8 @@ public:
// 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);
+2
View File
@@ -2173,6 +2173,7 @@ void GUI_App::init_networking_callbacks()
/* request_pushing */
MachineObject* obj = m_device_manager->get_my_machine(tunnel ? dev_id.substr(7) : dev_id);
if (obj) {
obj->reset_orca_virtual_trays_for_reconnect();
obj->is_tunnel_mqtt = tunnel;
obj->command_request_push_all(true);
obj->command_get_version();
@@ -2215,6 +2216,7 @@ void GUI_App::init_networking_callbacks()
if (obj->is_lan_mode_printer()) {
if (state == ConnectStatus::ConnectStatusOk) {
obj->reset_orca_virtual_trays_for_reconnect();
obj->command_request_push_all(true);
obj->command_get_version();
event.SetInt(0);
+2 -1
View File
@@ -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);
}
}
+5 -8
View File
@@ -90,11 +90,9 @@ void build_ams_payload_for_device(const std::string& dev_id,
const QueueOnMainFn& queue_fn,
const TrayInfoResolver& vendor_resolver = {});
// 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
// applies to OrcaSonar printers that answered. An answer without ams_ops
// registers an empty op set, so it gates every write.
// 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);
@@ -104,9 +102,8 @@ 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 has answered get_capabilities at all (any reply, even one
// declaring no material system). Lets a client re-request capabilities only
// while the topology is still unconfirmed, instead of on every filament frame.
// 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
+18 -3
View File
@@ -150,11 +150,26 @@ void BBLPrinterAgent::set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud)
// Communication
// ============================================================================
std::string BBLPrinterAgent::ams_refresh_rfid_gcode(const std::string& slot_id)
{
return (boost::format("M620 R%1% \n") % slot_id).str();
}
std::string BBLPrinterAgent::ams_calibrate_gcode(int ams_id)
{
return (boost::format("M620 C%1% \n") % ams_id).str();
}
std::string BBLPrinterAgent::ams_select_tray_gcode(const std::string& tray_id)
{
return (boost::format("M620 P%1% \n") % tray_id).str();
}
int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode)
{
nlohmann::json j;
if (ams_id == -1) {
const std::string gcode = (boost::format("M620 R%1% \n") % slot_id).str();
const std::string gcode = ams_refresh_rfid_gcode(std::to_string(slot_id));
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
@@ -170,7 +185,7 @@ int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, int ams_id, in
int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode)
{
const std::string gcode = (boost::format("M620 C%1% \n") % ams_id).str();
const std::string gcode = ams_calibrate_gcode(ams_id);
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
@@ -180,7 +195,7 @@ int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int s
int BBLPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
const std::string gcode = (boost::format("M620 P%1% \n") % tray_id).str();
const std::string gcode = ams_select_tray_gcode(tray_id);
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
+3
View File
@@ -32,6 +32,9 @@ public:
int command_ams_refresh_rfid(std::string dev_id, int ams_id, int slot_id, int sequence_id, bool lan_mode) override;
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override;
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
static std::string ams_refresh_rfid_gcode(const std::string& slot_id);
static std::string ams_calibrate_gcode(int ams_id);
static std::string ams_select_tray_gcode(const std::string& tray_id);
int command_axis_control(std::string dev_id, std::string axis, double unit, double input_val, int speed,
bool is_core_xy, bool supports_mqtt_axis_control, int sequence_id, bool lan_mode) override;
std::string default_lan_username() const override { return "bblp"; }
+77 -53
View File
@@ -41,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,
@@ -779,18 +781,7 @@ 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;
// }
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());
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*/)
@@ -808,8 +799,26 @@ std::string OrcaPrinterAgent::build_ams_change_filament_body(int tray_number, in
// 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"]["ams_id"] = tray_number / 4;
j["print"]["slot_id"] = tray_number % 4;
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 j.dump();
}
@@ -1274,14 +1283,9 @@ 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 Bambu-convention print.ams_* payloads onto the canonical OrcaSonar
// bodies (OPCP spec §7.8) and enforce the device's declared ams_ops. DevFilaSystem
// is built from flat lane indices, so the Bambu encodings the shared
// MachineObject command builders emit — ams_id/slot_id 4-tray pseudo-groups,
// virtual tray ids 254/255 — are decoded here, at the single funnel all print
// commands cross, and never reach the server. A command whose op token the
// device did not declare short-circuits as *unsupported (CAP_NOT_AVAILABLE),
// which publish_json already renders as the friendly unsupported dialog.
// 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)
@@ -1299,18 +1303,34 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
// 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 (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());
// A lane can resolve to an external slot server-side (§7.8), so
// either write op admits it; other selectors gate on their own token.
const bool allowed = (sel == "lane")
? (op_allowed("change_filament") || op_allowed("external"))
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;
@@ -1325,32 +1345,27 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
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");
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 {
// 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.erase("ams_id");
print.erase("slot_id");
if (ams >= 0 && slot >= 0) {
print["ams_id"] = ams;
print["slot_id"] = slot;
}
op = "change_filament";
}
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);
@@ -1375,7 +1390,15 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
if (!print.contains("tray_id")) {
const int ams = int_or("ams_id", -1);
const int slot = int_or("slot_id", -1);
if (ams >= 0 && slot >= 0) {
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");
@@ -1384,7 +1407,8 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
} else if (cmd == "auto_stop_ams_dry") {
op = "stop_dry";
}
if (!op_allowed(op) && unsupported)
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&) {
+3
View File
@@ -122,6 +122,9 @@ public:
// (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
// thread via queue_on_main_fn when set). Body of every connection's MessageHandler.
+65 -3
View File
@@ -26,9 +26,8 @@
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.
// 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
@@ -36,6 +35,7 @@ using namespace Slic3r;
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");
@@ -49,6 +49,7 @@ TEST_CASE("An empty vir_slot clears the virtual trays", "[DeviceManager]")
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);
@@ -62,6 +63,7 @@ TEST_CASE("A missing vir_slot keeps the virtual trays", "[DeviceManager]")
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());
@@ -79,6 +81,7 @@ TEST_CASE("Virtual trays repopulate after an authoritative clear", "[DeviceManag
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());
@@ -93,6 +96,7 @@ TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManag
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);
@@ -106,6 +110,7 @@ TEST_CASE("A configured vir_slot is present even with no material", "[DeviceMana
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);
@@ -121,6 +126,7 @@ TEST_CASE("A populated vir_slot prunes virtual trays it omits", "[DeviceManager]
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);
@@ -138,6 +144,7 @@ TEST_CASE("A delta vir_slot does not prune virtual trays it omits", "[DeviceMana
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);
@@ -150,6 +157,59 @@ TEST_CASE("An empty delta vir_slot keeps the virtual trays", "[DeviceManager]")
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");
@@ -196,9 +256,11 @@ TEST_CASE("Only the BBL agent is RFID-locking", "[DeviceManager]")
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());
+63 -18
View File
@@ -324,22 +324,23 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
CHECK(!out["print"].contains("target"));
CHECK(out["print"]["tar_temp"] == 220);
// External-spool selection ("254" arrives hacked to 255 with slot_id=0):
// must become the selector op, never a lane.
// 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"] == "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");
// 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"]["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.
@@ -378,14 +379,18 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
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");
// Declared ams_ops gate at the client: only change_filament is supported.
// 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(unsupported);
CHECK_FALSE(unsupported);
unsupported = false;
canon("dev-c2", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported);
CHECK(unsupported);
@@ -399,14 +404,24 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
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.
@@ -420,10 +435,9 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
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]") {
// 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}}}}})",
@@ -436,12 +450,25 @@ TEST_CASE("a filament_slots reply admits ams_filament_setting without ams_ops",
&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 box wider than 4 slots is shown as several 4-tray units, so the BBL tray id
@@ -460,6 +487,24 @@ TEST_CASE("an AMS tray selection sends the tray's ams_id and slot_id", "[OrcaPri
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
+1 -1
View File
@@ -232,7 +232,7 @@ 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);
}