mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-27 02:41:17 +00:00
Initial Commit for OrcaPrinterAgent AMS Sync
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <slic3r/Utils/AmsPayload.hpp>
|
||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
|
||||
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
|
||||
|
||||
@@ -46,7 +48,7 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus
|
||||
// is cached for the device.
|
||||
agent.deliver_to_sink(
|
||||
"dev-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]}}}})",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]},"protocol":{"ams_ops":["change_filament"]}}}})",
|
||||
/*local=*/false);
|
||||
CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos);
|
||||
CHECK(last_payload.find("\"print\"") == std::string::npos);
|
||||
@@ -65,6 +67,14 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus
|
||||
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","nozzle_diameter":0.6}})", /*local=*/false);
|
||||
CHECK(last_payload.find("\"nozzle_diameter\":0.6") != std::string::npos);
|
||||
CHECK(last_payload.find("N/A") == std::string::npos);
|
||||
|
||||
// The reply's declared ams_ops register on the device (OPCP §7.8): the one
|
||||
// declared op passes the client gate, an undeclared one is rejected.
|
||||
bool unsupported = false;
|
||||
OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported);
|
||||
CHECK_FALSE(unsupported);
|
||||
OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported);
|
||||
CHECK(unsupported);
|
||||
}
|
||||
|
||||
TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") {
|
||||
@@ -92,6 +102,89 @@ TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integrat
|
||||
agent.disconnect_printer();
|
||||
}
|
||||
|
||||
// why hidden: connect_printer starts a detached connect worker, like the LAN
|
||||
// test above. The sync mode follows the printer's own AMS capability, not the
|
||||
// transport: a connected printer stays `none` until its get_capabilities reply
|
||||
// declares a material system. The registry is process-wide, so this test uses
|
||||
// ids no other test feeds capabilities for.
|
||||
TEST_CASE("filament sync follows the printer's AMS capability", "[OrcaPrinterAgent][.integration]") {
|
||||
Probe agent("/tmp");
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
|
||||
REQUIRE(agent.connect_printer("dev-ams-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
|
||||
// Connected, but no capability reply yet: still none.
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
|
||||
// features.fms=true is the authoritative material-system flag.
|
||||
agent.deliver_to_sink("dev-ams-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true},"ams_ops":["change_filament"]}}}})",
|
||||
/*local=*/true);
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription);
|
||||
|
||||
// An explicit false clears it again.
|
||||
agent.deliver_to_sink("dev-ams-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false}}}}})",
|
||||
/*local=*/true);
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
|
||||
// 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);
|
||||
|
||||
// The pull contract (Sidebar's blocking path) is gone: a pull-mode fetch is
|
||||
// refused by the mode guard, and a subscription fetch for a device that is
|
||||
// not the active printer is refused before any HTTP.
|
||||
CHECK_FALSE(agent.fetch_filament_info("dev-ams-1", Slic3r::FilamentSyncMode::pull));
|
||||
CHECK_FALSE(agent.fetch_filament_info("dev-ams-2", Slic3r::FilamentSyncMode::subscription));
|
||||
|
||||
agent.disconnect_printer();
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
}
|
||||
|
||||
// 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]") {
|
||||
Probe agent("/tmp");
|
||||
REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") {
|
||||
struct SeqProbe : OrcaPrinterAgent {
|
||||
using OrcaPrinterAgent::OrcaPrinterAgent;
|
||||
@@ -202,3 +295,65 @@ TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinte
|
||||
}
|
||||
SUCCEED();
|
||||
}
|
||||
|
||||
// OPCP spec §7.8: Bambu wire conventions must be decoded at the agent funnel,
|
||||
// never smuggled through as numeric lanes (an external-spool select once read
|
||||
// as slot_id=0 and physically loaded gate 0).
|
||||
TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical OrcaSonar bodies", "[OrcaPrinterAgent]") {
|
||||
auto canon = [](const std::string& dev, const std::string& in, bool* unsupported = nullptr) {
|
||||
bool local = false;
|
||||
return OrcaPrinterAgent::canonicalize_ams_payload(dev, in, unsupported ? unsupported : &local);
|
||||
};
|
||||
|
||||
auto out = nlohmann::json::parse(canon("dev-c1",
|
||||
R"({"print":{"command":"ams_change_filament","sequence_id":"1","target":5,"slot_id":1,"ams_id":1,"curr_temp":210,"tar_temp":220}})"));
|
||||
CHECK(out["print"]["selector"] == "lane");
|
||||
CHECK(out["print"]["lane"] == 5);
|
||||
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):
|
||||
// must become the selector op, never a lane.
|
||||
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"));
|
||||
|
||||
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);
|
||||
|
||||
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"]["tray_type"] == "PLA");
|
||||
|
||||
// Legacy RFID call shape (ams_id+slot_id, no tray_id) flattens to tray_id.
|
||||
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_get_rfid","ams_id":1,"slot_id":2}})"));
|
||||
CHECK(out["print"]["tray_id"] == 6);
|
||||
CHECK(!out["print"].contains("ams_id"));
|
||||
|
||||
const std::string canonical = R"({"print":{"command":"ams_change_filament","selector":"lane","lane":3}})";
|
||||
CHECK(canon("dev-c1", canonical) == canonical);
|
||||
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.
|
||||
Slic3r::register_ams_ops("dev-c2", {"change_filament"});
|
||||
bool unsupported = false;
|
||||
canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":255,"slot_id":255}})", &unsupported);
|
||||
CHECK(unsupported);
|
||||
unsupported = false;
|
||||
canon("dev-c2", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported);
|
||||
CHECK(unsupported);
|
||||
unsupported = false;
|
||||
canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported);
|
||||
CHECK(!unsupported);
|
||||
|
||||
// A 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);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,136 @@ TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missin
|
||||
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f);
|
||||
}
|
||||
|
||||
// why: the lane_data projection is shared by Moonraker and OrcaSonar; the
|
||||
// parser is pure so it can be checked without a live printer or a GUI.
|
||||
TEST_CASE("unit: lane_data projection maps lanes into AMS trays", "[unit][moonraker]")
|
||||
{
|
||||
const auto body = nlohmann::json::parse(R"({
|
||||
"result": {
|
||||
"namespace": "lane_data",
|
||||
"value": {
|
||||
"lane0": {"lane": "0", "material": "PLA", "color": "FF0000FF"},
|
||||
"lane1": {"lane": "1"},
|
||||
"lane2": {"lane": "not-a-number"},
|
||||
"lane3": {"lane": "3", "material": "PETG", "color": "#00FF00", "nozzle_temp": 240}
|
||||
}
|
||||
}
|
||||
})");
|
||||
|
||||
std::vector<AmsTrayData> trays;
|
||||
int max_lane_index = -1;
|
||||
REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index));
|
||||
REQUIRE(trays.size() == 3);
|
||||
CHECK(trays[0].slot_index == 0);
|
||||
CHECK(trays[0].has_filament);
|
||||
CHECK(trays[0].tray_type == "PLA");
|
||||
CHECK(trays[0].tray_color == "FF0000FF");
|
||||
CHECK(trays[1].slot_index == 1);
|
||||
CHECK_FALSE(trays[1].has_filament);
|
||||
CHECK(trays[1].tray_type.empty());
|
||||
CHECK(trays[2].slot_index == 3);
|
||||
CHECK(trays[2].nozzle_temp == 240);
|
||||
CHECK(max_lane_index == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("unit: lane_data projection rejects malformed and empty responses", "[unit][moonraker]")
|
||||
{
|
||||
std::vector<AmsTrayData> trays;
|
||||
int max_lane_index = -1;
|
||||
CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::object(), trays, max_lane_index));
|
||||
CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::parse(R"({"result":{"value":{}}})"), trays, max_lane_index));
|
||||
CHECK_FALSE(parse_moonraker_lane_data(
|
||||
nlohmann::json::parse(R"({"result":{"value":{"lane0":{"lane":"x"},"lane1":42}}})"), trays, max_lane_index));
|
||||
}
|
||||
|
||||
// why: OrcaSonar projects sensed presence separately from declared material, so a
|
||||
// loaded lane with no type must not collapse to "empty".
|
||||
TEST_CASE("unit: lane_data presence is independent of declared material", "[unit][moonraker]")
|
||||
{
|
||||
const auto body = nlohmann::json::parse(R"({
|
||||
"result": {"value": {
|
||||
"lane0": {"lane": "0", "has_filament": true},
|
||||
"lane1": {"lane": "1", "has_filament": false, "material": "PLA"},
|
||||
"lane2": {"lane": "2", "loaded": true},
|
||||
"lane3": {"lane": "3", "material": "ASA"}
|
||||
}}
|
||||
})");
|
||||
|
||||
std::vector<AmsTrayData> trays;
|
||||
int max_lane_index = -1;
|
||||
REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index));
|
||||
REQUIRE(trays.size() == 4);
|
||||
|
||||
CHECK(trays[0].has_filament);
|
||||
CHECK(trays[0].tray_type.empty());
|
||||
|
||||
CHECK_FALSE(trays[1].has_filament);
|
||||
CHECK(trays[1].tray_type == "PLA");
|
||||
|
||||
CHECK(trays[2].has_filament);
|
||||
|
||||
CHECK(trays[3].has_filament);
|
||||
CHECK(trays[3].tray_type == "ASA");
|
||||
}
|
||||
|
||||
// why: the exist bits and per-slot placeholder flag drive the sidebar's occupied
|
||||
// vs empty rendering; the builder is pure so this needs no GUI.
|
||||
TEST_CASE("unit: AMS payload sets exist bits and omits placeholder for loaded lanes", "[unit][moonraker]")
|
||||
{
|
||||
std::vector<AmsTrayData> trays = {
|
||||
{0, true, "PLA", "FF0000FF", "", 0, 0},
|
||||
{2, true, "", "", "", 0, 0}, // loaded but undeclared
|
||||
{5, true, "PETG", "00FF00", "", 0, 0},
|
||||
};
|
||||
const auto ams = build_bbl_ams_json(trays, 2, 5);
|
||||
|
||||
// Units 0 and 1 exist; slots 0, 2 and 5 hold filament (0b100101).
|
||||
CHECK(ams["ams_exist_bits"].get<std::string>() == "3");
|
||||
CHECK(ams["tray_exist_bits"].get<std::string>() == "25");
|
||||
|
||||
const auto& u0 = ams["ams"][0]["tray"];
|
||||
CHECK_FALSE(u0[0].contains("tray_slot_placeholder"));
|
||||
CHECK(u0[1].contains("tray_slot_placeholder"));
|
||||
CHECK_FALSE(u0[2].contains("tray_slot_placeholder"));
|
||||
CHECK(u0[2]["tray_type"].get<std::string>() == "");
|
||||
CHECK(u0[2]["tray_color"].get<std::string>() == "00000000");
|
||||
|
||||
const auto& u1 = ams["ams"][1]["tray"];
|
||||
CHECK(u1[0].contains("tray_slot_placeholder")); // slot 4 absent
|
||||
CHECK_FALSE(u1[1].contains("tray_slot_placeholder")); // slot 5 present
|
||||
CHECK(u1[1]["tray_color"].get<std::string>() == "00FF00FF"); // 6-hex padded
|
||||
}
|
||||
|
||||
// why: the exist-bits fields are 64-bit; multi-box rigs past lane 31 would be
|
||||
// corrupted by a 32-bit unsigned long shift on MSVC. Guards the 64-bit path.
|
||||
TEST_CASE("unit: AMS payload sets exist bits beyond 31 lanes", "[unit][moonraker]")
|
||||
{
|
||||
std::vector<AmsTrayData> trays = {
|
||||
{33, true, "PLA", "FF0000FF", "", 0, 0},
|
||||
};
|
||||
const auto ams = build_bbl_ams_json(trays, 9, 33);
|
||||
|
||||
CHECK(ams["ams_exist_bits"].get<std::string>() == "1FF"); // units 0..8
|
||||
CHECK(ams["tray_exist_bits"].get<std::string>() == "200000000"); // 1 << 33
|
||||
|
||||
const auto& u8 = ams["ams"][8]["tray"];
|
||||
CHECK_FALSE(u8[1].contains("tray_slot_placeholder")); // slot 33 present
|
||||
}
|
||||
|
||||
// why: the sync mode keys off the printer's declared material system; a device
|
||||
// with no capability record must never read as AMS-capable.
|
||||
TEST_CASE("unit: AMS capability registry reports only declared material systems", "[unit][moonraker]")
|
||||
{
|
||||
CHECK_FALSE(has_ams_capability("cap-none"));
|
||||
register_ams_capability("cap-true", true);
|
||||
CHECK(has_ams_capability("cap-true"));
|
||||
register_ams_capability("cap-false", false);
|
||||
CHECK_FALSE(has_ams_capability("cap-false"));
|
||||
// Later replies overwrite: a removed material system must clear the flag.
|
||||
register_ams_capability("cap-true", false);
|
||||
CHECK_FALSE(has_ams_capability("cap-true"));
|
||||
}
|
||||
|
||||
// why: these builders preserve the Bambu firmware dialect byte-for-byte, including its trailing space.
|
||||
TEST_CASE("unit: BBL AMS gcode builders preserve command bytes", "[unit][bbl]")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user