diff --git a/docs/HLSD/multi-extruder-ams.md b/docs/HLSD/multi-extruder-ams.md new file mode 100644 index 0000000000..99305246f3 --- /dev/null +++ b/docs/HLSD/multi-extruder-ams.md @@ -0,0 +1,195 @@ +# Multi-extruder and multi-AMS device control + +`DeviceManager`/`MachineObject` model the toolheads, AMS units, and filament slots of a connected +printer and drive the device-control UI from that model. The model represents the counts as +device-reported runtime values rather than compile-time caps, so a printer with an arbitrary number +of toolheads, several AMS units, and a non-uniform number of slots per unit is handled by the same +code paths. The printer agent is responsible for translating its printer's state into the shared +JSON dialect this model consumes; the agent boundary is described in +[printer-agent.md](printer-agent.md), and this document describes the dialect and the model. + +## Model layout + +`MachineObject` owns the device subsystems that this document covers: + +- `DevExtderSystem` — the toolheads (extruders) and their per-toolhead filament/temperature state. +- `DevFilaSystem` — the AMS units (`DevAms`) and their trays (`DevAmsTray`). +- `DevNozzleSystem` — the nozzles and, on rack printers, `DevNozzleRack`. +- `DevFilaSwitch` — a filament switch that can feed several AMS units to more than one extruder. + +Each subsystem exposes its own count — `DevExtderSystem::GetTotalExtderCount()`, +`DevFilaSystem::GetAmsCount()`, `DevAms::GetSlotCount()` — and the rest of the application derives +its layout from those values rather than from fixed assumptions. + +## Wire dialect + +The dialect is the Bambu-shaped JSON that `DeviceManager` and `MachineObject` already consume. An +agent whose printer speaks something else is expected to translate into it; the agent is not free +to invent a new shape. Two representations coexist for extruders, and one for AMS/slots. + +### Extruders + +The generic representation is `print.device.extruder`, parsed by +`ExtderSystemParser::ParseV2_0` (`DevExtruderSystem.cpp`). It has a packed `state` integer and an +`info` array with one object per toolhead: + +```text +device.extruder.state packed integer + bits 0..3 total extruder count + bits 4..7 current extruder id + bits 8..11 target extruder id + bits 12..14 switch state + bits 15..18 currently loading extruder id + bit 19 busy for loading + +device.extruder.info[] one entry per toolhead + id extruder id + filam_bak array of backup-group bits + info bit 1 has filament, bit 2 buffer has filament, bit 3 nozzle present + temp bits 0..15 current temperature, bits 16..31 target temperature + spre slot_id bits 0..7, ams_id bits 8..15 (previous) + snow same packing (current) + star same packing (target) + stat bit 0..15 AMS status, bits 16..31 RFID status + hnow current nozzle id +``` + +The legacy single/dual representation is the pair of scalar fields `nozzle_temper` / +`nozzle_target_temper`, plus the `print.ams.tray_tar` / `tray_now` tray pointer, parsed by +`ExtderSystemParser::ParseV1_0`. That parser is a fallback and only fills the main extruder when the +reported toolhead count is one. + +Physical extruder ids are fixed (`MAIN_EXTRUDER_ID = 0`, `DEPUTY_EXTRUDER_ID = 1`). Bambu printers +present the two toolheads as a left/right pair and invert the physical-to-logical mapping; generic +printers use the toolhead id itself as the logical id. `DevNozzle::GetLogicExtruderId()` / +`GetExtruderId()` centralise that distinction. + +### AMS units and slots + +AMS units arrive as `print.ams.ams[]` and are parsed by `DevFilaSystemParser::ParseV1_0`. Each unit +carries an `id` and an `info` bitfield: + +```text +info bits 0..3 type: 0 dummy, 1 AMS, 2 AMS-Lite, 3 N3F, 4 N3S, 5 AMS-Lite-mixed (N9) +info bits 8..11 bound extruder id (0xE marks a switch-bound unit) +info bits 24..27 switch inlet when the unit is switch-bound +``` + +`DevAmsType` (`DevDefs.h`) enumerates the resulting unit kinds: `EXT_SPOOL`, `AMS`, `AMS_LITE`, +`N3F`, `N3S`, and the `AMS_LITE_MIXED` N9 variant. `AMS_LITE_MIXED` reports itself as `AMS_LITE` to +generic callers; code that needs the distinction uses `IsAmsLiteMixed()`. + +A unit whose bound-extruder nibble is `0xE` is switch-bound: it is kept only when a `DevFilaSwitch` +is installed, in which case its extruder id is pinned to the main extruder and its binding set +becomes `{0, 1}`; otherwise the unit is dropped from the AMS list. + +Each unit owns a map of `DevAmsTray` keyed by the tray id from the payload. The slot count is a +property of the unit kind and the reported trays: + +- `AMS`, `AMS_LITE`, and `N3F` report four slots on Bambu printers, matching the firmware contract; +- `N3S` reports one slot; +- `EXT_SPOOL` and any non-Bambu unit report the number of trays actually present. + +The tray ids are the payload's slot indices; they are expected to be unique, 0-based, and contiguous +within a unit, because the generic tray-index mapping below is cumulative. + +Bulk/extended spools are carried by `print.vir_slot`, which `DeviceManager` parses into +`MachineObject::vt_slot`. Two ids are fixed: `VIRTUAL_TRAY_MAIN_ID = 255` (extruder 0) and +`VIRTUAL_TRAY_DEPUTY_ID = 254` (extruder 1); the id convention is `vt_id = 255 - extruder_id`, so +further extruders use 253, 252, … . The `vir_slot` parser recognises the 255/254 pair; the agent +filament descriptor path `DevFilaSystemParser::ParseAgentFilament` is the entry that stores a fuller +descending set in `vt_slot`. + +## Mapping + +Several subsystems relate AMS units, trays, and extruders, and more than one place derives a tray +index. The Bambu mapping path (`DevMappingUtil`) computes `ams_id * 4 + slot_id` and knows only the +Bambu unit kinds; `DevFilaSystem` provides the device model's own mapping below. The two agree for +Bambu units and diverge for a non-Bambu unit whose slot count is not four. + +### AMS id to extruder id + +`DevFilaSystem::GetExtruderIdByAmsId()` resolves an AMS id to the extruder it feeds: + +- an AMS unit returns its bound extruder (`DevAms::GetExtruderId()`); +- a virtual tray returns `VIRTUAL_TRAY_MAIN_ID - vt_id`; +- Bambu's virtual AMS ids map to the main/deputy extruder. + +A unit may be bound to more than one extruder when a `DevFilaSwitch` feeds it; the binding set +(`DevAms::m_binded_extruder_set`) and switch inlet are read by switch-aware code while the +single-extruder id stays pinned for legacy callers. + +### Tray index + +`DevFilaSystem::GetTrayIndexMap()` maps a global tray index to an `(ams_id, slot_id)` pair and is +used for backup-slot translation, calibration addressing, and the mapping popups. + +- Bambu AMS: `ams_id * 4 + slot_id`; N3S: the AMS id; AMS-Lite-mixed (N9): `24 + slot_id`. +- Generic (non-Bambu): a running lane index — the count of trays in preceding units plus the slot + id. This depends on each unit's tray ids being 0-based and contiguous. + +### Virtual-slot recognition + +`devPrinterUtil::IsVirtualSlot()` decides whether an id denotes a virtual tray. On Bambu printers it +is the fixed pair `{255, 254}`. On generic printers it is the descending range +`[255 - count + 1, 255]`, where `count` is the selected printer preset's extruder count. The preset +is expected to match the connected device; when the two disagree the virtual range is wrong, which +is why a mismatch is reported rather than silently accepted. + +## Device-control UI + +The device-control widgets follow the same device-reported counts. + +- `AMSModel` (`AMSItem.hpp`) mirrors `DevAmsType` numerically: `EXT_AMS`, `GENERIC_AMS`, `AMS_LITE`, + `N3F_AMS`, `N3S_AMS`. A plain `AMS` maps to `GENERIC_AMS`. +- On non-Bambu printers `use_generic_ams_layout()` routes `GENERIC_AMS` units through a variable-lane + rendering that draws one lane per reported tray; Bambu keeps its fixed four-slot rendering. +- `AMSControl` picks its layout by vendor: Bambu keeps the left/right presentation + (`CreateAmsSingleNozzle` for one toolhead, `CreateAmsDoubleNozzle` for two), while non-Bambu + printers use `CreateAmsMultiNozzle`, which builds one preview and one AMS simplebook per toolhead + for any count. +- The status panel's `ExtruderImage` renders one per-toolhead state per nozzle and is resized to + `GetTotalExtderCount()`. Non-Bambu printers choose the active toolhead through + `m_generic_nozzle_selector` (shown when more than one toolhead exists); Bambu keeps its left/right + `m_nozzle_btn_panel`. Nozzle temperature controls are created on demand per toolhead. + +## Constraints + +- **Single dialect.** The agent translates its protocol into this JSON; the model does not learn + vendor protocols. Extending the model with a new unit kind or field means extending the dialect. +- **Agent-produced counts.** Toolhead, AMS, and slot counts come from the device payload. Nothing + caps them at two toolheads or four slots except the Bambu contract described above. +- **Extruder dialect invariants.** `device.extruder.state`'s count field must equal the length of + `info[]`, and `info[]` must be ordered by extruder id with `id` equal to the array index: the + parser places toolheads by array position while `GetExtderById()` indexes by id. +- **Preset/device agreement.** The generic layout and virtual-slot range derive their count from the + selected printer preset. A printer preset must therefore declare the same number of + `nozzle_diameter` entries as the device has toolheads; a mismatch is surfaced because the sidebar + and extruder logic would otherwise disagree. +- **Bambu compatibility.** Bambu-specific presentation (left/right pair, fixed 4-slot units, fixed + virtual ids, physical/logical inversion) is preserved behind the vendor check. Generic paths do + not alter it. +- **Tray-index invariant.** The generic tray-index map is cumulative, so payload tray ids must be + unique, 0-based, and contiguous per unit. A malformed id is a producer error. +- **Protocol markers.** The dialect has no neutral equivalent for some Bambu state, so agents that + lack it send empty `print.cfg`, `print.fun`, `print.aux`, and `print.stat`. Their presence selects + the new-protocol parse path; they are compatibility markers, not feature switches. + +## Main implementation locations + +- [`DevExtruderSystem`](../../src/slic3r/GUI/DeviceCore/DevExtruderSystem.h) — toolhead model and the + `device.extruder` parser +- [`DevFilaSystem`](../../src/slic3r/GUI/DeviceCore/DevFilaSystem.h) — AMS/tray model, the AMS parser, + and the mapping helpers +- [`DevDefs.h`](../../src/slic3r/GUI/DeviceCore/DevDefs.h) — `DevAmsType`, extruder ids, and virtual + tray ids +- [`DeviceManager.cpp`](../../src/slic3r/GUI/DeviceManager.cpp) — message dispatch, `vir_slot` + parsing, and device state assembly +- [`AMSControl.cpp`](../../src/slic3r/GUI/Widgets/AMSControl.cpp) — per-toolhead AMS panes + (`CreateAmsMultiNozzle`) and the generic layout +- [`AMSItem.hpp`](../../src/slic3r/GUI/Widgets/AMSItem.hpp) — `AMSModel` and the variable-lane AMS + rendering +- [`StatusPanel.cpp`](../../src/slic3r/GUI/StatusPanel.cpp) — `ExtruderImage`, the toolhead selector, + and per-toolhead temperature controls +- [`MoonrakerPrinterAgent.cpp`](../../src/slic3r/Utils/MoonrakerPrinterAgent.cpp) — producer example + (AMS payload and the `device.extruder` encoding) diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index b495f35526..03f7c6fd5d 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests test_config_value_formatter.cpp test_creality_cfs_match.cpp test_dev_mapping.cpp + test_dev_extder_system.cpp test_filament_bitmap_utils.cpp test_device_progress.cpp test_device_manager_integration.cpp diff --git a/tests/slic3rutils/test_dev_extder_system.cpp b/tests/slic3rutils/test_dev_extder_system.cpp new file mode 100644 index 0000000000..7219ad72ed --- /dev/null +++ b/tests/slic3rutils/test_dev_extder_system.cpp @@ -0,0 +1,85 @@ +// Match the include environment that libslic3r_gui TUs get from pchheader.hpp: Windows.h with +// WIN32_LEAN_AND_MEAN/NOMINMAX must come first so rpcndr.h's `byte` is processed before +// makes std::byte a competing candidate (otherwise the Windows COM headers pulled in via +// DeviceManager.hpp error with an ambiguous `byte`). wx/timer.h must precede DeviceManager.hpp, +// which includes DeviceErrorDialog.hpp (uses wxTimerEvent) before its own wx/timer.h include. +#include +#include +#include +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + +#include +#include + +#include + +#include "slic3r/GUI/DeviceManager.hpp" +#include "slic3r/GUI/DeviceCore/DevExtruderSystem.h" + +#include + +using json = nlohmann::json; +using namespace Slic3r; + +TEST_CASE("Extruder state decodes a generic toolhead set", "[DevExtderSystem]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + + // device.extruder.state packing: count 0..3, current 4..7, target 8..11, + // switch 12..14, loading 15..18, busy 19. + const int extruder_count = 4; + const int current_extruder = 2; + const int switch_state = ES_BUSY; + const int loading_extruder = 1; + const int state = extruder_count | (current_extruder << 4) | (3 << 8) | + (switch_state << 12) | (loading_extruder << 15) | (1 << 19); + + json extruder = { {"state", state}, {"info", json::array()} }; + for (int id = 0; id < extruder_count; ++id) { + // info: bit 1 has filament, bit 3 nozzle present. + // temp: current 0..15, target 16..31. spre/snow/star: slot 0..7, ams 8..15. + extruder["info"].push_back({ + {"id", id}, + {"filam_bak", json::array({id})}, + {"info", (id % 2 == 0 ? 2 : 0) | 8}, + {"temp", (200 + id) | ((250 + id) << 16)}, + {"spre", 10 << 8}, + {"snow", id | ((20 + id) << 8)}, + {"star", 30 << 8}, + {"stat", 0}, + {"hnow", id}, + }); + } + + ExtderSystemParser::ParseV2_0(extruder, obj.GetExtderSystem()); + + REQUIRE(obj.GetExtderSystem()->GetTotalExtderCount() == extruder_count); + CHECK(obj.GetExtderSystem()->GetCurrentExtderId() == current_extruder); + CHECK(static_cast(obj.GetExtderSystem()->GetSwitchState()) == switch_state); + CHECK(obj.GetExtderSystem()->GetLoadingExtderId() == loading_extruder); + CHECK(obj.GetExtderSystem()->IsBusyLoading()); + + for (int id = 0; id < extruder_count; ++id) { + const auto ext = obj.GetExtderSystem()->GetExtderById(id); + REQUIRE(ext.has_value()); + INFO("extruder " << id); + CHECK(ext->GetExtId() == id); + CHECK(ext->GetCurrentTemp() == 200 + id); + CHECK(ext->GetTargetTemp() == 250 + id); + CHECK(ext->HasFilamentInExt() == (id % 2 == 0)); + CHECK(ext->GetFilamBackup() == std::vector{id}); + CHECK(ext->GetSlotPre().ams_id == "10"); + CHECK(ext->GetSlotNow().ams_id == std::to_string(20 + id)); + CHECK(ext->GetSlotNow().slot_id == std::to_string(id)); + CHECK(ext->GetSlotTarget().ams_id == "30"); + CHECK(ext->GetNozzleId() == id); + } +} diff --git a/tests/slic3rutils/test_dev_mapping.cpp b/tests/slic3rutils/test_dev_mapping.cpp index 7d43016513..92c0cf1117 100644 --- a/tests/slic3rutils/test_dev_mapping.cpp +++ b/tests/slic3rutils/test_dev_mapping.cpp @@ -173,6 +173,57 @@ TEST_CASE("Generic AMS tray index map uses cumulative lane counts", "[DevMapping CHECK(tray_index_map.at(9).second == 4); } +TEST_CASE("Generic tray index map handles unequal per-unit slot counts", "[DevMapping]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + + // An "external" array must accompany "units" so vt_slot is reset (see the cumulative test above). + json filament = { {"units", json::array()}, {"external", json::array()} }; + const std::vector slot_counts = {4, 12}; + for (int ams_id = 0; ams_id < static_cast(slot_counts.size()); ++ams_id) { + json unit = { {"id", std::to_string(ams_id)}, {"extruder", ams_id}, {"slots", json::array()} }; + for (int slot_id = 0; slot_id < slot_counts[ams_id]; ++slot_id) + unit["slots"].push_back({{"index", slot_id}, {"loaded", false}}); + filament["units"].push_back(std::move(unit)); + } + + DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get()); + + const auto tray_index_map = obj.GetFilaSystem()->GetTrayIndexMap(); + REQUIRE(tray_index_map.size() == 16); + // Unit 0 occupies global lanes 0..3; unit 1 continues at 4..15 because the counter advances + // by the reported tray count, not by a fixed four-slot stride. + CHECK(tray_index_map.at(0).first == 0); + CHECK(tray_index_map.at(0).second == 0); + CHECK(tray_index_map.at(3).first == 0); + CHECK(tray_index_map.at(3).second == 3); + CHECK(tray_index_map.at(4).first == 1); + CHECK(tray_index_map.at(4).second == 0); + CHECK(tray_index_map.at(15).first == 1); + CHECK(tray_index_map.at(15).second == 11); +} + +TEST_CASE("A generic AMS unit reports its reported tray count", "[DevFilaSystem]") +{ + MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1"); + + json filament = { {"units", json::array()}, {"external", json::array()} }; + const std::vector slot_counts = {8, 1}; + for (int ams_id = 0; ams_id < static_cast(slot_counts.size()); ++ams_id) { + json unit = { {"id", std::to_string(ams_id)}, {"extruder", ams_id}, {"slots", json::array()} }; + for (int slot_id = 0; slot_id < slot_counts[ams_id]; ++slot_id) + unit["slots"].push_back({{"index", slot_id}, {"loaded", false}}); + filament["units"].push_back(std::move(unit)); + } + + DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get()); + + // Non-Bambu units are not capped at the four-slot Bambu contract; the reported tray count wins. + const auto& ams_list = obj.GetFilaSystem()->GetAmsList(); + REQUIRE(ams_list.at("0")->GetSlotCount() == 8); + REQUIRE(ams_list.at("1")->GetSlotCount() == 1); +} + TEST_CASE("Switch-bound AMS trays map to the left extruder", "[DevMapping]") { MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");