feat(orca-agent): integrate OrcaSonar AMS synchronization

- Translate slicer AMS commands to OrcaSonar’s canonical write format
- Gate AMS operations using printer capabilities
- Refresh capabilities when filament topology becomes available
- Apply virtual-slot removals only from full status snapshots
- Add synchronization tests and document the integration
This commit is contained in:
Lam Wei Lun
2026-09-24 12:34:51 +08:00
parent 4cc23e6492
commit aa934409a7
11 changed files with 364 additions and 36 deletions
@@ -5495,14 +5495,14 @@ TEST_CASE("filament_id_by_type resolves a modifier type to its generic family",
pla.is_visible = true;
pla.is_compatible = true;
pla.filament_id = "GFL99";
pla.config.option<ConfigOptionString>("filament_type")->value = "PLA";
pla.config.option<ConfigOptionStrings>("filament_type")->values = {"PLA"};
Preset &petg = add_inmemory_preset(bundle.filaments, "Generic PETG @System");
petg.is_system = true;
petg.is_visible = true;
petg.is_compatible = true;
petg.filament_id = "GFG99";
petg.config.option<ConfigOptionString>("filament_type")->value = "PETG";
petg.config.option<ConfigOptionStrings>("filament_type")->values = {"PETG"};
CHECK(bundle.filaments.filament_id_by_type("PETG Basic") == "GFG99");
CHECK(bundle.filaments.filament_id_by_type("PLA") == "GFL99");
+86
View File
@@ -88,6 +88,92 @@ TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManag
CHECK(machine.vt_slot.empty());
}
// OrcaSonar emits a virtual slot only when the layout has it, so it counts as
// present even with no filament: the user's configuration wins.
TEST_CASE("A configured vir_slot is present even with no material", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].is_exists);
CHECK(machine.vt_slot[1].is_exists);
}
// A full vir_slot snapshot is authoritative: an id it omits is removed, so a
// tool-count shrink does not leave a stale deputy tray.
TEST_CASE("A populated vir_slot prunes virtual trays it omits", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false);
REQUIRE(machine.vt_slot.size() == 1);
CHECK(machine.vt_slot[0].id == "255");
}
// A delta frame (msg=1) is not authoritative for removals: an entry it omits
// stays held, and an entry it names is updated in place (spec §7.3).
TEST_CASE("A delta vir_slot does not prune virtual trays it omits", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
// Delta names only the deputy: the main must survive, the deputy updates.
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":1,"vir_slot":[{"id":"254","tag_uid":"ABCDEF0123456789"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].id == "255");
CHECK(machine.vt_slot[1].id == "254");
CHECK(machine.vt_slot[1].tag_uid == "ABCDEF0123456789");
}
// An empty delta is not authoritative and must preserve the known layout.
TEST_CASE("An empty delta vir_slot keeps the virtual trays", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":1,"vir_slot":[]}})", false);
REQUIRE(machine.vt_slot.size() == 2);
CHECK(machine.vt_slot[0].id == "255");
CHECK(machine.vt_slot[1].id == "254");
CHECK(machine.ams_support_virtual_tray);
}
TEST_CASE("Capability flags parse without a DeviceManager", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan",
R"({"info":{"command":"get_capabilities","capabilities":{"flags":{"support_tunnel_mqtt":true}}}})", false);
CHECK(machine.is_support_tunnel_mqtt);
}
// A filament setting landing on the virtual tray also marks it present.
TEST_CASE("A virtual tray setting marks the tray present", "[DeviceManager]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false);
REQUIRE(machine.vt_slot.size() == 1);
machine.vt_slot[0].is_exists = false;
machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":255,"tray_id":255,"tray_color":"FF0000FF","tray_type":"PLA","tray_info_idx":"GFA00","nozzle_temp_min":190,"nozzle_temp_max":230}})", false);
CHECK(machine.vt_slot[0].is_exists);
}
// acks that target the virtual tray must survive an emptied vt_slot.
TEST_CASE("Virtual tray acks are safe with no virtual tray", "[DeviceManager]")
{
@@ -394,6 +394,20 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &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"});
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":"external"}})", &unsupported);
CHECK(!unsupported);
// Other selectors still gate on their own token.
unsupported = false;
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"unload"}})", &unsupported);
CHECK(unsupported);
// A device with no capabilities record is never gated (server backstops).
unsupported = false;
canon("dev-c3", R"({"print":{"command":"ams_user_setting","ams_id":0}})", &unsupported);
@@ -441,3 +455,72 @@ TEST_CASE("an AMS tray selection sends the tray's ams_id and slot_id", "[OrcaPri
CHECK(body["print"]["ams_id"] == 0);
CHECK(body["print"]["slot_id"] == 3);
}
// A filament frame can arrive after the get_capabilities reply was missed (the
// topology resolved late, or Klipper restarted). While the device is still
// unconfirmed, one filament frame must re-ask, throttled, and stop once a reply
// arrives so the session can leave FilamentSyncMode::none.
TEST_CASE("a filament frame re-requests capabilities while the topology is unconfirmed", "[OrcaPrinterAgent]") {
struct RefreshProbe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
using OrcaPrinterAgent::deliver_to_sink;
std::vector<std::string> refreshes;
void request_filament_capabilities(const std::string& dev_id, bool /*local*/) override {
refreshes.push_back(dev_id);
}
} agent("/tmp");
agent.set_on_message_fn([](std::string, std::string) {});
const std::string frame = R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})";
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
REQUIRE(agent.refreshes.size() == 1);
// Throttled: a second frame immediately after does not ask again.
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
CHECK(agent.refreshes.size() == 1);
// A capability reply confirms the topology: no further re-request.
agent.deliver_to_sink("dev-refresh-1",
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"filament_slots":true}}}}})",
/*local=*/true);
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
CHECK(agent.refreshes.size() == 1);
// A status without filament state never triggers a request.
agent.deliver_to_sink("dev-refresh-2", R"({"print":{"command":"push_status","mc_percent":10}})", /*local=*/true);
CHECK(agent.refreshes.size() == 1);
}
// Deselecting a cloud device forgets its declaration, so a later session starts
// from "no reply yet" instead of a stale one. With no record an undeclared op is
// admitted again, which is how a freshly selected device starts.
TEST_CASE("deselecting a cloud device forgets its capabilities", "[OrcaPrinterAgent]") {
OrcaPrinterAgent agent("/tmp");
Slic3r::register_ams_ops("dev-cloud-clear", {"change_filament"});
Slic3r::register_filament_slots("dev-cloud-clear", true);
CHECK_FALSE(Slic3r::ams_op_supported("dev-cloud-clear", "external"));
agent.set_user_selected_machine("dev-cloud-clear");
agent.set_user_selected_machine("");
CHECK(Slic3r::ams_op_supported("dev-cloud-clear", "external"));
CHECK_FALSE(Slic3r::has_filament_slots("dev-cloud-clear"));
}
// A LAN feed for the same device id must keep its declaration when the cloud
// selection is cleared: clearing it would strand an independently live session.
// why hidden: spawns the LAN connect worker, like the other connect tests.
TEST_CASE("deselecting a cloud device keeps a live LAN declaration", "[OrcaPrinterAgent][.integration]") {
OrcaPrinterAgent agent("/tmp");
Slic3r::register_ams_ops("dev-lan-keep", {"change_filament"});
Slic3r::register_filament_slots("dev-lan-keep", true);
REQUIRE(agent.connect_printer("dev-lan-keep", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
agent.set_user_selected_machine("dev-lan-keep");
agent.set_user_selected_machine("");
CHECK_FALSE(Slic3r::ams_op_supported("dev-lan-keep", "external"));
CHECK(Slic3r::has_filament_slots("dev-lan-keep"));
agent.disconnect_printer();
}