Compare commits

...
Author SHA1 Message Date
Ian Chua 3684948150 Merge branch 'main' into feat/generic-extruders-and-ams 2026-10-20 14:03:17 +08:00
Ian Chua 0e5b07012f feat: add labels to temp control 2026-10-20 14:03:04 +08:00
SoftFever 6626377abc Stop re-asking for plugin permissions granted in an earlier session (#16658) 2026-10-20 13:38:44 +08:00
HanifKoh 74d4392dae Keep a Disabled Filament Override Off After a Restart (Key List Variant) (#16650)
Keep a Disabled Filament Override Off After a Restart

A child preset stores "nil" both for an override the user switched off and
for a per-variant column it inherits from its parent, and the loader filled
every nil from the parent. Whenever the parent preset sets Retract Before
Wipe or another override, switching it off lasted only until the next
restart, project open or newer cloud copy.

Treat the keys in is_filament_extruder_override_key, now including the
ironing overrides, as "nil means off": Preset::save and the project-embedded
preset diff write such an option with all its columns, and the loader keeps
its nil columns while still mapping them onto the parent's variants by
name. Every other nullable option keeps nil as "inherit this column". The
cloud diff only force-emits a disabled override whose default is a real
value.
2026-10-20 13:38:20 +08:00
HanifKoh 0d5b3a79d7 Keep a Scalar Config Option's Value When Deserializing Fails (#16641)
A failed stream extraction writes 0 into its target, so Float, Int, Percent and FloatOrPercent options were overwritten before deserialize reported the error, and Point/Point3 kept a partially scanned value. Parse into a temporary and assign only on success; FloatOrPercent sets its percent flag only then too.
2026-10-20 13:02:15 +08:00
Ian Chua 53d7b87db5 feat: ams dropdown in sidebar 2026-10-19 18:32:18 +08:00
Ian Chua f38f2bddec fix: test fixture 2026-10-16 22:23:42 +08:00
Ian Chua 1d291f6f04 fix: clang-tidy 2026-10-16 21:28:37 +08:00
Ian Chua 5390b789f1 fix: nozzle & preset sync 2026-10-16 20:51:22 +08:00
Ian Chua f68fbfed58 feat: update happy hare parsing 2026-10-16 20:48:57 +08:00
Ian Chua 7ed4095b20 fix: clang-tidy 2026-10-15 16:06:50 +08:00
Ian Chua ee1cc1b227 Merge branch 'feat/ams-parsing' into feat/generic-extruders-and-ams 2026-10-15 16:05:00 +08:00
Ian Chua 46096416cd Merge branch 'main' into feat/generic-n-extruders 2026-10-15 16:04:30 +08:00
Ian Chua 74fe2b7996 Merge branch 'main' into feat/generic-n-extruders 2026-10-15 14:45:26 +08:00
Ian Chua 38f649fc00 Merge branch 'main' into feat/ams-parsing 2026-10-15 14:42:59 +08:00
peachismomo 23f6771dcb fix: parse AFC lane data and non-bambu ams tray indices correctly 2026-10-11 20:11:47 +08:00
Ian Chua aebc0a2979 fix: change ams type when parsing AFC mmu 2026-10-09 18:27:14 +08:00
Ian Chua 6f0ec60d21 feat: update happy hare parsing 2026-10-08 21:26:00 +08:00
Ian Chua 8e43a2ac13 fix: center external spool 2026-10-08 21:19:00 +08:00
Ian Chua 6b366ad8a9 fix: sync_ams_list should drop placeholder filaments 2026-10-08 20:42:11 +08:00
Ian Chua 7d8261668e fix: use extruder count for bbl and m_nozzle_book for non bbl 2026-10-08 20:29:47 +08:00
Ian Chua 00127beffa fix: use multi-nozzle AMS layout for non-Bambu printers at any extruder count 2026-10-08 19:26:43 +08:00
Lam Wei Lun 927eb9922c Map slot id directly 2026-10-08 18:37:09 +08:00
Ian Chua 3d62769664 feat: expand filament parsing in MoonrakerPrinterAgent 2026-10-08 17:46:01 +08:00
Ian Chua e5e30ee0e8 fix: harden generic N-extruder device model and status/AMS UI, add N-toolhead ExtruderImage sizing, tests, and HLSD 2026-10-08 17:39:44 +08:00
Ian Chua 7515fb480f fix: remove dead code 2026-10-08 15:56:20 +08:00
Ian Chua acc1d6fd21 Merge branch 'main' into feat/generic-n-extruders 2026-10-08 14:49:50 +08:00
Ian Chua 6ca0fc5ef1 fix: clang-tidy 2026-10-07 19:33:58 +08:00
Ian Chua bcf7d078cd Merge branch 'main' into feat/generic-n-extruders 2026-10-07 19:25:52 +08:00
Ian Chua 15fc96fca7 Merge branch 'main' into feat/generic-n-extruders 2026-10-07 19:25:35 +08:00
Ian Chua 1fffda0533 chore: add tests and HLSD 2026-10-07 19:05:06 +08:00
Ian Chua 33e26a127e fix: warn when toolhead count differs from the selected printer preset's nozzle_diameter count 2026-10-07 18:40:41 +08:00
Ian Chua 6dd0cb9eba fix: harden generic N-extruder device paths 2026-10-07 18:30:49 +08:00
Ian Chua 9bf0fc2a8e test: reset virtual trays in the generic AMS tray index map case 2026-10-06 20:53:46 +08:00
Ian Chua 5bf6db95b3 fix: clang-tidy 2026-10-06 19:56:53 +08:00
Ian Chua 0feb87b417 feat: generic N extruders, multiple AMS and AMS slots 2026-10-06 19:22:10 +08:00
38 changed files with 5917 additions and 902 deletions

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
# 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.
### External spool slots
External/manual spools are virtual trays in `MachineObject::vt_slot`, separate from the trays owned
by `DevFilaSystem`. Their stable id is `vt_id = VIRTUAL_TRAY_MAIN_ID - extruder_id`: 255 for
extruder 0, 254 for extruder 1, then 253, 252, and so on. The index in `vt_slot` is therefore
`VIRTUAL_TRAY_MAIN_ID - vt_id`, not the order in which a printer happens to report trays.
For non-Bambu printers, slot topology comes from the live `DevExtderSystem` extruder count, not from
external-spool telemetry. `MachineObject::ensure_external_virtual_tray_slots()` grows `vt_slot` to
one entry per extruder and enables virtual-tray support even when the agent reports no `vir_slot`,
`vt_tray`, or external-spool data. It only adds missing entries, so reported tray metadata is kept.
The shared ensure runs after push-status parsing has updated the extruder count and after the generic
AMS/agent-filament parsers. Thus agents may report external-spool metadata, but do not have to invent
empty slots just to make each toolhead's external-spool row visible.
When a payload does report virtual trays, `vir_slot` entries are placed by their id and missing
indices are filled with placeholders. Generic agent `external` entries are likewise placed by
extruder id, so sparse or out-of-order reports do not shift a spool onto another toolhead. An empty
placeholder represents the slot's existence, not a claim that the printer reported a loaded spool.
Bambu keeps its fixed 255/254 virtual-tray pair and does not use generic slot synthesis.
## Mapping
Several subsystems relate AMS units, trays, and extruders, and more than one place derives a tray
index. Bambu mapping computes `ams_id * 4 + slot_id` for its fixed-width units; generic mapping in
`DevFilaSystem` and the sidebar matcher uses the count of lanes in preceding units plus the slot id.
The two agree for Bambu units and differ 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.
The sidebar sync matcher uses the same identities. Bambu retains its fixed left/right AMS and
external-spool choices. For a generic printer, a full sync considers AMS units according to their
bound extruder ids and considers every external slot by `255 - vt_id`; a split left/right mapping
still restricts itself to the corresponding legacy extruders 0 and 1. Generic AMS lanes use the
cumulative tray index above, while their `ams_id`/`slot_id` pair remains the address used to update
the matching sidebar entry. The runtime filament-list configs also carry a transient device-extruder
association so generic entries are grouped under the correct toolhead after sync; that association
is removed before the configs enter the slicing pipeline. Bambu keeps the existing key-based
left/right grouping.
### 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. Slot
topology is grown from the live device extruder count, while this recognition range still uses the
preset count; the preset is expected to match the connected device. A mismatch can therefore make
the recognized virtual-id range disagree with `vt_slot`, and is reported by the existing count
warning 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 toolhead count, not vendor: `CreateAmsSingleNozzle` for one
toolhead, `CreateAmsDoubleNozzle` for two, and `CreateAmsMultiNozzle` (one preview and one AMS
simplebook per toolhead) for three or more. A two-toolhead generic printer therefore keeps the
legacy left/right presentation; that is deliberate.
- The status panel builds external-spool entries from `MachineObject::vt_slot`; generic slot
synthesis keeps one empty entry available for each live toolhead even when the printer does not
report external-spool state.
- 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.
- **Runtime counts.** The agent reports toolhead count and AMS unit/tray data. The shared model derives
generic external-spool slot count from the parsed live toolhead count; nothing caps the number of
toolheads or generic slots except the id range and 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.
- **One filament road per toolhead.** Each toolhead renders a single AMS filament road rather than an
independent per-toolhead road state. A generic scaffold that allowed the latter was removed as
unused; the one-extruder-per-toolhead road is the intended model.
- **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 only as a log warning
(`StatusPanel::check_extruder_count_mismatch()`), deliberately log-only, because the sidebar and
extruder logic would otherwise silently 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
- [`DevMapping.cpp`](../../src/slic3r/GUI/DeviceCore/DevMapping.cpp) — filament-to-device tray matching
for Bambu and generic external/AMS slots
- [`PresetBundle.cpp`](../../src/libslic3r/PresetBundle.cpp) — per-extruder grouping of the transient
device filament list
- [`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, external-slot topology, 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)
+41 -16
View File
@@ -377,7 +377,8 @@ public:
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) = 0;
// keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit".
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil = false) = 0;
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
// Resize the vector of values, copy the newly added values from opt_default if provided.
@@ -580,7 +581,7 @@ public:
//set a item related with extruder variants when loading user config, only set the different value of some extruder
//rhs: item from user config
//diff_index: which index in this vector need to be set
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) override
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil) override
{
if (rhs->type() == this->type()) {
// Assign the first value of the rhs vector.
@@ -592,7 +593,7 @@ public:
for (size_t i = 0; i < diff_index.size(); i++) {
if (diff_index[i] != -1) {
for (size_t j = 0; j < stride; j++) {
if (!other->is_nil(diff_index[i] * stride))
if (keep_nil || !other->is_nil(diff_index[i] * stride))
this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
}
}
@@ -841,8 +842,12 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
}
bool operator==(const ConfigOption &rhs) const override
@@ -1033,8 +1038,12 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
int value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
}
ConfigOptionInt& operator=(const ConfigOption *opt)
@@ -1252,8 +1261,12 @@ public:
UNUSED(append);
// don't try to parse the trailing % since it's optional
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
}
private:
@@ -1365,10 +1378,14 @@ public:
bool deserialize(const std::string &str, bool append = false) override
{
UNUSED(append);
this->percent = str.find_first_of("%") != std::string::npos;
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
this->percent = str.find_first_of("%") != std::string::npos;
return true;
}
private:
@@ -1538,8 +1555,12 @@ public:
{
UNUSED(append);
char dummy;
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 ||
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2;
Vec2d value;
if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
return false;
this->value = value;
return true;
}
private:
@@ -1658,8 +1679,12 @@ public:
{
UNUSED(append);
char dummy;
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 ||
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3;
Vec3d value;
if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
return false;
this->value = value;
return true;
}
private:
+8 -5
View File
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
{
ConfigOption *opt_src = config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src);
else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset)
{
ConfigOption *opt_src = preset.config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src);
else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2199,9 +2199,12 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map<std
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
if (m_type == Preset::TYPE_FILAMENT) {
for (const std::string& opt_key : filament_extruder_override_keys) {
if (key_values.count(opt_key))
continue; // already carried by the diff
for (const std::string& opt_key : preset.config.keys()) {
if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
continue; // not an override, or already carried by the diff
const ConfigOptionDef *def = print_config_def.get(opt_key);
if (def == nullptr || !def->default_value || def->default_value->is_nil())
continue; // the option's default is nil too, so an absent key cannot come back as a value
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
if (opt_vec == nullptr)
continue;
+162 -53
View File
@@ -4511,6 +4511,53 @@ static void warn_ambiguous_filament_id_match(const PresetCollection &filaments,
<< "\", also matches " << others;
}
// Orca: resolve a compatible system preset from a printer-reported filament type. Prefers an exact
// "Generic <type>" match, then the longest generic whose filament_type appears as a whole word in
// the reported type (e.g. "ASA" in "ASA Sparkle"). On success `generic_name_out` holds the
// "Generic <...>" name used for compatibility messages. Shared by the known-filament_id lookup and
// the data-bearing-but-unmapped tray path so both pick the same preset.
static PresetCollection::Iterator find_generic_preset_for_type(PresetCollection &filaments, const std::string &filament_type, std::string &generic_name_out)
{
if (filament_type.empty())
return filaments.end();
const std::string generic_name = "Generic " + filament_type;
auto iter = std::find_if(filaments.begin(), filaments.end(), [&generic_name](const Preset &f) {
return f.is_compatible && f.is_system && boost::algorithm::starts_with(f.name, generic_name);
});
if (iter != filaments.end()) {
generic_name_out = generic_name;
return iter;
}
// Similarity fallback: find a generic preset whose filament_type
// appears as a whole word in the reported type (e.g. "ASA" in "ASA Sparkle").
auto upper_type = boost::to_upper_copy(filament_type);
auto contains_word = [](const std::string& haystack, const std::string& needle) {
auto pos = haystack.find(needle);
while (pos != std::string::npos) {
bool start_ok = (pos == 0 || !std::isalnum(static_cast<unsigned char>(haystack[pos - 1])));
bool end_ok = (pos + needle.size() >= haystack.size() ||
!std::isalnum(static_cast<unsigned char>(haystack[pos + needle.size()])));
if (start_ok && end_ok)
return true;
pos = haystack.find(needle, pos + 1);
}
return false;
};
// Find the longest-matching preset type to prefer e.g. "PA-CF" over "PA".
size_t best_len = 0;
for (auto it = filaments.begin(); it != filaments.end(); ++it) {
if (!it->is_compatible || !it->is_system || !boost::algorithm::starts_with(it->name, "Generic "))
continue;
auto preset_type = boost::to_upper_copy(it->config.opt_string("filament_type", 0u));
if (preset_type.size() > best_len && contains_word(upper_type, preset_type)) {
iter = it;
best_len = preset_type.size();
generic_name_out = "Generic " + it->config.opt_string("filament_type", 0u);
}
}
return iter;
}
void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info)
{
combox_info.clear();
@@ -4566,7 +4613,9 @@ void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info)
unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfig *,std::string>> &unknowns, bool use_map, std::map<int, AMSMapInfo> &maps, bool enable_append, MergeFilamentInfo &merge_info, bool color_only)
{
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "use_map:" << use_map << " enable_append:" << enable_append;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": use_map %1% enable_append %2% color_only %3% start_presets %4% start_colors %5%")
% use_map % enable_append % color_only % this->filament_presets.size()
% project_config.option<ConfigOptionStrings>("filament_colour")->values.size();
std::vector<std::string> ams_filament_presets;
std::vector<std::string> ams_filament_colors;
std::vector<std::string> ams_filament_color_types;
@@ -4578,6 +4627,10 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
bool valid{false};
bool is_map{false};
bool is_placeholder{false};
// Orca: set for a tray that reports filament data (type and/or loaded flag) but carries
// no preset id. It is not `valid` (no id to map through), yet it still holds a real spool
// and is eligible for the same append step as a mapped tray.
bool has_reported_data{false};
std::string filament_color = "";
std::string filament_color_type = "";
std::string filament_preset = "";
@@ -4591,12 +4644,32 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
auto filament_id = ams.opt_string("filament_id", 0u);
auto filament_color = ams.opt_string("filament_colour", 0u);
auto filament_color_type = ams.opt_string("filament_colour_type", 0u);
auto filament_type = ams.opt_string("filament_type", 0u);
auto filament_exist = ams.has("filament_exist") && ams.opt_bool("filament_exist", 0u);
auto filament_changed = !ams.has("filament_changed") || ams.opt_bool("filament_changed");
auto filament_multi_color = ams.opt<ConfigOptionStrings>("filament_multi_colour")->values;
auto ams_id = ams.opt_string("ams_id", 0u);
auto slot_id = ams.opt_string("slot_id", 0u);
auto is_placeholder = ams.has("filament_slot_placeholder") && ams.opt_bool("filament_slot_placeholder", 0u);
ams_infos.push_back({filament_id.empty() ? false : true, false, is_placeholder, filament_color});
// Real reported filament data: the device named a type or flagged the slot loaded. A
// topology-only synthetic virtual tray carries neither, so it must not become a filament.
bool has_reported_data = !is_placeholder && filament_id.empty() && (!filament_type.empty() || filament_exist);
AmsInfo ams_info;
ams_info.valid = !filament_id.empty();
ams_info.is_placeholder = is_placeholder;
ams_info.has_reported_data = has_reported_data;
ams_info.filament_color = filament_color;
// Only the append path of a data-bearing, id-less tray reads these; the known-id path keeps
// taking its color and color type from ams_filament_colors/ams_filament_color_types.
if (has_reported_data) {
if (ams_info.filament_color.empty())
ams_info.filament_color = "#CECECE";
ams_info.filament_color_type = filament_color_type.empty() ? "1" : filament_color_type;
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": entry %1% ams_id %2% slot_id %3% filament_id %4% type %5% color %6% valid %7% placeholder %8% reported_data %9% changed %10%")
% index % ams_id % slot_id % filament_id % filament_type % filament_color
% (!filament_id.empty()) % is_placeholder % has_reported_data % filament_changed;
ams_infos.push_back(std::move(ams_info));
AMSMapInfo temp = {ams_id, slot_id};
ams_array_maps.push_back(temp);
index++;
@@ -4607,20 +4680,55 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
maps.erase(j);
}
}
ams_filament_presets.push_back("Generic PLA");//for unknow matieral
auto default_unknown_color = "#CECECE";
ams_filament_colors.push_back(default_unknown_color);
ams_filament_color_types.push_back("1");
if (filament_multi_color.size() == 0) {
filament_multi_color.push_back(default_unknown_color);
}
ams_multi_color_filment.push_back(filament_multi_color);
} else if (is_placeholder) {
// Orca: push placeholders to keep index alignment with ams_infos
}
if (is_placeholder) {
// Orca: an empty AMS slot is a placeholder, not a filament - never synthesize a
// preset for it. Kept here only to keep index alignment with ams_infos; the sync
// drops it so no filament slot is created or retained for an empty tray.
ams_filament_presets.push_back("");
ams_filament_colors.push_back("");
ams_filament_color_types.push_back("");
ams_multi_color_filment.push_back({});
} else if (use_map) {
if (has_reported_data) {
// Orca: the tray reports a filament but no preset id, so it never reaches the
// filament_id lookup. Resolve the same generic preset the id path would pick
// from the reported type and keep the reported color; the append step below (if
// enabled) then turns it into a sidebar filament. Without a generic match the
// slot is dropped rather than invented, exactly like an empty tray.
std::string resolved_generic;
auto generic = find_generic_preset_for_type(filaments, filament_type, resolved_generic);
if (generic == filaments.end()) {
// Reported loaded but no usable type: fall back to the first compatible
// generic, matching the id path's fallback for an unknown model.
generic = std::find_if(filaments.begin(), filaments.end(), [](const Preset &f) {
return f.is_compatible && f.is_system && boost::algorithm::starts_with(f.name, "Generic ");
});
}
if (generic != filaments.end()) {
auto append_color = filament_color.empty() ? std::string("#CECECE") : filament_color;
ams_filament_presets.push_back(generic->name);
ams_filament_colors.push_back(append_color);
ams_filament_color_types.push_back(filament_color_type.empty() ? "1" : filament_color_type);
if (filament_multi_color.size() == 0)
filament_multi_color.push_back(append_color);
ams_multi_color_filment.push_back(filament_multi_color);
continue;
}
ams_filament_presets.push_back("");
ams_filament_colors.push_back("");
ams_filament_color_types.push_back("");
ams_multi_color_filment.push_back({});
} else {
ams_filament_presets.push_back("Generic PLA");//for unknow matieral
auto default_unknown_color = "#CECECE";
ams_filament_colors.push_back(default_unknown_color);
ams_filament_color_types.push_back("1");
if (filament_multi_color.size() == 0) {
filament_multi_color.push_back(default_unknown_color);
}
ams_multi_color_filment.push_back(filament_multi_color);
}
}
continue;
}
@@ -4632,49 +4740,19 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
continue;
}
bool has_type = false;
auto filament_type = ams.opt_string("filament_type", 0u);
auto iter = std::find_if(filaments.begin(), filaments.end(), [this, &filament_id, &has_type, filament_type](auto &f) {
has_type |= f.config.opt_string("filament_type", 0u) == filament_type;
return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; });
warn_ambiguous_filament_id_match(filaments, iter, filament_id);
if (iter == filaments.end()) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id;
if (!filament_type.empty()) {
auto original_type = filament_type;
std::string resolved_generic;
auto generic = find_generic_preset_for_type(filaments, filament_type, resolved_generic);
if (!filament_type.empty())
filament_type = "Generic " + filament_type;
iter = std::find_if(filaments.begin(), filaments.end(), [&filament_type](auto &f) {
return f.is_compatible && f.is_system
&& boost::algorithm::starts_with(f.name, filament_type);
});
if (iter == filaments.end()) {
// Similarity fallback: find a generic preset whose filament_type
// appears as a whole word in the AMS type (e.g. "ASA" in "ASA Sparkle").
auto upper_type = boost::to_upper_copy(original_type);
auto contains_word = [](const std::string& haystack, const std::string& needle) {
auto pos = haystack.find(needle);
while (pos != std::string::npos) {
bool start_ok = (pos == 0 || !std::isalnum(static_cast<unsigned char>(haystack[pos - 1])));
bool end_ok = (pos + needle.size() >= haystack.size() ||
!std::isalnum(static_cast<unsigned char>(haystack[pos + needle.size()])));
if (start_ok && end_ok)
return true;
pos = haystack.find(needle, pos + 1);
}
return false;
};
// Find the longest-matching preset type to prefer e.g. "PA-CF" over "PA".
size_t best_len = 0;
for (auto it = filaments.begin(); it != filaments.end(); ++it) {
if (!it->is_compatible || !it->is_system || !boost::algorithm::starts_with(it->name, "Generic "))
continue;
auto preset_type = boost::to_upper_copy(it->config.opt_string("filament_type", 0u));
if (preset_type.size() > best_len && contains_word(upper_type, preset_type)) {
iter = it;
best_len = preset_type.size();
filament_type = "Generic " + it->config.opt_string("filament_type", 0u);
}
}
}
if (generic != filaments.end()) {
iter = generic;
filament_type = resolved_generic;
}
if (iter == filaments.end()) {
// Prefer old selection
@@ -4834,6 +4912,8 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
}
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": color_only mapped_any %1% maps %2% entries %3%")
% mapped_any % maps.size() % ams_infos.size();
filament_color->values = exist_colors;
ams_multi_color_filment = exist_multi_color_filment;
merge_info.merges.clear();
@@ -4893,13 +4973,18 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
}
}
for (size_t i = 0; i < ams_infos.size(); i++) {// check append
if (ams_infos[i].valid) {
// A mapped tray (valid id) and a data-bearing tray without an id both append the same
// way; only a tray with neither contributes nothing.
if (ams_infos[i].valid || ams_infos[i].has_reported_data) {
if (i >= ams_filament_presets.size()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << "check error: array bound (check append)";
continue;
}
ams_infos[i].filament_preset = ams_filament_presets[i];
ams_infos[i].mutli_filament_color = ams_multi_color_filment[i];
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": entry %1% mapped %2% preset %3% color %4% valid %5% reported_data %6%")
% i % ams_infos[i].is_map % ams_infos[i].filament_preset % ams_infos[i].filament_color
% ams_infos[i].valid % ams_infos[i].has_reported_data;
if (!ams_infos[i].is_map) {
need_append_colors.emplace_back(ams_infos[i]);
ams_filament_colors[i] = "";
@@ -4909,6 +4994,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
}
}
else {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": entry %1% dropped (no valid id or reported data)") % i;
ams_filament_colors[i] = "";
ams_filament_color_types[i] = "";
ams_filament_presets[i] = "";
@@ -4926,7 +5012,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
[](std::vector<std::string> &value) { return value.empty(); }),
ams_multi_color_filment.end());
if (need_append_colors.size() > 0 && enable_append) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "need_append_colors.size() > 0 && enable_append";
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": append candidates %1% (start existing %2%)") % need_append_colors.size() % exist_filament_presets.size();
auto get_idx_in_array = [](std::vector<std::string> &presets, std::vector<std::string> &colors, const std::string &preset, const std::string &color) -> int {
for (size_t i = 0; i < presets.size(); i++) {
if (presets[i] == preset && colors[i] == color) {
@@ -4937,17 +5023,22 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
};
for (size_t i = 0; i < need_append_colors.size(); i++){
if (exist_filament_presets.size() >= MAXIMUM_AMS_SYNC_FILAMENT_NUMBER){
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": append capped at %1%, %2% candidate(s) left") % MAXIMUM_AMS_SYNC_FILAMENT_NUMBER % (need_append_colors.size() - i);
break;
}
auto idx = get_idx_in_array(exist_filament_presets, exist_colors, need_append_colors[i].filament_preset, need_append_colors[i].filament_color);
if (idx >= 0) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": append dedup preset %1% color %2% already at %3%") % need_append_colors[i].filament_preset % need_append_colors[i].filament_color % idx;
continue;
}
exist_filament_presets.push_back(need_append_colors[i].filament_preset);
exist_colors.push_back(need_append_colors[i].filament_color);
exist_color_types.push_back(need_append_colors[i].filament_color_type);
exist_multi_color_filment.push_back(need_append_colors[i].mutli_filament_color);
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": appended preset %1% color %2% type %3% -> slot %4%") % need_append_colors[i].filament_preset % need_append_colors[i].filament_color % need_append_colors[i].filament_color_type % (exist_filament_presets.size() - 1);
}
} else {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": append skipped candidates %1% enable_append %2%") % need_append_colors.size() % enable_append;
}
filament_color->values = exist_colors;
filament_color_type->values = exist_color_types;
@@ -4984,6 +5075,10 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
result_multi_colors.push_back(
i < ams_multi_color_filment.size() ? ams_multi_color_filment[i]
: std::vector<std::string>{ams_filament_colors[i]});
} else if (i < ams_infos.size() && ams_infos[i].is_placeholder) {
// Orca: an empty AMS slot is a placeholder, not a filament, so drop it instead of
// keeping or filling a slot for it. The device AMS panel still shows the empty tray.
continue;
} else if (i < exist_presets.size()) {
// Empty tray or beyond tray count: keep existing filament
result_colors.push_back(exist_colors[i]);
@@ -5008,8 +5103,8 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
filament_color_type->values = result_color_types;
this->filament_presets = result_presets;
ams_multi_color_filment = result_multi_colors;
filament_map->values.resize(total, 1);
filament_volume_map->values.resize(total, static_cast<int>(NozzleVolumeType::nvtStandard));
filament_map->values.resize(result_presets.size(), 1);
filament_volume_map->values.resize(result_presets.size(), static_cast<int>(NozzleVolumeType::nvtStandard));
} else {
// BBL: existing wholesale replace
filament_color->values = ams_filament_colors;
@@ -5059,7 +5154,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
// Update ams_multi_color_filment
update_filament_multi_color();
update_multi_material_filament_presets();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "finish sync ams list";
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": finish sync ams list, end_presets %1% end_colors %2%") % this->filament_presets.size() % filament_color->values.size();
return this->filament_presets.size();
}
@@ -5138,6 +5233,20 @@ std::vector<std::vector<DynamicPrintConfig>> PresetBundle::get_extruder_filament
if (extruder_nums > 1) {
filament_infos.resize(extruder_nums, std::vector<DynamicPrintConfig>());
for (auto ams_item : filament_ams_list) {
// A generic slot carries the extruder it actually belongs to, so it lands in that
// extruder's bucket instead of the legacy left/right pair. Bambu entries (and anything
// without the field) keep the existing 0x10000 key convention.
const bool has_extruder_id = ams_item.second.has("device_tray_extruder_id");
const int entry_extruder = has_extruder_id ? ams_item.second.opt_int("device_tray_extruder_id") : -1;
if (has_extruder_id) {
if (entry_extruder >= 0 && entry_extruder < extruder_nums) {
// Drop the transient association before the config reaches the slicing pipeline.
DynamicPrintConfig entry = ams_item.second;
entry.erase("device_tray_extruder_id");
filament_infos[entry_extruder].push_back(std::move(entry));
}
continue;
}
if (ams_item.first & 0x10000) { // right
filament_infos[1].push_back(ams_item.second);
} else { // left
+11 -4
View File
@@ -119,12 +119,18 @@ const std::vector<std::string> filament_extruder_override_keys = {
};
// Some filament override parameters are generated from filament_extruder_override_keys,
// while filament_retract_length_nc is defined separately. Keep the generator list
// unchanged and use this helper for behavior checks that need the full override set.
// while filament_retract_length_nc and the ironing overrides are defined separately.
// Keep the generator list unchanged and use this helper for behavior checks that need
// the full override set. A nil in one of these keys means the override is disabled, so
// child presets store and load it verbatim instead of treating nil as "inherit".
bool is_filament_extruder_override_key(const std::string &opt_key)
{
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
opt_key == "filament_retract_length_nc";
opt_key == "filament_retract_length_nc" ||
opt_key == "filament_ironing_flow" ||
opt_key == "filament_ironing_spacing" ||
opt_key == "filament_ironing_inset" ||
opt_key == "filament_ironing_speed";
}
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
@@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
opt_src->set(opt_target);
}
else
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride);
// An override's nil means "disabled" and is the child's value; every other nil column inherits.
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt));
}
}
}
+5 -2
View File
@@ -163,8 +163,8 @@ public:
~devPrinterUtil() = delete;
public:
static bool IsVirtualSlot(int ams_id) { return (ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID);}
static bool IsVirtualSlot(const std::string& ams_id) { return (ams_id == VIRTUAL_AMS_MAIN_ID_STR || ams_id == VIRTUAL_AMS_DEPUTY_ID_STR); }
static bool IsVirtualSlot(int ams_id);
static bool IsVirtualSlot(const std::string& ams_id);
};
namespace GUI
@@ -198,3 +198,6 @@ template<> struct std::hash<NozzleDef>
return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2));
};
};
// key(extruder_id) -> { key1(nozzle type info), val1( number of the nozzle type)}
using ExtruderNozzleInfos = std::unordered_map<int, std::unordered_map<NozzleDef, int>>;
+160 -23
View File
@@ -8,6 +8,7 @@
#include <cstdlib>
#include <chrono>
#include <nlohmann/json.hpp>
#include <wx/app.h>
#include <wx/colour.h>
#include <string>
#include <wx/string.h>
@@ -33,6 +34,36 @@
using namespace nlohmann;
namespace Slic3r {
bool devPrinterUtil::IsVirtualSlot(int ams_id)
{
// When the app or preset bundle is unavailable we cannot know the vendor's
// extruder count, so fall back deterministically to the fixed Bambu pair
// {255, 254} rather than guessing from an unknown generic layout.
if (wxTheApp == nullptr || GUI::wxGetApp().preset_bundle == nullptr || GUI::wxGetApp().preset_bundle->is_bbl_vendor())
return ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID;
const int extruder_count = GUI::wxGetApp().preset_bundle->get_printer_extruder_count();
return ams_id >= VIRTUAL_TRAY_MAIN_ID - extruder_count + 1 && ams_id <= VIRTUAL_TRAY_MAIN_ID;
}
bool devPrinterUtil::IsVirtualSlot(const std::string& ams_id)
{
// Strict: only a fully numeric string is a valid slot id. std::stoi alone
// would accept trailing garbage (e.g. "255abc" -> 255).
if (ams_id.empty())
return false;
for (char c : ams_id) {
if (c < '0' || c > '9')
return false;
}
try {
return IsVirtualSlot(std::stoi(ams_id));
} catch (...) {
return false;
}
}
static int _hex_digit_to_int(const char c) { return (c >= '0' && c <= '9') ? c - '0' : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1; }
wxColour DevAmsTray::decode_color(const std::string &color)
@@ -200,18 +231,22 @@ wxString DevAms::GetDisplayName() const
int DevAms::GetSlotCount() const
{
// GetAmsType() maps AMS_LITE_MIXED -> AMS_LITE, so N9 reports 4 slots like AMS-Lite.
auto ams_type = GetAmsType();
if (ams_type == AMS || ams_type == AMS_LITE || ams_type == N3F)
{
return 4;
}
else if (ams_type == N3S)
{
// N3S is a 1-slot Bambu contract, so report 1 regardless of whether the
// preset bundle is momentarily unavailable (m_trays.size() can be 0).
if (GetAmsType() == N3S)
return 1;
if (wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr &&
GUI::wxGetApp().preset_bundle->is_bbl_vendor()) {
// GetAmsType() maps AMS_LITE_MIXED -> AMS_LITE, so N9 reports 4 slots like AMS-Lite.
auto ams_type = GetAmsType();
if (ams_type == AMS || ams_type == AMS_LITE || ams_type == N3F)
{
return 4;
}
}
return 1;
return static_cast<int>(m_trays.size());
}
DevAmsTray* DevAms::GetTray(const std::string& tray_id) const
@@ -302,13 +337,21 @@ int DevFilaSystem::GetExtruderIdByAmsId(const std::string& ams_id) const
{
return it->second->GetExtruderId();
}
else if (stoi(ams_id) == VIRTUAL_TRAY_MAIN_ID)
{
return MAIN_EXTRUDER_ID;
const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr && GUI::wxGetApp().preset_bundle->is_bbl_vendor();
if (!is_bbl_vendor && GetOwner()) {
for (const auto& tray : GetOwner()->vt_slot) {
if (tray.id == ams_id)
return VIRTUAL_TRAY_MAIN_ID - std::stoi(ams_id);
}
}
else if (stoi(ams_id) == VIRTUAL_TRAY_DEPUTY_ID)
{
if (is_bbl_vendor && ams_id == VIRTUAL_AMS_MAIN_ID_STR)
return MAIN_EXTRUDER_ID;
if (is_bbl_vendor && ams_id == VIRTUAL_AMS_DEPUTY_ID_STR)
return DEPUTY_EXTRUDER_ID;
if (!is_bbl_vendor && devPrinterUtil::IsVirtualSlot(ams_id)) {
return VIRTUAL_TRAY_MAIN_ID - std::stoi(ams_id);
}
assert(false && __FUNCTION__);
@@ -325,9 +368,22 @@ std::string DevFilaSystem::GetNozzleFlowStringByAmsId(const std::string& ams_id)
std::map<int, DevAmsSlotId> DevFilaSystem::GetTrayIndexMap()
{
std::map<int, DevAmsSlotId> tray_id_map;
tray_id_map[VIRTUAL_TRAY_MAIN_ID] = DevAmsSlotId{VIRTUAL_TRAY_MAIN_ID, 0};
tray_id_map[VIRTUAL_TRAY_DEPUTY_ID] = DevAmsSlotId{VIRTUAL_TRAY_DEPUTY_ID, 0};
const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr && GUI::wxGetApp().preset_bundle->is_bbl_vendor();
if (is_bbl_vendor) {
tray_id_map[VIRTUAL_TRAY_MAIN_ID] = DevAmsSlotId{VIRTUAL_TRAY_MAIN_ID, 0};
tray_id_map[VIRTUAL_TRAY_DEPUTY_ID] = DevAmsSlotId{VIRTUAL_TRAY_DEPUTY_ID, 0};
} else if (GetOwner()) {
for (const auto& tray : GetOwner()->vt_slot) {
try {
const int tray_id = std::stoi(tray.id);
tray_id_map[tray_id] = DevAmsSlotId{tray_id, 0};
} catch (...) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " invalid virtual tray id: " << tray.id;
}
}
}
int generic_tray_index = 0;
for (auto& [ams_id, ams_item] : GetAmsList()) {
for (auto &[slot_id, slot_item] : ams_item->GetTrays()) {
if (ams_item && slot_item) {
@@ -335,9 +391,11 @@ std::map<int, DevAmsSlotId> DevFilaSystem::GetTrayIndexMap()
int ams_id_int = stoi(ams_id);
int slot_id_int = stoi(slot_id);
int tray_index = -1;
if (ams_item->GetAmsType() == DevAms::N3S) {
if (!is_bbl_vendor) {
tray_index = generic_tray_index + slot_id_int;
} else if (ams_item->GetAmsType() == DevAms::N3S) {
tray_index = ams_id_int;
} else if(ams_item->GetAmsType() == DevAms::AMS_LITE && ams_item->IsAmsLiteMixed()) {
} else if (ams_item->GetAmsType() == DevAms::AMS_LITE && ams_item->IsAmsLiteMixed()) {
tray_index = 24 + slot_id_int;
} else {
tray_index = (ams_id_int * 4 + slot_id_int);
@@ -348,6 +406,8 @@ std::map<int, DevAmsSlotId> DevFilaSystem::GetTrayIndexMap()
}
}
}
if (!is_bbl_vendor)
generic_tray_index += static_cast<int>(ams_item->GetTrays().size());
}
return tray_id_map;
@@ -456,6 +516,13 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
ams_id_set.insert(it->first);
}
// Bambu trays index their exist bit as ams_id*4+tray_id; a generic (non-Bambu) unit
// reports its own lane count, so its trays carry a cumulative lane index instead.
// Same index DevFilaSystem::GetTrayIndexMap() builds and the agent sets bits with.
const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr &&
GUI::wxGetApp().preset_bundle->is_bbl_vendor();
int generic_tray_base = 0;
for (auto it = j_ams.begin(); it != j_ams.end(); it++)
{
if (!it->contains("id")) continue;
@@ -795,7 +862,18 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
if (type_id < 4)
{
curr_tray->is_exists = (obj->tray_exist_bits & (1 << (ams_id_int * 4 + tray_id_int))) != 0 ? true : false;
if (is_bbl_vendor)
{
curr_tray->is_exists = (obj->tray_exist_bits & (1 << (ams_id_int * 4 + tray_id_int))) != 0 ? true : false;
}
else
{
// A generic unit is not four slots wide, so its trays carry a
// cumulative lane index across units instead of ams_id*4+tray_id.
const int tray_bit = generic_tray_base + tray_id_int;
curr_tray->is_exists = tray_bit >= 0 && tray_bit < 32 &&
(obj->tray_exist_bits & (1UL << tray_bit)) != 0;
}
}
else if (type_id == DevAms::AMS_LITE_MIXED)
{
@@ -851,6 +929,9 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
}
}
}
if (!is_bbl_vendor && it->contains("tray") && (*it)["tray"].is_array()) {
generic_tray_base += static_cast<int>((*it)["tray"].size());
}
}
// remove not in amsList
for (auto it = ams_id_set.begin(); it != ams_id_set.end(); it++)
@@ -866,6 +947,21 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
}
}
}
// Give every live device extruder an external virtual tray slot (non-Bambu) so a plugin agent
// that reaches the model only through this parser also gets the shared external-slot topology.
if (obj)
obj->ensure_external_virtual_tray_slots();
// Diagnostic: summarize the AMS state this production parse produced, so the agent boundary
// (submit_ams_payload) box/tray counts can be correlated with what actually landed in the model.
if (obj && system) {
size_t parsed_trays = 0;
for (const auto& kv : system->amsList)
parsed_trays += kv.second->GetTrays().size();
BOOST_LOG_TRIVIAL(info) << "DevFilaSystemParser::ParseV1_0: parsed units " << system->amsList.size()
<< " trays " << parsed_trays;
}
}
static DevAms::AmsType ams_type_from_string(const std::string& s)
@@ -876,6 +972,23 @@ static DevAms::AmsType ams_type_from_string(const std::string& s)
return DevAms::AMS; // default
}
void grow_virtual_tray_slots(std::vector<DevAmsTray>& slots, std::size_t tray_id)
{
// Address a slot by its virtual tray id, never by a raw vector index: the slot for `tray_id`
// lives at index VIRTUAL_TRAY_MAIN_ID - tray_id and placeholder entries carry
// id = VIRTUAL_TRAY_MAIN_ID - index. Append-only, so a slot that already holds reported
// metadata keeps it and a sparse/out-of-order report cannot shift the id <-> extruder mapping.
if (tray_id > static_cast<std::size_t>(VIRTUAL_TRAY_MAIN_ID))
return;
const std::size_t slot_index = static_cast<std::size_t>(VIRTUAL_TRAY_MAIN_ID) - tray_id;
while (slots.size() <= slot_index) {
slots.emplace_back(std::to_string(VIRTUAL_TRAY_MAIN_ID - static_cast<int>(slots.size())));
// A filler only makes the slot addressable; it is topology, not a reported spool. Callers
// overwrite the target entry with reported metadata, which clears this marker.
slots.back().is_slot_placeholder = true;
}
}
void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* obj, DevFilaSystem* system)
{
if (!system || !data.is_object())
@@ -913,6 +1026,8 @@ void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* ob
}
ams->m_exist = true;
ams->m_binded_extruder_set = {ext_id};
ams->m_binded_switcher_pos.reset();
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);
@@ -995,16 +1110,21 @@ void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* ob
}
// --- external / direct spools -> obj->vt_slot ---
// extruder 0 -> main virtual slot, extruder >0 -> deputy.
// Keep one virtual slot per extruder. The legacy two-extruder mapping uses
// 255 for extruder 0 and 254 for extruder 1; continue that sequence for
// additional extruders (253, 252, ...).
if (obj && data.contains("external") && data["external"].is_array())
{
obj->vt_slot.clear();
std::map<int, DevAmsTray> external_slots;
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;
if (ext < MAIN_EXTRUDER_ID || ext >= VIRTUAL_TRAY_MAIN_ID)
continue;
const int vt_id = VIRTUAL_TRAY_MAIN_ID - ext;
DevAmsTray tray(std::to_string(vt_id));
tray.is_exists = e.value("loaded", false);
tray.m_fila_type = e.value("material", std::string());
@@ -1013,9 +1133,26 @@ void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* ob
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);
external_slots.insert_or_assign(ext, std::move(tray));
}
// Place each reported spool at its own index (VIRTUAL_TRAY_MAIN_ID - extruder_id) instead of
// appending in report order: a sparse or out-of-order report must not compact the vector and
// shift the slot -> extruder association.
obj->vt_slot.clear();
for (const auto& entry : external_slots)
{
// entry.first is the extruder id; its virtual tray id is VIRTUAL_TRAY_MAIN_ID - extruder_id
// and the slot lives at vector index extruder_id (the id <-> index mapping grow applies).
grow_virtual_tray_slots(obj->vt_slot, static_cast<std::size_t>(VIRTUAL_TRAY_MAIN_ID - entry.first));
obj->vt_slot[entry.first] = entry.second;
}
}
// Fill any extruder still missing a slot so the model always has one external slot per live
// extruder, even when the agent reported no external data at all. Preserves reported metadata.
if (obj)
obj->ensure_external_virtual_tray_slots();
}
}
+14 -3
View File
@@ -7,6 +7,7 @@
#include "DevFilaSwitch.h"
#include "DevUtil.h"
#include <cstddef>
#include <map>
#include <set>
#include <string>
@@ -82,7 +83,7 @@ public:
wxColour wx_color;
bool is_bbl;
bool is_exists = false;
bool is_slot_placeholder = false; // Orca: True for empty tray slots from pull-mode agents
bool is_slot_placeholder = false; // Orca: True for synthetic topology slots or empty reported trays
int hold_count = 0;
int remain = 0; // filament remain: 0 ~ 100
@@ -114,6 +115,16 @@ public:
std::optional<DevFilamentDryingPreset> get_ams_drying_preset() const;
};
/**
* Ensure the virtual-tray vector can address the slot for a virtual tray id. The slot's index is
* `VIRTUAL_TRAY_MAIN_ID - tray_id` and placeholder entries carry id = VIRTUAL_TRAY_MAIN_ID - index.
* Existing entries are left untouched, so a slot already populated with reported filament data
* keeps it; callers then assign the reported tray at index `VIRTUAL_TRAY_MAIN_ID - tray_id`.
* Appended placeholders are marked `is_slot_placeholder`, so consumers can tell a synthetic
* topology filler from a spool the printer actually reported (overwriting the entry clears it).
*/
void grow_virtual_tray_slots(std::vector<DevAmsTray>& slots, std::size_t tray_id);
/**
* DevAms - Represents a single AMS (Automatic Material System) unit.
*
@@ -348,7 +359,7 @@ public:
DevAmsTray* GetAmsTray(const std::string& ams_id, const std::string& tray_id) const;
void CollectAmsColors(std::vector<wxColour>& ams_colors) const;
// Map a linear tray index -> {ams_id, slot_id}. Includes the two virtual (external-spool) trays,
// Map a linear tray index -> {ams_id, slot_id}. Includes virtual external-spool trays,
// N3S single-slot units, and the A2L/N9 AMS-Lite-mixed layout (trays 24-27).
std::map<int, DevAmsSlotId> GetTrayIndexMap();
@@ -433,4 +444,4 @@ struct DevFilamentDryingPreset
float filament_dev_ams_drying_heat_distortion_temperature = 0.0f;
};
}// namespace Slic3r
}// namespace Slic3r
+167 -17
View File
@@ -9,6 +9,7 @@
#include <vector>
#include <string>
#include <utility>
#include <wx/app.h>
#include <wx/colour.h>
#include <set>
#include "slic3r/GUI/DeviceCore/DevDefs.h"
@@ -18,14 +19,80 @@
// TODO: remove this include
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/GuiColor.hpp"
namespace Slic3r
{
// Bambu keeps its fixed external pair (255 main/right, 254 deputy/left) and its two
// left/right flags. Every other printer addresses one external virtual tray per live
// extruder, so ids 255, 254, 253, ... are extruders 0, 1, 2, ... .
static bool _is_bbl_vendor_printer()
{
return wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr &&
GUI::wxGetApp().preset_bundle->is_bbl_vendor();
}
// Extruder a virtual (external spool) tray id feeds: VIRTUAL_TRAY_MAIN_ID - tray_id.
// Returns -1 for an empty, non-numeric or out-of-range id.
static int _virtual_tray_extruder_id(const std::string& tray_id)
{
if (tray_id.empty())
return -1;
for (char c : tray_id)
{
if (c < '0' || c > '9')
return -1;
}
try
{
const int vt_id = std::stoi(tray_id);
if (vt_id < 0 || vt_id > VIRTUAL_TRAY_MAIN_ID)
return -1;
return VIRTUAL_TRAY_MAIN_ID - vt_id;
}
catch (...)
{
return -1;
}
}
// True when obj->vt_slot actually carries this virtual tray id as a reported spool. A synthetic
// topology filler (is_slot_placeholder) only makes the slot addressable and does not count.
static bool _has_virtual_tray_slot(const MachineObject* obj, const std::string& tray_id)
{
if (obj == nullptr || tray_id.empty())
return false;
for (const auto& tray : obj->vt_slot)
{
if (!tray.is_slot_placeholder && tray.id == tray_id)
return true;
}
return false;
}
// A generic printer may expose one AMS unit / external slot per extruder. When both legacy
// flags of a category are enabled (the sidebar sync call uses both AMS and both external flags)
// every extruder participates; when only one side is enabled (SelectMachine's left/right split)
// keep to that side's legacy extruder (right = main = 0, left = deputy = 1), so the N slots do
// not leak across sides.
static bool _generic_extruder_in_selected_side(int extruder_id, bool use_right, bool use_left)
{
if (use_right && use_left)
return true;
if (use_right)
return extruder_id == MAIN_EXTRUDER_ID;
if (use_left)
return extruder_id == DEPUTY_EXTRUDER_ID;
return false;
}
bool DevMappingUtil::is_valid_mapping_result(const MachineObject* obj, std::vector<FilamentInfo>& result, bool check_empty_slot)
{
if (result.empty()) return false;
const bool is_bbl_vendor = _is_bbl_vendor_printer();
for (int i = 0; i < result.size(); i++)
{
// invalid mapping result
@@ -42,8 +109,13 @@ namespace Slic3r
auto ams_item = obj->GetFilaSystem()->GetAmsById(result[i].ams_id);
if (ams_item == nullptr)
{
if ((result[i].ams_id != std::to_string(VIRTUAL_TRAY_MAIN_ID)) &&
(result[i].ams_id != std::to_string(VIRTUAL_TRAY_DEPUTY_ID)))
// A virtual (external spool) tray is not an AMS unit. Bambu keeps its fixed pair;
// a generic printer accepts whichever per-extruder slots it actually reported.
const bool is_virtual_tray = is_bbl_vendor
? (result[i].ams_id == std::to_string(VIRTUAL_TRAY_MAIN_ID) ||
result[i].ams_id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID))
: _has_virtual_tray_slot(obj, result[i].ams_id);
if (!is_virtual_tray)
{
result[i].tray_id = -1;
return false;
@@ -94,8 +166,11 @@ namespace Slic3r
result.ams_id = std::to_string(ams_id);
result.slot_id = std::to_string(slot_id);
if (ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID)
if (type == DevAms::EXT_SPOOL || ams_id == VIRTUAL_TRAY_MAIN_ID || ams_id == VIRTUAL_TRAY_DEPUTY_ID)
{
// An external spool is addressed by its virtual tray id (255 -> extruder 0, 254 -> 1,
// ...); the id is the tray's identity, so it must not fall through to the AMS
// ams_id * 4 + slot_id indexing.
result.tray_id = atoi(tray.id.c_str());
result.id = atoi(tray.id.c_str());
}
@@ -126,11 +201,25 @@ namespace Slic3r
std::map<int, FilamentInfo> tray_filaments; // tray_index : tray_color
bool left_nozzle_has_ams = false, right_nozzle_has_ams = false;
const bool is_bbl_vendor = _is_bbl_vendor_printer();
// Extruders whose AMS filament participates in this mapping. On a generic printer this
// carries the per-extruder "nozzle already has AMS" state that the legacy right/left flags
// express for Bambu, so the matching external spool can be suppressed.
std::set<int> generic_extruders_with_ams;
const auto& ams_list = obj->GetFilaSystem()->GetAmsList();
for (auto ams = ams_list.begin(); ams != ams_list.end(); ams++)
{
std::string ams_id = ams->second->GetAmsId();
auto ams_type = ams->second->GetAmsType();
const int ams_id_int = atoi(ams->first.c_str());
int generic_tray_base = 0;
if (!is_bbl_vendor) {
for (const auto& other : ams_list) {
if (atoi(other.first.c_str()) < ams_id_int)
generic_tray_base += static_cast<int>(other.second->GetTrays().size());
}
}
// GetAmsType() maps mixed -> AMS_LITE; recover the mixed type so N9 trays index at 24+slot.
if (ams_type == DevAms::AMS_LITE && ams->second->IsAmsLiteMixed())
{
@@ -138,10 +227,16 @@ namespace Slic3r
}
for (auto tray = ams->second->GetTrays().begin(); tray != ams->second->GetTrays().end(); tray++)
{
int ams_id = atoi(ams->first.c_str());
int ams_id = ams_id_int;
int tray_id = atoi(tray->first.c_str());
int tray_index = 0;
if (ams_type == DevAms::AMS || ams_type == DevAms::AMS_LITE || ams_type == DevAms::N3F)
if (!is_bbl_vendor)
{
// Generic units can have different lane counts; index all lanes cumulatively,
// matching the shared tray-index map and sidebar list builder.
tray_index = generic_tray_base + tray_id;
}
else if (ams_type == DevAms::AMS || ams_type == DevAms::AMS_LITE || ams_type == DevAms::N3F)
{
tray_index = ams_id * 4 + tray_id;
}
@@ -169,14 +264,18 @@ namespace Slic3r
if (tray->second->is_tray_info_ready())
{
_parse_tray_info(ams_id, tray_id, ams_type, *(tray->second), info);
// Generic unit lanes use one cumulative index across variable-width units;
// ams_id * 4 + slot_id is only valid for Bambu's fixed four-slot units.
if (!is_bbl_vendor)
info.id = tray_index;
}
//first: left,nozzle=1,map=1 second: right,nozzle=0,map=2
bool right_ams_valid = (ams->second->GetBindedExtruderSet().count(MAIN_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_RIGHT_AMS];
bool left_ams_valid = (ams->second->GetBindedExtruderSet().count(DEPUTY_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_LEFT_AMS];
if (right_ams_valid || left_ams_valid)
bool ams_valid = false;
if (is_bbl_vendor)
{
tray_filaments.emplace(std::make_pair(tray_index, info));
//first: left,nozzle=1,map=1 second: right,nozzle=0,map=2
bool right_ams_valid = (ams->second->GetBindedExtruderSet().count(MAIN_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_RIGHT_AMS];
bool left_ams_valid = (ams->second->GetBindedExtruderSet().count(DEPUTY_EXTRUDER_ID) != 0) && map_opt[MappingOption::USE_LEFT_AMS];
if (right_ams_valid)
{
right_nozzle_has_ams = true;
@@ -185,6 +284,25 @@ namespace Slic3r
{
left_nozzle_has_ams = true;
}
ams_valid = right_ams_valid || left_ams_valid;
}
else
{
// A generic unit is bound to its own extruder, so it takes part whenever the
// request covers that extruder.
for (int extruder_id : ams->second->GetBindedExtruderSet())
{
if (_generic_extruder_in_selected_side(extruder_id, map_opt[MappingOption::USE_RIGHT_AMS], map_opt[MappingOption::USE_LEFT_AMS]))
{
ams_valid = true;
generic_extruders_with_ams.insert(extruder_id);
}
}
}
if (ams_valid)
{
tray_filaments.emplace(std::make_pair(tray_index, info));
}
}
}
@@ -193,10 +311,21 @@ namespace Slic3r
{
for (auto tray : obj->vt_slot)
{
bool right_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID) && map_opt[MappingOption::USE_RIGHT_EXT]);
bool left_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID) && map_opt[MappingOption::USE_LEFT_EXT]);
if (right_ext_valid || left_ext_valid)
// A synthetic topology filler is not a reported external spool, so it is never a
// mapping candidate. Bambu keeps its fixed reported pair.
if (!is_bbl_vendor && tray.is_slot_placeholder)
{
continue;
}
if (is_bbl_vendor)
{
// Bambu keeps its fixed pair and the two left/right external flags.
const bool right_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID) && map_opt[MappingOption::USE_RIGHT_EXT]);
const bool left_ext_valid = (tray.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID) && map_opt[MappingOption::USE_LEFT_EXT]);
if (!right_ext_valid && !left_ext_valid)
{
continue;
}
if (nozzle_has_ams_then_ignore_ext)
{
if (right_ext_valid && right_nozzle_has_ams)
@@ -208,10 +337,31 @@ namespace Slic3r
continue;
}
}
FilamentInfo info;
_parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::EXT_SPOOL, tray, info);
tray_filaments.emplace(std::make_pair(info.tray_id, info));
}
else
{
// Generic: every present virtual tray is the external spool of the extruder it
// addresses (VIRTUAL_TRAY_MAIN_ID - tray.id): 255 -> 0, 254 -> 1, ... . Keep to
// the selected side when only one legacy flag is enabled, and drop the spool when
// its extruder already has AMS filament in this mapping and suppression is on.
const int extruder_id = _virtual_tray_extruder_id(tray.id);
if (extruder_id < 0)
{
continue;
}
if (!_generic_extruder_in_selected_side(extruder_id, map_opt[MappingOption::USE_RIGHT_EXT], map_opt[MappingOption::USE_LEFT_EXT]))
{
continue;
}
if (nozzle_has_ams_then_ignore_ext && generic_extruders_with_ams.count(extruder_id) != 0)
{
continue;
}
}
FilamentInfo info;
_parse_tray_info(atoi(tray.id.c_str()), 0, DevAms::EXT_SPOOL, tray, info);
tray_filaments.emplace(std::make_pair(info.tray_id, info));
}
}
@@ -412,4 +562,4 @@ namespace Slic3r
return 0;
}
}
}
+37 -17
View File
@@ -13,6 +13,7 @@
#include "slic3r/GUI/DeviceCore/DevDefs.h"
#include "libslic3r/PrintConfig.hpp"
#include <boost/log/trivial.hpp>
#include <wx/app.h>
#include <wx/string.h>
#include <string>
#include "libslic3r/CommonDefs.hpp"
@@ -27,6 +28,7 @@
#include <unordered_map>
#include <optional>
#include <cmath>
#include "slic3r/GUI/GUI_App.hpp"
using json = nlohmann::json;
@@ -176,34 +178,52 @@ DevFirmwareVersionInfo DevNozzle::GetFirmwareInfo() const
int DevNozzle::GetLogicExtruderId() const
{
int total_ext_count = GetTotalExtruderCount();
if (total_ext_count == 1) {
return LOGIC_UNIQUE_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtLeftExtruder()) {
return LOGIC_L_EXTRUDER_ID;
} else if (AtRightExtruder()) {
return LOGIC_R_EXTRUDER_ID;
const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr &&
GUI::wxGetApp().preset_bundle->is_bbl_vendor();
if (is_bbl_vendor) {
if (total_ext_count == 1) {
return LOGIC_UNIQUE_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtLeftExtruder()) {
return LOGIC_L_EXTRUDER_ID;
} else if (AtRightExtruder()) {
return LOGIC_R_EXTRUDER_ID;
}
}
assert(0);
return LOGIC_UNIQUE_EXTRUDER_ID;
}
assert(0);
return LOGIC_UNIQUE_EXTRUDER_ID;
// For some reason, BBL's logical extruder ID is inverted:
// physical extruder id = 0 (MAIN_EXTRUDER_ID) vs logical extruder id = 1 (LOGIC_R_EXTRUDER_ID)
// Likely because logical id reads from left to right (left = 0, right = 1)
// For generic N extruders, this inversion does not apply.
if (IsOnRack()) return INVALID_EXTRUDER_ID;
return m_nozzle_id;
}
int DevNozzle::GetExtruderId() const
{
int total_ext_count = GetTotalExtruderCount();
if (total_ext_count == 1) {
return MAIN_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtRightExtruder()) {
const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr &&
GUI::wxGetApp().preset_bundle->is_bbl_vendor();
if (is_bbl_vendor) {
int total_ext_count = GetTotalExtruderCount();
if (total_ext_count == 1) {
return MAIN_EXTRUDER_ID;
} else if (AtLeftExtruder()) {
return DEPUTY_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtRightExtruder()) {
return MAIN_EXTRUDER_ID;
} else if (AtLeftExtruder()) {
return DEPUTY_EXTRUDER_ID;
}
}
return MAIN_EXTRUDER_ID;
}
return MAIN_EXTRUDER_ID;
return m_nozzle_id;
}
bool DevNozzle::AtLeftExtruder() const
+99 -47
View File
@@ -65,6 +65,7 @@
#include <unordered_set>
#include <utility>
#include <vector>
#include <wx/app.h>
#include <wx/colour.h>
#include <tuple>
#include <wx/app.h>
@@ -806,8 +807,12 @@ bool MachineObject::is_extrusion_cali_finished()
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) {
return &vt_slot[0];
if (devPrinterUtil::IsVirtualSlot(cur_ams_id)) {
for (auto& tray : vt_slot) {
if (tray.id == cur_ams_id)
return &tray;
}
return nullptr;
}
DevAms* curr_ams = get_curr_Ams();
@@ -1273,19 +1278,24 @@ int MachineObject::get_bed_temperature_limit()
bool MachineObject::is_filament_installed()
{
if (m_extder_system->GetTotalExtderCount() > 0) {
// right//or single
auto ext = m_extder_system->m_extders[MAIN_EXTRUDER_ID];
if (ext.m_ext_has_filament) {
// if (m_extder_system->GetTotalExtderCount() > 0) {
// // right//or single
// auto ext = m_extder_system->m_extders[MAIN_EXTRUDER_ID];
// if (ext.m_ext_has_filament) {
// return true;
// }
// }
// /*left*/
// if (m_extder_system->GetTotalExtderCount() > 1) {
// auto ext = m_extder_system->m_extders[DEPUTY_EXTRUDER_ID];
// if (ext.m_ext_has_filament) {
// return true;
// }
// }
for (auto& ext : m_extder_system->m_extders) {
if (ext.m_ext_has_filament)
return true;
}
}
/*left*/
if (m_extder_system->GetTotalExtderCount() > 1) {
auto ext = m_extder_system->m_extders[DEPUTY_EXTRUDER_ID];
if (ext.m_ext_has_filament) {
return true;
}
}
return false;
}
@@ -2632,13 +2642,10 @@ 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);
}
}
for (auto& tray : vt_slot)
tray.reset();
if (vt_slot.size() > 1)
vt_slot.erase(vt_slot.begin() + 1, vt_slot.end());
// why: reset reuses MachineObject, so release its lazy subtask
// before dropping the pointer to prevent reconnect leaks.
if (subtask_) {
@@ -4119,25 +4126,25 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) {
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
try {
const auto tray_json = it.value().get<json>();
if (!tray_json.is_object() || !tray_json.contains("id") || !tray_json["id"].is_string())
continue;
if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) {
auto it = std::next(vt_slot.begin(), 0);
if (it != vt_slot.end()) {
vt_slot[0] = vslot;
}
else {
vt_slot.push_back(vslot);
}
const auto tray_id_str = tray_json["id"].get<std::string>();
int tray_id = 0;
const auto result = std::from_chars(tray_id_str.data(), tray_id_str.data() + tray_id_str.size(), tray_id);
if (result.ec != std::errc{} || result.ptr != tray_id_str.data() + tray_id_str.size()
|| tray_id < 0 || tray_id > VIRTUAL_TRAY_MAIN_ID)
continue;
auto vslot = parse_vt_tray(tray_json);
grow_virtual_tray_slots(vt_slot, static_cast<std::size_t>(tray_id));
const auto index = static_cast<std::vector<DevAmsTray>::size_type>(VIRTUAL_TRAY_MAIN_ID - tray_id);
vt_slot[index] = vslot;
}
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] = vslot;
}
else {
vt_slot.push_back(vslot);
}
catch (...) {
continue;
}
}
@@ -4168,6 +4175,13 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
try {
parse_new_info(jj);
} catch (...) {}
// Give every live device extruder an external virtual tray even when the payload
// carried no vir_slot/vt_tray data (non-Bambu), so the UI always has a slot per
// extruder. Runs after parse_new_info(), which refreshes the extruder count, and
// after the vir_slot/vt_tray handling above, which may have cleared the support
// flag and would otherwise hide these synthetic slots.
ensure_external_virtual_tray_slots();
#pragma endregion
} else if (jj["command"].get<std::string>() == "gcode_line") {
//ack of gcode_line
@@ -5273,18 +5287,50 @@ DevAmsTray MachineObject::parse_vt_tray(json vtray)
return vt_tray;
}
void MachineObject::ensure_external_virtual_tray_slots()
{
// Without a preset bundle the vendor layout is unknown, and Bambu owns its own fixed pair
// (255, 254) reported through vir_slot/vt_tray; in both cases leave vt_slot untouched.
if (wxTheApp == nullptr || GUI::wxGetApp().preset_bundle == nullptr ||
GUI::wxGetApp().preset_bundle->is_bbl_vendor()) {
return;
}
const int extruder_count = m_extder_system != nullptr ? m_extder_system->GetTotalExtderCount() : 0;
if (extruder_count <= 0) {
return;
}
// One slot per live extruder, addressed by VIRTUAL_TRAY_MAIN_ID - extruder_id. Grow with
// placeholders and only fill slots that do not exist yet, so a slot already populated from a
// reported tray keeps its metadata and the slot -> extruder mapping never shifts.
for (int extruder_id = 0; extruder_id < extruder_count; ++extruder_id) {
const int tray_id = VIRTUAL_TRAY_MAIN_ID - extruder_id;
grow_virtual_tray_slots(vt_slot, static_cast<std::vector<DevAmsTray>::size_type>(tray_id));
auto& tray = vt_slot[extruder_id];
// The constructor seeds id 255 without a placeholder flag. Mark any empty generic slot as
// synthetic, but leave a slot carrying reported filament data eligible for sync/mapping.
tray.is_slot_placeholder = !tray.is_exists && tray.setting_id.empty() && tray.m_fila_type.empty();
}
// A printer with an external-slot layout supports virtual trays even when the payload carried
// no vir_slot/vt_tray data, which would otherwise clear this flag and hide the synthetic slots.
ams_support_virtual_tray = true;
}
bool MachineObject::contains_tray(const std::string &ams_id, const std::string &tray_id) const
{
if (ams_id != VIRTUAL_AMS_MAIN_ID_STR && ams_id != VIRTUAL_AMS_DEPUTY_ID_STR) {
const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr && GUI::wxGetApp().preset_bundle->is_bbl_vendor();
if (is_bbl_vendor && ams_id != VIRTUAL_AMS_MAIN_ID_STR && ams_id != VIRTUAL_AMS_DEPUTY_ID_STR)
return m_fila_system->GetAmsTray(ams_id, tray_id) != nullptr;
} else {
if (!is_bbl_vendor || ams_id == VIRTUAL_AMS_MAIN_ID_STR || ams_id == VIRTUAL_AMS_DEPUTY_ID_STR) {
for (const auto& tray : vt_slot) {
if (tray.id == ams_id) { return true; }
if (tray.id == ams_id) return true;
}
}
return false;
return !is_bbl_vendor && m_fila_system->GetAmsTray(ams_id, tray_id) != nullptr;
}
DevAmsTray MachineObject::get_tray(const std::string &ams_id, const std::string &tray_id) const
@@ -5294,13 +5340,19 @@ DevAmsTray MachineObject::get_tray(const std::string &ams_id, const std::string
return DevAmsTray(tray_id);
}
if (ams_id != VIRTUAL_AMS_MAIN_ID_STR && ams_id != VIRTUAL_AMS_DEPUTY_ID_STR) {
const bool is_bbl_vendor = wxTheApp != nullptr && GUI::wxGetApp().preset_bundle != nullptr && GUI::wxGetApp().preset_bundle->is_bbl_vendor();
if (is_bbl_vendor && ams_id != VIRTUAL_AMS_MAIN_ID_STR && ams_id != VIRTUAL_AMS_DEPUTY_ID_STR) {
auto tray = m_fila_system->GetAmsTray(ams_id, tray_id);
if (tray) { return *tray;};
if (tray) return *tray;
}
else {
else if (!is_bbl_vendor || ams_id == VIRTUAL_AMS_MAIN_ID_STR || ams_id == VIRTUAL_AMS_DEPUTY_ID_STR) {
for (const auto &tray : vt_slot) {
if (tray.id == ams_id) { return tray; }
if (tray.id == ams_id) return tray;
}
if (!is_bbl_vendor) {
auto tray = m_fila_system->GetAmsTray(ams_id, tray_id);
if (tray) return *tray;
}
}
+10
View File
@@ -951,6 +951,16 @@ public:
std::vector<DevAmsTray> vt_slot;
DevAmsTray parse_vt_tray(nlohmann::json vtray);
/**
* Give every live device extruder an external virtual tray slot (non-Bambu printers only).
*
* Slots are addressed by VIRTUAL_TRAY_MAIN_ID - extruder_id, so a slot already reported for an
* extruder keeps its tray metadata and sparse/out-of-order reports never shift the slot ->
* extruder association. Only missing topology slots are added, so the call is idempotent across
* refreshes. Bambu keeps its existing fixed pair reported through vir_slot/vt_tray.
*/
void ensure_external_virtual_tray_slots();
/*get ams slot info*/
bool contains_tray(const std::string &ams_id, const std::string &tray_id) const;
DevAmsTray get_tray(const std::string &ams_id, const std::string &tray_id) const;/*use contains_tray() check first*/
+528 -99
View File
@@ -20,6 +20,7 @@
#include <clocale>
#include <cstddef>
#include <algorithm>
#include <cctype>
#include <ios>
#include <iomanip>
#include <cstring>
@@ -773,11 +774,13 @@ struct ExtruderGroup : StaticBox
Label * prefix = nullptr;
Label * diameter_label = nullptr; // read-only nozzle diameter of this extruder
ComboBox * flow = nullptr;
wxBoxSizer *row_sizer = nullptr; // the row's line, moved between the two columns as needed
};
ExtruderGroup(wxWindow * parent, int index, wxString const &title);
wxBoxSizer * sizer = nullptr;
HoverLabel * hover_label = nullptr;
wxPanel * ams_panel = nullptr;
wxStaticText* ams_label{nullptr};
ScalableButton * btn_edit = nullptr;
ComboBox * combo_diameter = nullptr;
@@ -786,6 +789,8 @@ struct ExtruderGroup : StaticBox
ScalableButton * btn_up{nullptr};
ScalableButton * btn_down{nullptr};
wxBoxSizer *hsizer_ams { nullptr };
wxBoxSizer *ams_section { nullptr }; // AMS panel + its spacing, shown independently of the nozzle rows
bool ams_visible{false};
size_t page_cur{0};
size_t page_num{3};
size_t ams_n4 = 0;
@@ -795,13 +800,30 @@ struct ExtruderGroup : StaticBox
wxString diameter;
std::vector<NozzleRow> rows; // rows[0] always exists, one row per extruder when multi
size_t nozzle_row_count = 0; // rows currently shown
size_t nozzle_row_count = 0; // rows currently configured
int visible_row = -1; // -1 shows every configured row, otherwise the only row shown
wxBoxSizer * rows_sizer = nullptr;
wxBoxSizer * row_columns[2] = {}; // row i is dealt into column i % 2
bool rows_single_column = false;
int group_index = -1; // ctor index, the extruder a single-row group edits
// Show `count` nozzle rows, one per extruder, each prefixed with T<index> when count > 1.
void SetNozzleRowCount(size_t count);
size_t NozzleRowCount() const { return nozzle_row_count; }
// Generic multi-tool cards list one tool at a time while keeping every configured row alive.
bool IsNozzleRowVisible(size_t row) const
{
return row < nozzle_row_count && (visible_row < 0 || row == size_t(visible_row));
}
void SetVisibleNozzleRow(int index);
// Re-apply row visibility, re-measure and lay the card out after the row selection changed.
void apply_nozzle_row_visibility();
// A single-tool card shows its one row across the whole width; a listing deals rows into columns.
void restack_nozzle_rows();
// The AMS section is hidden on the listing card used for a single nozzle or a plain multi-row list.
void SetAmsVisible(bool show);
// Restart paging when the card starts showing another tool.
void ResetAmsPage();
// Nozzle diameter readout of the shown rows, one value per extruder. A listing card cannot edit
// it: the diameter belongs to the machine variant the printer preset selects, not to the extruder.
void SetRowDiameters(const std::vector<double> &diameters);
@@ -817,7 +839,7 @@ struct ExtruderGroup : StaticBox
return;
ams_n4 = n4;
ams_n1 = n1;
if (btn_edit) {
if (btn_edit || ams_visible) {
update_ams();
}
}
@@ -828,7 +850,7 @@ struct ExtruderGroup : StaticBox
void SetEditEnabled(bool enable) { if (hover_label) hover_label->EnableEdit(enable); }
void SetOnHoverClick(std::function<void()> on_click) { if (hover_label) hover_label->SetOnHoverClick(std::move(on_click)); }
void sync_ams(MachineObject const *obj, std::vector<DevAms *> const &ams4, std::vector<DevAms *> const &ams1);
void sync_ams(MachineObject const *obj, std::vector<DevAms *> const &ams4, std::vector<DevAms *> const &ams1, bool live_count = false);
void Rescale()
{
@@ -906,8 +928,17 @@ struct Sidebar::priv
ExtruderGroup *right_extruder = nullptr;
ExtruderGroup *single_extruder = nullptr;
// Generic printers with more than two extruders show one tool at a time: a read-only selector
// above the card and the selected tool's nozzle row and AMS section inside it.
wxPanel * panel_extruder_tool = nullptr;
ComboBox * combo_extruder_tool = nullptr;
int selected_tool = 0;
bool generic_multi_tool_layout = false;
int FromDIP(int n) { return plater->FromDIP(n); }
void layout_printer(bool isBBL, bool isDual);
// Refresh the tool choices and keep `selected_tool` valid when the extruder count changes.
void update_tool_selector(int extruder_count, bool generic_multi);
void flush_printer_sync(bool restart = false);
@@ -1081,11 +1112,42 @@ void Sidebar::priv::layout_printer(bool isBBL, bool isDual)
});
}
// Tool selector for a generic printer with more than two extruders. The card below shows only
// the selected tool; every other layout keeps the selector hidden.
panel_extruder_tool = new wxPanel(m_panel_printer_content, wxID_ANY);
panel_extruder_tool->SetBackgroundColour(*wxWHITE);
combo_extruder_tool = new ComboBox(panel_extruder_tool, wxID_ANY, wxString(""), wxDefaultPosition, wxDefaultSize, 0, nullptr, wxCB_READONLY);
combo_extruder_tool->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent &) {
const int selection = combo_extruder_tool->GetSelection();
if (selection < 0 || !generic_multi_tool_layout)
return;
selected_tool = selection;
single_extruder->SetVisibleNozzleRow(selection);
single_extruder->SetTitle(wxString::Format(_L("Tool %d"), selected_tool + 1));
if (auto *plater = GUI::wxGetApp().plater())
plater->update_machine_sync_status();
single_extruder->ResetAmsPage();
if (m_panel_printer_content)
m_panel_printer_content->Layout();
});
use_nozzle_row_font(combo_extruder_tool);
auto *tool_label = new Label(panel_extruder_tool, _L("Tool"));
tool_label->SetFont(Label::Body_13);
auto *tool_row = new wxBoxSizer(wxHORIZONTAL);
tool_row->Add(tool_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5));
tool_row->Add(combo_extruder_tool, 1, wxALIGN_CENTER_VERTICAL);
auto *tool_panel_sizer = new wxBoxSizer(wxVERTICAL);
tool_panel_sizer->Add(tool_row, 0, wxEXPAND);
tool_panel_sizer->AddSpacer(FromDIP(4)); // gap above the card, only present while the selector is shown
panel_extruder_tool->SetSizer(tool_panel_sizer);
panel_extruder_tool->Hide();
// single
extruder_single_sizer = new wxBoxSizer(wxHORIZONTAL);
extruder_single_sizer->Add(single_extruder, 1, wxEXPAND, 0);
wxBoxSizer * extruder_sizer = new wxBoxSizer(wxVERTICAL);
extruder_sizer->Add(panel_extruder_tool, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(SidebarProps::ContentMargin()));
extruder_sizer->Add(extruder_dual_sizer , 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(SidebarProps::ContentMargin()));
extruder_sizer->Add(extruder_single_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(SidebarProps::ContentMargin()));
@@ -1115,19 +1177,37 @@ void Sidebar::priv::layout_printer(bool isBBL, bool isDual)
int extruder_count = 0;
const bool has_flow_variant = cfg.support_different_extruders(extruder_count);
// ORCA: a non-Bambu printer with several extruders lists one nozzle row per tool; Bambu's own
// multi-extruder printers keep the left/right pair above.
// ORCA: a non-Bambu printer with several extruders lists nozzle rows per tool; Bambu's own
// multi-extruder printers keep the left/right pair above. Beyond two tools the card shows one
// tool at a time, chosen with the selector, so the AMS section has room to stay visible.
const bool multi_extruder_rows = !preset_bundle.is_bbl_vendor() && extruder_count > 1;
const bool generic_multi_tool = multi_extruder_rows && extruder_count > 2;
update_tool_selector(extruder_count, generic_multi_tool);
// The variant box switches the machine variant. The card below lists the nozzles when there
// are flow variants to choose or several extruders to show; otherwise the box says it all.
panel_nozzle_dia->Show(true);
extruder_single_sizer->Show(has_flow_variant || multi_extruder_rows);
single_extruder->SetNozzleRowCount(multi_extruder_rows ? size_t(extruder_count) : 1);
if (generic_multi_tool) {
// Keep every tool row configured (see update_all_presets) but display only the selected
// one, together with that tool's AMS previews.
single_extruder->SetNozzleRowCount(size_t(extruder_count));
single_extruder->SetVisibleNozzleRow(selected_tool);
// The card may be carrying another printer's AMS until the device sync refills it.
single_extruder->sync_ams(nullptr, {}, {}, true);
single_extruder->SetAmsVisible(true);
} else {
single_extruder->SetNozzleRowCount(multi_extruder_rows ? size_t(extruder_count) : 1);
single_extruder->SetVisibleNozzleRow(-1);
single_extruder->SetAmsVisible(false);
}
} else {
update_tool_selector(0, false);
panel_nozzle_dia->Show(false);
extruder_single_sizer->Show(false);
single_extruder->SetNozzleRowCount(1);
single_extruder->SetVisibleNozzleRow(-1);
single_extruder->SetAmsVisible(false);
}
// ORCA ensure printer section is visible after changing printer from printer selection dialog
@@ -1141,6 +1221,44 @@ void Sidebar::priv::layout_printer(bool isBBL, bool isDual)
}
}
void Sidebar::priv::update_tool_selector(int extruder_count, bool generic_multi)
{
generic_multi_tool_layout = generic_multi;
if (!combo_extruder_tool)
return;
if (!generic_multi) {
selected_tool = 0;
single_extruder->SetTitle(_L("Nozzle"));
combo_extruder_tool->Hide();
if (panel_extruder_tool)
panel_extruder_tool->Hide();
if (m_panel_printer_content)
m_panel_printer_content->Layout();
return;
}
const int count = std::min(std::max(extruder_count, 1), int(MAXIMUM_EXTRUDER_NUMBER));
if (combo_extruder_tool->GetCount() != count) {
combo_extruder_tool->Clear();
for (int i = 0; i < count; ++i)
combo_extruder_tool->Append(wxString::Format("T%d", i + 1));
}
// A device refresh keeps the chosen tool; a preset change that removes it falls back to the last
// remaining tool rather than leaving a stale selection.
const int previous_tool = selected_tool;
selected_tool = std::clamp(selected_tool, 0, count - 1);
combo_extruder_tool->SetSelection(selected_tool);
single_extruder->SetTitle(wxString::Format(_L("Tool %d"), selected_tool + 1));
if (selected_tool != previous_tool)
single_extruder->ResetAmsPage();
if (panel_extruder_tool)
panel_extruder_tool->Show();
combo_extruder_tool->Show();
if (m_panel_printer_content)
m_panel_printer_content->Layout();
}
void Sidebar::priv::flush_printer_sync(bool restart)
{
if (restart) {
@@ -1546,7 +1664,7 @@ ExtruderGroup::ExtruderGroup(wxWindow * parent, int index, wxString const &title
use_nozzle_row_font(combo_diameter);
// AMS
auto ams_panel = new wxPanel(this, wxID_ANY);
ams_panel = new wxPanel(this, wxID_ANY);
ams_panel->SetBackgroundColour(*wxWHITE);
ams_label = new wxStaticText(ams_panel, wxID_ANY, _L("AMS"));
@@ -1631,18 +1749,19 @@ ExtruderGroup::ExtruderGroup(wxWindow * parent, int index, wxString const &title
wxBoxSizer *vsizer = new wxBoxSizer(wxVERTICAL);
vsizer->AddSpacer(FromDIP(16)); // spacing for title and control
if (index < 0) {
ams_panel->Hide();
} else {
vsizer->Add(ams_panel, 0, wxEXPAND | wxLEFT | wxRIGHT , FromDIP(5));
vsizer->AddSpacer(FromDIP(2));
}
ams_section = new wxBoxSizer(wxVERTICAL);
ams_section->Add(ams_panel, 0, wxEXPAND | wxLEFT | wxRIGHT , FromDIP(5));
ams_section->AddSpacer(FromDIP(2));
vsizer->Add(ams_section, 0, wxEXPAND);
ams_visible = index >= 0;
ams_panel->Show(ams_visible);
vsizer->Show(ams_section, ams_visible);
// Nozzle rows. The first one is the only one for a printer whose nozzles are not listed
// individually; a multi-extruder printer grows one row per tool (see SetNozzleRowCount).
// The rows are dealt into two columns, which halves the height of a long tool list; the second
// column empties itself when there is a single row, so a one-row card still spans the width.
auto rows_sizer = new wxBoxSizer(wxHORIZONTAL);
rows_sizer = new wxBoxSizer(wxHORIZONTAL);
for (wxBoxSizer *&column : row_columns) {
column = new wxBoxSizer(wxVERTICAL);
rows_sizer->Add(column, 1, wxEXPAND | wxLEFT , FromDIP(5));
@@ -1654,7 +1773,11 @@ ExtruderGroup::ExtruderGroup(wxWindow * parent, int index, wxString const &title
Layout();
AMSCountPopupWindow::UpdateAMSCount(index < 0 ? 0 : index, this);
// The indexed cards also maintain the two-entry AMS-count model used by the edit popup. The
// single/nozzle card is a listing card: its AMS previews come from the live unit list bound to
// the chosen tool instead, so it must not seed that model.
if (index >= 0)
AMSCountPopupWindow::UpdateAMSCount(index, this);
}
ExtruderGroup::NozzleRow ExtruderGroup::create_nozzle_row()
@@ -1698,8 +1821,11 @@ ExtruderGroup::NozzleRow ExtruderGroup::create_nozzle_row()
row_sizer->Add(combo_diameter, 1, wxRIGHT, FromDIP(5));
row_sizer->Add(row.diameter_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5));
row_sizer->Add(row.flow, 1);
// Row-major: T1 T2 on the first line, T3 T4 on the second, and so on.
row_columns[row_index % 2]->Add(row_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(5));
row.row_sizer = row_sizer;
// Row-major: T1 T2 on the first line, T3 T4 on the second, and so on. A single-tool card keeps
// its one row on the first column so it spans the card width.
wxBoxSizer *column = rows_single_column ? row_columns[0] : row_columns[row_index % 2];
column->Add(row_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(5));
return row;
}
@@ -1740,22 +1866,56 @@ void ExtruderGroup::SetNozzleRowCount(size_t count)
if (row_count == nozzle_row_count)
return;
nozzle_row_count = row_count;
if (visible_row >= int(row_count))
visible_row = int(row_count) - 1;
for (size_t i = 0; i < row_count; ++i) {
if (i >= rows.size())
rows.push_back(create_nozzle_row());
}
apply_nozzle_row_visibility();
}
void ExtruderGroup::SetVisibleNozzleRow(int index)
{
const size_t shown_rows = std::min(rows.size(), nozzle_row_count);
visible_row = shown_rows == 0 || index < 0 ? -1 : std::min<int>(index, int(shown_rows) - 1);
apply_nozzle_row_visibility();
}
void ExtruderGroup::restack_nozzle_rows()
{
const bool single = visible_row >= 0;
if (rows_single_column == single || !rows_sizer || !row_columns[0] || !row_columns[1])
return;
for (const NozzleRow &row : rows) {
row_columns[0]->Detach(row.row_sizer);
row_columns[1]->Detach(row.row_sizer);
}
for (size_t i = 0; i < rows.size(); ++i) {
wxBoxSizer *column = single ? row_columns[0] : row_columns[i % 2];
column->Add(rows[i].row_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(5));
}
rows_sizer->Show(row_columns[1], !single);
rows_single_column = single;
}
void ExtruderGroup::apply_nozzle_row_visibility()
{
restack_nozzle_rows();
// Every row names its tool as soon as there is more than one, so a single-nozzle card keeps the
// plain "Nozzle" look. A row is shown by showing its widgets: a sizer item holding a sub-sizer
// reports itself as shown while any of its windows is, so hiding the sub-sizer alone would
// leave the row in the layout.
for (size_t i = 0; i < row_count; ++i) {
if (i >= rows.size())
rows.push_back(create_nozzle_row());
rows[i].prefix->Show(row_count > 1);
rows[i].diameter_label->Show(group_index < 0); // the dual left/right cards show combo_diameter
// The flow combo's own visibility is decided with its choices, see update_extruder_variant.
}
for (size_t i = row_count; i < rows.size(); ++i) {
rows[i].prefix->Hide();
rows[i].diameter_label->Hide();
rows[i].flow->Hide();
for (size_t i = 0; i < rows.size(); ++i) {
const bool show = IsNozzleRowVisible(i);
rows[i].prefix->Show(show && nozzle_row_count > 1);
rows[i].diameter_label->Show(show && group_index < 0); // the dual left/right cards show combo_diameter
// The flow combo's own visibility follows its choices and the selected row, see update_extruder_variant.
rows[i].flow->Show(show && rows[i].flow->GetCount() > 0);
}
// The rows just changed: re-measure them, then let the card and its containers give it the new room.
@@ -1768,6 +1928,33 @@ void ExtruderGroup::SetNozzleRowCount(size_t count)
}
}
void ExtruderGroup::SetAmsVisible(bool show)
{
if (ams_visible == show)
return;
ams_visible = show;
if (ams_panel)
ams_panel->Show(show);
if (wxSizer *vsizer = GetSizer(); vsizer && ams_section)
vsizer->Show(ams_section, show);
if (show)
update_ams();
Layout();
if (wxWindow *parent = GetParent()) {
parent->Layout();
if (wxWindow *grand_parent = parent->GetParent())
grand_parent->Layout();
}
}
void ExtruderGroup::ResetAmsPage()
{
page_cur = 0;
if (ams_visible)
update_ams();
}
void ExtruderGroup::update_ams()
{
static AMSinfo info4;
@@ -1778,10 +1965,16 @@ void ExtruderGroup::update_ams()
info1.cans.push_back({});
}
if (btn_edit == nullptr)
if (!ams_visible)
return;
page_num = (ams_n4 * 2 + ams_n1 + 3) / 4;
// The AMS set can shrink when another tool is selected or the device changes; never leave the
// page pointer behind the last page.
if (page_num == 0)
page_cur = 0;
else if (page_cur >= page_num)
page_cur = page_num - 1;
size_t i4 = page_cur * 2;
size_t i1 = 0;
if (i4 > ams_n4) {
@@ -1808,8 +2001,12 @@ void ExtruderGroup::update_ams()
btn_up->Show(page_cur > 0);
btn_down->Show(page_cur + 1 < page_num);
while (hsizer_ams->GetItemCount() > 2)
hsizer_ams->Remove(2);
// Rebuild the row instead of trimming a fixed prefix: the listing card has no edit button, and
// the installed/not-installed message is reused across updates.
hsizer_ams->Clear(false);
hsizer_ams->Add(ams_label, 0, wxALIGN_CENTER | wxRIGHT, FromDIP(5));
if (btn_edit)
hsizer_ams->Add(btn_edit, 0, wxALIGN_CENTER | wxRIGHT, FromDIP(5));
if (ams_not_installed_msg->IsShown()) {
hsizer_ams->AddStretchSpacer(1);
hsizer_ams->Add(ams_not_installed_msg, 0, wxALIGN_CENTER);
@@ -1835,27 +2032,41 @@ void ExtruderGroup::update_ams()
}
}
// The row was rebuilt: lay its contents out before the card sizes itself around them.
ams_panel->Layout();
Layout();
}
void ExtruderGroup::sync_ams(MachineObject const *obj, std::vector<DevAms *> const &ams4, std::vector<DevAms *> const &ams1)
void ExtruderGroup::sync_ams(MachineObject const *obj, std::vector<DevAms *> const &ams4, std::vector<DevAms *> const &ams1, bool live_count)
{
if (ams_4.empty() && ams4.empty()
&& ams_1.empty() && ams1.empty())
return;
auto sync = [obj](std::vector<AMSinfo> &infos, std::vector<DevAms *> const &ams) -> bool {
std::vector<AMSinfo> infos2;
for (auto a : ams) {
AMSinfo ams_info;
ams_info.parse_ams_info(const_cast<MachineObject*>(obj), a, obj->GetFilaSystem()->IsDetectRemainEnabled(), obj->is_support_ams_humidity);
infos2.push_back(ams_info);
}
if (infos == infos2)
return false;
infos.swap(infos2);
return true;
};
if (sync(ams_4, ams4) || sync(ams_1, ams1))
bool changed = false;
if (!ams_4.empty() || !ams4.empty()
|| !ams_1.empty() || !ams1.empty()) {
auto sync = [obj](std::vector<AMSinfo> &infos, std::vector<DevAms *> const &ams) -> bool {
std::vector<AMSinfo> infos2;
for (auto a : ams) {
AMSinfo ams_info;
ams_info.parse_ams_info(const_cast<MachineObject*>(obj), a, obj->GetFilaSystem()->IsDetectRemainEnabled(), obj->is_support_ams_humidity);
infos2.push_back(ams_info);
}
if (infos == infos2)
return false;
infos.swap(infos2);
return true;
};
const bool ams4_changed = sync(ams_4, ams4);
const bool ams1_changed = sync(ams_1, ams1);
changed = ams4_changed || ams1_changed;
}
// A card fed from its own extruder binding derives its preview count from that list instead of
// the preset's AMS count.
if (live_count && (ams_n4 != ams4.size() || ams_n1 != ams1.size())) {
ams_n4 = ams4.size();
ams_n1 = ams1.size();
changed = true;
}
if (changed)
update_ams();
}
@@ -2362,17 +2573,24 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_man
return false;
}
if (!this->plater)
return false;
const Preset &selected_preset = preset_bundle->printers.get_selected_preset();
const Preset *parent_preset = preset_bundle->printers.get_preset_parent(selected_preset);
const bool inherits_system_preset = selected_preset.is_user() && parent_preset && parent_preset->is_system;
if (!inherits_system_preset) {
if (!this->plater)
return false;
this->plater->update_objects_position_when_select_preset([machine_preset]() {
Tab *printer_tab = GUI::wxGetApp().get_tab(Preset::Type::TYPE_PRINTER);
printer_tab->select_preset(machine_preset->name);
});
this->plater->update_objects_position_when_select_preset([machine_preset]() {
Tab *printer_tab = GUI::wxGetApp().get_tab(Preset::Type::TYPE_PRINTER);
printer_tab->select_preset(machine_preset->name);
});
}
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " go on sync_extruder_list";
const Preset &cur_preset = preset_bundle->printers.get_selected_preset();
int extruder_nums = preset_bundle->get_printer_extruder_count();
// The left/right cards and their two-entry AMS-count model do not describe an N-tool printer.
const bool generic_multi_tool = !preset_bundle->is_bbl_vendor() && extruder_nums > 2;
std::vector<int> extruder_map(extruder_nums);
std::iota(extruder_map.begin(), extruder_map.end(), 0);
const ConfigOptionInts *physical_extruder_map = cur_preset.config.option<ConfigOptionInts>("physical_extruder_map");
@@ -2380,7 +2598,19 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_man
assert(physical_extruder_map->values.size() == extruder_nums);
extruder_map = physical_extruder_map->values;
}
assert(obj->GetExtderSystem()->GetTotalExtderCount() == extruder_nums);
const int device_extruder_count = obj->GetExtderSystem()->GetTotalExtderCount();
if (device_extruder_count != extruder_nums) {
MessageDialog dlg(
this->plater,
format_wxstr(
_L("The connected printer reports %1% extruder(s), but the selected printer preset has %2%. "
"Update the preset to match the printer before syncing."),
device_extruder_count, extruder_nums),
_L("Sync printer information"),
wxOK | wxICON_WARNING);
dlg.ShowModal();
return false;
}
// Multi-nozzle: resolve the nozzle option (pops MultiNozzleSyncDialog when is_manual). No-op for every
// existing printer — support_multi_nozzle is false unless some extruder_max_nozzle_count entry is a real
@@ -2459,26 +2689,62 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_man
int main_index = obj->is_main_extruder_on_left() ? 0 : 1;
int deputy_index = obj->is_main_extruder_on_left() ? 1 : 0;
if (extruder_nums > 1) {
int left_index = left_extruder->combo_diameter->FindString(get_diameter_string(nozzle_diameters[0]));
int right_index = left_extruder->combo_diameter->FindString(get_diameter_string(nozzle_diameters[1]));
assert(left_index != -1 && right_index != -1);
left_extruder->combo_diameter->SetSelection(left_index);
right_extruder->combo_diameter->SetSelection(right_index);
is_switching_diameter = true;
switch_diameter(false);
is_switching_diameter = false;
AMSCountPopupWindow::SetAMSCount(deputy_index, deputy_4, deputy_1);
AMSCountPopupWindow::SetAMSCount(main_index, main_4, main_1);
AMSCountPopupWindow::UpdateAMSCount(0, left_extruder);
AMSCountPopupWindow::UpdateAMSCount(1, right_extruder);
} else {
int index = single_extruder->combo_diameter->FindString(get_diameter_string(nozzle_diameters[0]));
assert(index != -1);
single_extruder->combo_diameter->SetSelection(index);
is_switching_diameter = true;
switch_diameter(true);
is_switching_diameter = false;
// Nozzle diameters are not changed by device sync. If the device reports a different or unavailable
// diameter, warn and leave the preset and controls alone while syncing AMS and other printer info.
const ConfigOptionFloats *preset_nozzle_diameter =
preset_bundle->printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter");
bool nozzle_diameters_match = false;
if (preset_nozzle_diameter != nullptr && preset_nozzle_diameter->values.size() == nozzle_diameters.size()) {
nozzle_diameters_match = true;
for (size_t index = 0; index < nozzle_diameters.size(); ++index) {
// A diameter the device does not report (missing, zero, negative or not a finite number) never matches.
const double device_diameter = nozzle_diameters[index];
const double preset_diameter = preset_nozzle_diameter->values[index];
if (!std::isfinite(device_diameter) || device_diameter <= 0.0 ||
!std::isfinite(preset_diameter) || preset_diameter <= 0.0 ||
std::fabs(device_diameter - preset_diameter) > EPSILON) {
nozzle_diameters_match = false;
break;
}
}
}
if (!nozzle_diameters_match) {
if (is_manual) {
MessageDialog dlg(
this->plater,
_L("The nozzle diameter reported by the connected printer does not match the current printer preset. "
"The nozzle diameter and the printer preset will not be synchronized. "
"Select the printer preset that matches the nozzle installed on the printer, then sync again."),
_L("Sync printer information"), wxICON_WARNING | wxOK);
dlg.ShowModal();
} else {
// An automatic sync runs on every device update, so log the mismatch instead of popping a dialog.
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << __LINE__
<< " nozzle diameter mismatch between connected printer and printer preset,"
" skip syncing the nozzle diameter";
}
}
if (!generic_multi_tool) {
if (extruder_nums > 1) {
if (nozzle_diameters_match) {
int left_index = left_extruder->combo_diameter->FindString(get_diameter_string(nozzle_diameters[0]));
int right_index = left_extruder->combo_diameter->FindString(get_diameter_string(nozzle_diameters[1]));
assert(left_index != -1 && right_index != -1);
left_extruder->combo_diameter->SetSelection(left_index);
right_extruder->combo_diameter->SetSelection(right_index);
}
AMSCountPopupWindow::SetAMSCount(deputy_index, deputy_4, deputy_1);
AMSCountPopupWindow::SetAMSCount(main_index, main_4, main_1);
AMSCountPopupWindow::UpdateAMSCount(0, left_extruder);
AMSCountPopupWindow::UpdateAMSCount(1, right_extruder);
} else {
if (nozzle_diameters_match) {
int index = single_extruder->combo_diameter->FindString(get_diameter_string(nozzle_diameters[0]));
assert(index != -1);
single_extruder->combo_diameter->SetSelection(index);
}
}
}
// set nozzle volume type after switching prset, so this value can override the old value stored in conf
@@ -2519,7 +2785,7 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
right_extruder->ShowBadge(false);
right_extruder->sync_ams(nullptr, {}, {});
single_extruder->ShowBadge(false);
single_extruder->sync_ams(nullptr, {}, {});
single_extruder->sync_ams(nullptr, {}, {}, true);
update_extruder_separator_icon(false, false);
//btn_sync_printer->SetBorderColor(not_synced_colour);
//btn_sync_printer->SetIcon("printer_sync");
@@ -2594,8 +2860,19 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
// 2. update extruder status
int extruder_nums = preset_bundle->get_printer_extruder_count();
if (extruder_nums != obj->GetExtderSystem()->GetTotalExtderCount())
// A generic printer with more than two tools shows one tool at a time. Its AMS units are
// attributed by their own extruder bindings; the Bambu left/right conventions do not apply.
const bool generic_multi_tool = !preset_bundle->is_bbl_vendor() && extruder_nums > 2;
if (extruder_nums != obj->GetExtderSystem()->GetTotalExtderCount()) {
// A count mismatch means the card cannot be trusted. Clear the previous device's previews
// and make sure the selected tool still exists instead of keeping a stale card.
clear_all_sync_status();
if (generic_multi_tool) {
update_tool_selector(extruder_nums, true);
single_extruder->SetVisibleNozzleRow(selected_tool);
}
return;
}
// Filament Track Switch: only ever surfaced on multi-extruder machines. When installed, tip/warn
// and show the status icon (green ready / red not-calibrated); when absent the icon stays hidden.
@@ -2644,6 +2921,13 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
right_extruder->diameter.ToDouble(&value);
extruder_infos[1].diameter = float(value);
}
else if (generic_multi_tool) {
// The selected card reads the nozzle diameter from the preset, one value per tool.
if (const auto *nozzle_diameter = preset_bundle->printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter")) {
for (size_t i = 0; i < extruder_infos.size() && i < nozzle_diameter->values.size(); ++i)
extruder_infos[i].diameter = float(nozzle_diameter->values[i]);
}
}
std::vector<ExtruderInfo> machine_extruder_infos(obj->GetExtderSystem()->GetTotalExtderCount());
@@ -2653,6 +2937,31 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
machine_extruder_infos[extruder.GetExtId()].diameter = extruder.GetNozzleDiameter();
}
for (auto &item : obj->GetFilaSystem()->GetAmsList()) {
auto add_to_extruder = [&machine_extruder_infos](DevAms *ams, int extruder_id) {
if (extruder_id < 0 || extruder_id >= int(machine_extruder_infos.size()))
return;
if (ams->GetAmsType() == DevAms::N3S) { // N3S
machine_extruder_infos[extruder_id].ams_1++;
machine_extruder_infos[extruder_id].ams_v1.push_back(ams);
} else {
machine_extruder_infos[extruder_id].ams_4++;
machine_extruder_infos[extruder_id].ams_v4.push_back(ams);
}
};
if (generic_multi_tool) {
// Generic agents report the bound extruder on every AMS unit. Honour the whole binding
// set so a unit the device binds to several tools appears on each of them. Bindings are
// 0-based tool indices, matching selected_tool (T1 == 0).
const std::set<int> &bindings = item.second->GetBindedExtruderSet();
if (bindings.empty())
add_to_extruder(item.second, item.second->GetExtruderId());
else
for (int extruder_id : bindings)
add_to_extruder(item.second, extruder_id);
continue;
}
int extruder_id;
// With the switch ready every AMS feeds both extruders, so attribute each to the extruder its
// input track feeds. Without a switch the binding is a single extruder
@@ -2671,20 +2980,13 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
extruder_id = uniq_extruder_id.value();
}
if (extruder_id >= machine_extruder_infos.size())
continue;
if (item.second->GetAmsType() == DevAms::N3S)
{ // N3S
machine_extruder_infos[extruder_id].ams_1++;
machine_extruder_infos[extruder_id].ams_v1.push_back(item.second);
} else {
machine_extruder_infos[extruder_id].ams_4++;
machine_extruder_infos[extruder_id].ams_v4.push_back(item.second);
}
add_to_extruder(item.second, extruder_id);
}
std::reverse(machine_extruder_infos.begin(), machine_extruder_infos.end());
// UI left is the deputy extruder on Bambu's dual-tool cards, hence the reversal. Generic
// multi-tool cards keep the tool order the printer reports.
if (!generic_multi_tool)
std::reverse(machine_extruder_infos.begin(), machine_extruder_infos.end());
std::vector<bool> extruder_synced(extruder_nums, false);
if (extruder_nums == 1) {
@@ -2721,6 +3023,16 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
right_extruder->sync_ams(obj, {}, {});
}
}
else if (generic_multi_tool) {
// Show the selected tool's AMS previews and badge. The other tools keep their sync state for
// the status icon but have no card of their own.
for (size_t i = 0; i < extruder_infos.size(); ++i)
extruder_synced[i] = is_same_nozzle_info(extruder_infos[i], machine_extruder_infos[i]);
const size_t tool = size_t(std::clamp(selected_tool, 0, extruder_nums - 1));
single_extruder->ShowBadge(extruder_synced[tool]);
single_extruder->sync_ams(obj, machine_extruder_infos[tool].ams_v4, machine_extruder_infos[tool].ams_v1, true);
}
StateColor synced_colour(std::pair<wxColour, int>(wxColour("#CECECE"), StateColor::Normal));
bool all_extruder_synced = std::all_of(extruder_synced.begin(), extruder_synced.end(), [](bool value) { return value; });
@@ -4049,7 +4361,7 @@ void Sidebar::update_presets(Preset::Type preset_type)
select = combo_flow->GetCount() - 1;
combo_flow->SetSelection(select);
// No flow variant for this extruder: the diameter keeps the whole row.
combo_flow->Show(combo_flow->GetCount() > 0);
combo_flow->Show(combo_flow->GetCount() > 0 && extruder.IsNozzleRowVisible(row));
};
auto update_extruder_diameter = [&diameters, &nozzle_diameter, &diameter](int extruder_index,ExtruderGroup & extruder) {
@@ -4096,7 +4408,10 @@ void Sidebar::update_presets(Preset::Type preset_type)
//}
p->image_printer_bed->SetBitmap(create_scaled_bitmap(image_path, this, PRINTER_THUMBNAIL_SIZE.GetHeight()));
} else {
AMSCountPopupWindow::UpdateAMSCount(0, p->single_extruder);
// The generic multi-tool card is populated from the live AMS bindings, not the two-entry
// AMS-count model used by the editable left/right cards.
if (!p->generic_multi_tool_layout)
AMSCountPopupWindow::UpdateAMSCount(0, p->single_extruder);
for (size_t row = 0; row < p->single_extruder->NozzleRowCount(); ++row)
update_extruder_variant(*p->single_extruder, row, int(row));
//if (!p->is_switching_diameter)
@@ -6070,8 +6385,12 @@ void Sidebar::on_bed_type_change(BedType bed_type)
*
* Return Value:
* - Map key encoding:
* - Virtual trays: 0x10000 + vt_tray.id (first/main extruder), or just vt_tray.id (secondary)
* - Virtual trays: 0x10000 + vt_tray.id for the main extruder's slot, or just vt_tray.id for the
* others. Bambu's fixed pair is 255 (main) / 254 (secondary); a generic printer identifies each
* slot by its own id (extruder = VIRTUAL_TRAY_MAIN_ID - id: 255 -> 0, 254 -> 1, ...)
* - AMS trays: 0x10000 + (ams_id * 4 + slot_id) (main extruder), or (ams_id * 4 + slot_id) (secondary)
* - Generic (non-Bambu) units are not four slots wide: their trays index by a cumulative lane
* count across units instead of ams_id * 4 + slot_id
* - The 0x10000 flag indicates the main/right extruder
* - Map value: DynamicPrintConfig with filament properties (id, type, color, etc.)
*
@@ -6091,7 +6410,13 @@ 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) {
const bool is_bbl_vendor = wxTheApp != nullptr && wxGetApp().preset_bundle != nullptr && wxGetApp().preset_bundle->is_bbl_vendor();
// Diagnostic: keys produced from virtual (external) trays, so the final summary can label each
// output entry's origin as virtual vs AMS.
std::set<int> virtual_slot_keys;
auto build_tray_config = [is_bbl_vendor](DevAmsTray const &tray, std::string const &name, std::string ams_id, std::string slot_id, int extruder_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;
DynamicPrintConfig tray_config;
@@ -6106,6 +6431,12 @@ std::map<int, DynamicPrintConfig> Sidebar::build_filament_ams_list(MachineObject
tray_config.set_key_value("filament_colour_type", new ConfigOptionStrings{std::to_string(tray.ctype)});
tray_config.set_key_value("filament_exist", new ConfigOptionBools{tray.is_exists});
tray_config.set_key_value("filament_slot_placeholder", new ConfigOptionBools{tray.is_slot_placeholder});
// Transient (never persisted) per-extruder association of a generic slot, so the sync and
// the slicing per-extruder grouping keep the real extruder instead of the legacy left/right
// buckets. Bambu entries omit it and keep the legacy key-only convention.
if (!is_bbl_vendor) {
tray_config.set_key_value("device_tray_extruder_id", new ConfigOptionInt(extruder_id));
}
std::optional<FilamentBaseInfo> info;
if (wxGetApp().preset_bundle) {
info = wxGetApp().preset_bundle->get_filament_by_filament_id(tray.setting_id);
@@ -6118,10 +6449,44 @@ std::map<int, DynamicPrintConfig> Sidebar::build_filament_ams_list(MachineObject
};
if (obj->ams_support_virtual_tray) {
int extruder = 0x10000; // Main (first) extruder at right
for (auto & vt_tray : obj->vt_slot) {
filament_ams_list.emplace(extruder + stoi(vt_tray.id), build_tray_config(vt_tray, "Ext",vt_tray.id, "0"));//254 or 255
extruder = 0;
for (size_t index = 0; index < obj->vt_slot.size(); ++index) {
auto & vt_tray = obj->vt_slot[index];
int vt_id = 0;
if (vt_tray.id.empty()) continue;
if (!std::all_of(vt_tray.id.begin(), vt_tray.id.end(), [](unsigned char c) { return std::isdigit(c) != 0; })) continue;
try { vt_id = std::stoi(vt_tray.id); } catch (...) { continue; }
if (vt_id < 0 || vt_id > VIRTUAL_TRAY_MAIN_ID) continue;
// Enumerate the extruder ordinal (the vt_slot index) and validate the expected tray id
// instead of trusting the vector size: a stray filler or a stale id for a non-live
// extruder must never surface as a real tray. Bambu keeps its fixed pair untouched.
if (!is_bbl_vendor) {
const int expected_id = VIRTUAL_TRAY_MAIN_ID - static_cast<int>(index);
if (vt_id != expected_id || !devPrinterUtil::IsVirtualSlot(vt_id)) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": skip vt_slot index %1% id %2% expected %3%") % index % vt_id % expected_id;
continue;
}
}
// Bambu keeps its fixed pair with the leading (main/right) slot marked 0x10000. On a
// generic printer every external slot is identified by its own virtual tray id
// (extruder = VIRTUAL_TRAY_MAIN_ID - id: 255 -> 0, 254 -> 1, ...), so mark the main
// extruder's slot by identity rather than by position in vt_slot. The map key then
// keeps each slot bound to its extruder across refreshes and sparse reports.
const bool is_main_slot = is_bbl_vendor ? (&vt_tray == &obj->vt_slot.front())
: (vt_id == VIRTUAL_TRAY_MAIN_ID);
const int key = (is_main_slot ? 0x10000 : 0) + vt_id;
const auto inserted = filament_ams_list.emplace(key,
build_tray_config(vt_tray, "Ext", vt_tray.id, "0", VIRTUAL_TRAY_MAIN_ID - vt_id));
virtual_slot_keys.insert(key);
if (!inserted.second) {
const auto &existing = inserted.first->second;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": virtual insert collision key %1% incoming_id %2% existing_origin %3% existing_ams %4% existing_slot %5% existing_type %6% existing_id %7%")
% key % vt_id
% (virtual_slot_keys.count(key) ? "virtual" : "ams")
% existing.opt_string("ams_id", 0u)
% existing.opt_string("slot_id", 0u)
% existing.opt_string("filament_type", 0u)
% existing.opt_string("filament_id", 0u);
}
}
}
@@ -6141,11 +6506,61 @@ std::map<int, DynamicPrintConfig> Sidebar::build_filament_ams_list(MachineObject
for (auto ams : list) {
int ams_id = std::stoi(ams.first);
int extruder = ams.second->GetExtruderId() ? 0 : 0x10000; // Main (first) extruder at right
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": AMS unit %1% extruder %2% trays %3%")
% ams_id % ams.second->GetExtruderId() % ams.second->GetTrays().size();
// Bambu units are four slots wide; a generic unit reports its own lane count and its trays
// carry a cumulative lane index. The AMS map is keyed by string id, so count preceding units
// numerically to keep this the same index the parser and the agent use.
int generic_tray_base = 0;
if (!is_bbl_vendor) {
for (const auto& other : list) {
if (std::stoi(other.first) < ams_id)
generic_tray_base += static_cast<int>(other.second->GetTrays().size());
}
}
size_t inserted_trays = 0;
size_t rejected_trays = 0;
for (auto tray : ams.second->GetTrays()) {
int slot_id = std::stoi(tray.first);
filament_ams_list.emplace(extruder + (ams_id * 4 + slot_id),
build_tray_config(*tray.second, get_ams_name(ams_id, slot_id), std::to_string(ams_id), std::to_string(slot_id)));
const int tray_index = is_bbl_vendor ? ams_id * 4 + slot_id : generic_tray_base + slot_id;
const int key = extruder + tray_index;
auto inserted = filament_ams_list.emplace(key,
build_tray_config(*tray.second, get_ams_name(ams_id, slot_id), std::to_string(ams_id), std::to_string(slot_id),
ams.second->GetExtruderId()));
if (inserted.second) {
++inserted_trays;
} else {
++rejected_trays;
const auto &existing = inserted.first->second;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": AMS insert collision key %1% incoming_ams %2% incoming_slot %3% incoming_extruder %4% existing_origin %5% existing_ams %6% existing_slot %7% existing_extruder %8% existing_type %9% existing_id %10%")
% key % ams_id % slot_id % ams.second->GetExtruderId()
% (virtual_slot_keys.count(key) ? "virtual" : "ams")
% existing.opt_string("ams_id", 0u)
% existing.opt_string("slot_id", 0u)
% (existing.has("device_tray_extruder_id") ? existing.opt_int("device_tray_extruder_id") : -1)
% existing.opt_string("filament_type", 0u)
% existing.opt_string("filament_id", 0u);
}
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": AMS unit %1% tray inserts accepted %2% rejected %3%")
% ams_id % inserted_trays % rejected_trays;
}
// Diagnostic: dump the final output map (origin, key, identity/data flags) so parser output can
// be correlated with the entries logged at the sync boundary.
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": output entries %1%") % filament_ams_list.size();
for (const auto &entry : filament_ams_list) {
const auto &cfg = entry.second;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": out %1% key %2% ams_id %3% slot_id %4% extruder %5% type %6% id %7% exist %8% placeholder %9%")
% (virtual_slot_keys.count(entry.first) ? "virtual" : "ams")
% entry.first
% cfg.opt_string("ams_id", 0u)
% cfg.opt_string("slot_id", 0u)
% (cfg.has("device_tray_extruder_id") ? cfg.opt_int("device_tray_extruder_id") : -1)
% cfg.opt_string("filament_type", 0u)
% cfg.opt_string("filament_id", 0u)
% cfg.opt_bool("filament_exist", 0u)
% cfg.opt_bool("filament_slot_placeholder", 0u);
}
return filament_ams_list;
}
@@ -6382,6 +6797,20 @@ void Sidebar::sync_ams_list(bool is_from_big_sync_btn)
std::vector<std::pair<DynamicPrintConfig *,std::string>> unknowns;
auto enable_append = wxGetApp().app_config->get_bool("enable_append_color_by_sync_ams");
auto sync_color_only = wxGetApp().app_config->get("sync_ams_filament_mode") == "1";
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": sync_color_only %1% enable_append %2% direct_sync %3% effective_use_map %4% filament_ams_list %5%")
% sync_color_only % enable_append % sync_result.direct_sync % (!sync_result.direct_sync) % list.size();
for (const auto &entry : list) {
const auto &tray = entry.second;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": tray %1% ams_id %2% slot_id %3% filament_id %4% type %5% color %6% filament_exist %7% placeholder %8%")
% entry.first
% tray.opt_string("ams_id", 0u)
% tray.opt_string("slot_id", 0u)
% tray.opt_string("filament_id", 0u)
% tray.opt_string("filament_type", 0u)
% tray.opt_string("filament_colour", 0u)
% (tray.has("filament_exist") && tray.opt_bool("filament_exist", 0u))
% (tray.has("filament_slot_placeholder") && tray.opt_bool("filament_slot_placeholder", 0u));
}
auto n = wxGetApp().preset_bundle->sync_ams_list(unknowns, !sync_result.direct_sync, sync_result.sync_maps, enable_append, merge_info, sync_color_only);
wxString detail;
for (auto & uk : unknowns) {
+8 -2
View File
@@ -37,6 +37,7 @@
#include "BackgroundSlicingProcess.hpp" // complete type for background_process().get_current_gcode_result()
#include "DeviceCore/DevStorage.h"
#include <wx/app.h>
#include <wx/event.h>
#include <string>
#include "libslic3r/PrintConfig.hpp"
@@ -1155,7 +1156,7 @@ void SelectMachineDialog::sync_ams_mapping_result(std::vector<FilamentInfo> &res
wxString ams_id;
wxColour ams_col;
if (f->tray_id == VIRTUAL_TRAY_MAIN_ID || f->tray_id == VIRTUAL_TRAY_DEPUTY_ID)
if (devPrinterUtil::IsVirtualSlot(f->ams_id))
{
ams_id = _L("Ext");
}else if (f->tray_id >= 0) {
@@ -1347,6 +1348,8 @@ bool SelectMachineDialog::get_ams_mapping_result(std::string &mapping_array_str,
if (m_ams_mapping_result.empty())
return false;
const bool is_bbl_vendor = wxTheApp != nullptr && wxGetApp().preset_bundle != nullptr && wxGetApp().preset_bundle->is_bbl_vendor();
bool valid_mapping_result = true;
int invalid_count = 0;
for (int i = 0; i < m_ams_mapping_result.size(); i++) {
@@ -1402,7 +1405,10 @@ bool SelectMachineDialog::get_ams_mapping_result(std::string &mapping_array_str,
// convert #RRGGBB to RRGGBBAA
mapping_item["sourceColor"] = m_filaments[k].color;
mapping_item["targetColor"] = m_ams_mapping_result[k].color;
if (tray_id == VIRTUAL_TRAY_MAIN_ID || tray_id == VIRTUAL_TRAY_DEPUTY_ID) { tray_id = -1; }
// The legacy v0 mapping array encodes an external spool as -1, a BBL-firmware
// contract. A generic printer keeps the virtual tray id (255 - extruder) so
// the payload still says which extruder's external slot was mapped.
if (is_bbl_vendor && (tray_id == VIRTUAL_TRAY_MAIN_ID || tray_id == VIRTUAL_TRAY_DEPUTY_ID)) { tray_id = -1; }
/*new ams mapping data*/
try {
File diff suppressed because it is too large. Load diff
+36 -4
View File
@@ -1,6 +1,8 @@
#ifndef slic3r_StatusPanel_hpp_
#define slic3r_StatusPanel_hpp_
#include <vector>
#include "libslic3r/ProjectTask.hpp"
#include "DeviceManager.hpp"
#include "MonitorPage.hpp"
@@ -66,6 +68,7 @@
#include "StagedBuild.hpp"
class StepIndicator;
class wxChoice;
#define COMMAND_TIMEOUT 5
@@ -140,28 +143,36 @@ class ExtruderImage : public wxWindow
ScalableBitmap *m_left_extruder_active_empty;
ScalableBitmap *m_left_extruder_unactive_filled;
ScalableBitmap *m_left_extruder_unactive_empty;
ScalableBitmap *m_right_extruder_active_filled;
ScalableBitmap *m_right_extruder_active_empty;
ScalableBitmap *m_right_extruder_unactive_filled;
ScalableBitmap *m_right_extruder_unactive_empty;
ScalableBitmap *m_extruder_single_nozzle_empty_load;
ScalableBitmap *m_extruder_single_nozzle_empty_unload;
ScalableBitmap *m_extruder_single_nozzle_filled_load;
ScalableBitmap *m_extruder_single_nozzle_filled_unload;
ExtruderState m_left_ext_state = {ExtruderState::EMPTY_LOAD};
ExtruderState m_right_ext_state = {ExtruderState::EMPTY_LOAD};
ExtruderState m_single_ext_state = {ExtruderState::EMPTY_LOAD};
std::vector<ExtruderState> m_multi_extruder_states;
bool m_generic_nozzle_display{false};
public:
void update(int nozzle_num, int nozzle_id);
void update(ExtruderState single_state);
void update(ExtruderState right_state, ExtruderState left_state);
void update(ExtruderState state, int idx);
void msw_rescale();
void updateGenericSize();
void setExtruderCount(int nozzle_num);
void setGenericNozzleDisplay(bool enabled);
void setExtruderUsed(std::string loc);
void setExtruderUsed(int nozzle_idx);
void paintEvent(wxPaintEvent &evt);
void render(wxDC &dc);
@@ -495,6 +506,8 @@ protected:
std::vector<ExtruderImage *> m_extruderImage;
SwitchBoard * m_nozzle_btn_panel;
wxChoice* m_generic_nozzle_selector{nullptr};
int m_generic_nozzle_selector_count{0};
wxStaticText * m_text_tasklist_caption;
@@ -507,10 +520,23 @@ protected:
wxBoxSizer * m_misc_ctrl_sizer;
StaticBox* m_fan_panel;
StaticLine * m_line_nozzle;
TempInput* m_tempCtrl_nozzle;
wxWindowID m_nozzle_temp_control_id{wxID_ANY};
wxWindow* m_temp_nozzle_parent{nullptr};
wxBoxSizer* m_temp_nozzle_sizer{nullptr};
size_t m_temp_nozzle_active_count{0};
TempInput* m_tempCtrl_nozzle{nullptr};
int m_temp_nozzle_timeout{ 0 };
TempInput* m_tempCtrl_nozzle_deputy;
TempInput* m_tempCtrl_nozzle_deputy{nullptr};
int m_temp_nozzle_deputy_timeout{ 0 };
std::vector<TempInput*> m_tempCtrl_nozzles;
std::vector<int> m_temp_nozzle_timeouts;
// Last (dev_id, device toolhead count, preset nozzle count) we warned about, to avoid log spam.
std::string m_last_mismatch_dev_id;
int m_last_mismatch_device_count{-1};
int m_last_mismatch_preset_count{-1};
TempInput * m_tempCtrl_bed;
int m_temp_bed_timeout {0};
TempInput * m_tempCtrl_chamber;
@@ -550,6 +576,7 @@ protected:
/* AMS control box <-> live nozzle-rack panel toggle (rack printers only) */
SwitchBoard* m_ams_rack_switch{ nullptr };
MultiSwitchButton* m_ams_nozzle_switch{ nullptr };
AMSControl* m_ams_control;
StaticBox* m_ams_control_box;
@@ -624,6 +651,9 @@ public:
wxBoxSizer *create_temp_axis_group(wxWindow *parent);
wxBoxSizer *create_temp_control(wxWindow *parent);
TempInput* create_nozzle_temp_control(wxWindow *parent, wxWindowID id);
void set_temp_input_colors(TempInput* temp_ctrl);
void ensure_nozzle_temp_controls(size_t count);
wxBoxSizer *create_misc_control(wxWindow *parent);
wxBoxSizer *create_axis_control(wxWindow *parent);
wxPanel *create_bed_control(wxWindow *parent);
@@ -654,6 +684,7 @@ private:
friend class MonitorPanel;
void wire_controls();
bool load_thumbnail_from_url(const wxString &url, MachineObject *obj);
void sync_nozzle_temp_controls(size_t count);
protected:
std::shared_ptr<SliceInfoPopup> m_slice_info_popup;
@@ -801,6 +832,7 @@ protected:
void update_cloud_subtask(MachineObject *obj);
void update_sdcard_subtask(MachineObject *obj);
void update_temp_ctrl(MachineObject *obj);
void check_extruder_count_mismatch(MachineObject* obj);
void update_misc_ctrl(MachineObject *obj);
void update_ams(MachineObject* obj);
void update_rack(MachineObject* obj);
+8 -2
View File
@@ -37,6 +37,7 @@
#include <vector>
#include <wx/anybutton.h>
#include <wx/animate.h>
#include <wx/app.h>
#include <wx/busycursor.h>
#include <wx/chartype.h>
#include <wx/arrstr.h>
@@ -1245,7 +1246,7 @@ void SyncAmsInfoDialog::sync_ams_mapping_result(std::vector<FilamentInfo> &resul
wxString ams_id;
wxColour ams_col;
if (f->tray_id == VIRTUAL_TRAY_MAIN_ID || f->tray_id == VIRTUAL_TRAY_DEPUTY_ID) {
if (devPrinterUtil::IsVirtualSlot(f->ams_id)) {
ams_id = _L("Ext");
}
@@ -1393,6 +1394,8 @@ bool SyncAmsInfoDialog::get_ams_mapping_result(std::string &mapping_array_str, s
if (m_ams_mapping_result.empty())
return false;
const bool is_bbl_vendor = wxTheApp != nullptr && wxGetApp().preset_bundle != nullptr && wxGetApp().preset_bundle->is_bbl_vendor();
bool valid_mapping_result = true;
int invalid_count = 0;
for (int i = 0; i < m_ams_mapping_result.size(); i++) {
@@ -1449,7 +1452,10 @@ bool SyncAmsInfoDialog::get_ams_mapping_result(std::string &mapping_array_str, s
// convert #RRGGBB to RRGGBBAA
mapping_item["sourceColor"] = m_filaments[k].color;
mapping_item["targetColor"] = m_ams_mapping_result[k].color;
if (tray_id == VIRTUAL_TRAY_MAIN_ID || tray_id == VIRTUAL_TRAY_DEPUTY_ID) { tray_id = -1; }
// The legacy v0 mapping array encodes an external spool as -1, a BBL-firmware
// contract. A generic printer keeps the virtual tray id (255 - extruder) so the
// payload still says which extruder's external slot was mapped.
if (is_bbl_vendor && (tray_id == VIRTUAL_TRAY_MAIN_ID || tray_id == VIRTUAL_TRAY_DEPUTY_ID)) { tray_id = -1; }
/*new ams mapping data*/
try {
+502 -21
View File
@@ -244,7 +244,7 @@ AMSControl::AMSControl(wxWindow *parent, wxWindowID id, const wxPoint &pos, cons
m_sizer_ams_body->Add(m_sizer_ams_area_right, wxALIGN_CENTER, 0);
m_sizer_body->Add(m_sizer_ams_items, 0, wxALIGN_CENTER, 0);
m_sizer_body->Add(0, 0, 1, wxEXPAND | wxTOP, FromDIP(10));
m_sizer_ams_gap = m_sizer_body->Add(0, 0, 1, wxEXPAND | wxTOP, FromDIP(10));
m_sizer_body->Add(m_sizer_ams_body, 0, wxALIGN_CENTER, 0);
m_sizer_body->Add(m_sizer_down_road, 0, wxALIGN_CENTER, 0);
m_sizer_body->Add(m_sizer_ams_option, 0, wxEXPAND, 0);
@@ -371,7 +371,7 @@ bool AMSControl::IsAmsInRightPanel(std::string ams_id) {
return false;
}
}
else{
else if (m_total_ext_count == 1){
for (auto id : m_item_ids[MAIN_EXTRUDER_ID]){
if (id == ams_id){
return true;
@@ -379,6 +379,10 @@ bool AMSControl::IsAmsInRightPanel(std::string ams_id) {
}
return false;
}
else {
// Generic N Nozzles
}
return false;
}
void AMSControl::AmsSelectedSwitch(wxCommandEvent& event) {
@@ -519,7 +523,7 @@ void AMSControl::msw_rescale()
m_button_ams_setting_press.msw_rescale();
m_button_ams_setting->SetBitmap(m_button_ams_setting_normal.bmp());
m_extruder->msw_rescale();
if (m_extruder) m_extruder->msw_rescale();
if (m_button_extruder_feed) m_button_extruder_feed->Rescale(); // ORCA
if (m_button_extruder_back) m_button_extruder_back->Rescale(); // ORCA
@@ -603,6 +607,8 @@ void AMSControl::CreateAms()
void AMSControl::ClearAms() {
m_sizer_ams_items->Clear(false);
m_simplebook_ams_right->DeleteAllPages();
m_simplebook_ams_left->DeleteAllPages();
m_simplebook_ams_right->DestroyChildren();
@@ -613,7 +619,7 @@ void AMSControl::ClearAms() {
m_simplebook_ams_left->Refresh();
for (auto it : m_ams_preview_list) {
delete it.second;
if (it.second) it.second->Destroy();
}
m_ams_preview_list.clear();
m_ext_image_list.clear();
@@ -624,12 +630,181 @@ void AMSControl::ClearAms() {
m_ams_item_list.clear();
m_sizer_prv_right->Clear();
m_sizer_prv_left->Clear();
m_item_ids = { {}, {} };
if (m_nozzle_book) {
m_nozzle_book->Destroy();
m_nozzle_book = nullptr;
} else {
for (auto &pane : m_nozzle_panes) {
if (pane.preview_panel) pane.preview_panel->Destroy();
if (pane.ams_book) pane.ams_book->Destroy();
if (pane.page) pane.page->Destroy();
}
}
m_item_ids.assign(std::max(m_total_ext_count, 2), {});
m_nozzle_panes.clear();
m_active_nozzle_id = 0;
m_current_ams.clear();
m_current_show_ams_left.clear();
m_current_show_ams_right.clear();
pair_id.clear();
}
void AMSControl::restore_legacy_ams_layout()
{
m_sizer_ams_items->Clear(false);
m_sizer_ams_items->SetOrientation(wxHORIZONTAL);
m_sizer_body->GetItem(m_sizer_ams_items)->SetFlag(wxALIGN_CENTER);
m_sizer_ams_items->Add(m_panel_prv_left, 0, wxLEFT | wxRIGHT, FromDIP(5));
m_sizer_ams_items->Add(m_panel_prv_right, 0, wxLEFT | wxRIGHT, FromDIP(5));
m_panel_prv_left->Show();
m_panel_prv_right->Show();
m_simplebook_ams_left->Show();
m_simplebook_ams_right->Show();
m_panel_down_road->Show();
m_sizer_body->Show(m_sizer_ams_body, true);
m_sizer_option_mid->Show(m_extruder, true);
m_down_road->SetSingleSideLayout(false);
m_sizer_ams_gap->SetProportion(1);
m_sizer_ams_gap->SetBorder(FromDIP(10));
m_down_road->SetNozzleCount(m_total_ext_count);
}
bool AMSControl::use_multi_nozzle_layout() const
{
const bool is_bbl = wxGetApp().preset_bundle && wxGetApp().preset_bundle->is_bbl_vendor();
return is_bbl ? m_total_ext_count > 2 : m_nozzle_book != nullptr;
}
void AMSControl::CreateAmsMultiNozzle(const std::string &series_name, const std::string &printer_type) {
const size_t pane_count = static_cast<size_t>(std::max(m_total_ext_count, 0));
m_nozzle_panes.resize(pane_count);
m_active_nozzle_id = 0;
m_sizer_ams_items->Clear(false);
m_sizer_ams_items->SetOrientation(wxVERTICAL);
m_sizer_body->GetItem(m_sizer_ams_items)->SetFlag(wxALIGN_CENTER);
m_sizer_body->Show(m_sizer_ams_body, false);
m_sizer_ams_gap->SetProportion(0);
m_sizer_ams_gap->SetBorder(0);
m_panel_prv_left->Hide();
m_panel_prv_right->Hide();
m_simplebook_ams_left->Hide();
m_simplebook_ams_right->Hide();
m_panel_down_road->Show();
m_sizer_option_mid->Show(m_extruder, true);
m_switcher->Hide();
m_down_road->SetNozzleCount(1);
m_down_road->SetSingleSideLayout(true, AMSPanelPos::LEFT_PANEL);
m_down_road->UpdateLeft(1, AMSRoadShowMode::AMS_ROAD_MODE_SINGLE);
m_down_road->UpdateRight(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
m_extruder->updateNozzleNum(1, series_name);
m_nozzle_book = new wxSimplebook(m_amswin, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0);
m_nozzle_book->SetMinSize(wxSize(FromDIP(264), -1));
m_nozzle_book->SetBackgroundColour(StateColor::darkModeColorFor(AMS_CONTROL_DEF_BLOCK_BK_COLOUR));
m_sizer_ams_items->Add(m_nozzle_book, 0, wxALIGN_CENTER);
for (size_t nozzle_id = 0; nozzle_id < pane_count; ++nozzle_id) {
auto &pane = m_nozzle_panes[nozzle_id];
// A generic page is one H2C side. Keep the left-side geometry as the
// canonical single-nozzle presentation; the road and AMS item code
// still use the same side-specific paths as the legacy layout.
pane.panel_pos = AMSPanelPos::LEFT_PANEL;
pane.page = new wxPanel(m_nozzle_book, wxID_ANY);
pane.page->SetMinSize(wxSize(FromDIP(264), -1));
pane.page->SetBackgroundColour(StateColor::darkModeColorFor(AMS_CONTROL_DEF_BLOCK_BK_COLOUR));
pane.page_sizer = new wxBoxSizer(wxVERTICAL);
pane.page->SetSizer(pane.page_sizer);
pane.preview_panel = new wxScrolledWindow(pane.page, wxID_ANY);
pane.preview_panel->SetScrollRate(10, 0);
pane.preview_panel->SetSize(AMS_ITEMS_PANEL_SIZE);
pane.preview_panel->SetMinSize(AMS_ITEMS_PANEL_SIZE);
pane.preview_panel->SetBackgroundColour(StateColor::darkModeColorFor(AMS_CONTROL_DEF_BLOCK_BK_COLOUR));
pane.preview_sizer = new wxBoxSizer(wxHORIZONTAL);
pane.preview_panel->SetSizer(pane.preview_sizer);
// Generic lane cards grow to their calculated width for 1-8 lanes. Keep the
// book at the legacy minimum, but allow the page to expand to that width.
pane.ams_book = new wxSimplebook(pane.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, 0);
pane.ams_book->SetMinSize(wxSize(FromDIP(264), -1));
pane.ams_book->SetBackgroundColour(StateColor::darkModeColorFor(AMS_CONTROL_DEF_BLOCK_BK_COLOUR));
// wxALIGN_CENTER does not centre horizontally inside a HORIZONTAL sizer, so a book wider
// than pane.ams_area's own minimum would sit at ams_area's left edge and overflow to the
// right, shifting the whole card column off the shared down-road connector axis. A pair of
// stretch spacers keeps the book centred on the pane (and thus on the connector) at any
// book width; ams_area is expanded below so the spacers have room to work with.
pane.ams_area = new wxBoxSizer(wxHORIZONTAL);
pane.ams_area->AddStretchSpacer(1);
pane.ams_area->Add(pane.ams_book, 0, wxALIGN_CENTER, 0);
pane.ams_area->AddStretchSpacer(1);
pane.page_sizer->Add(pane.preview_panel, 0, wxALIGN_CENTER | wxTOP, FromDIP(5));
pane.page_sizer->Add(pane.ams_area, 0, wxEXPAND | wxTOP, FromDIP(10));
m_nozzle_book->AddPage(pane.page, wxEmptyString, nozzle_id == 0);
}
auto add_info = [this, series_name, printer_type, pane_count](AMSinfo info) {
if (pane_count == 0) return;
info.nozzle_id = std::clamp(info.nozzle_id, 0, static_cast<int>(pane_count) - 1);
AddAmsPreview(info, info.ams_type);
if (info.ams_type == AMSModel::EXT_AMS && info.cans.size() == 1)
AddAms({info}, series_name, printer_type, m_nozzle_panes[info.nozzle_id].panel_pos);
else
AddAms(info);
};
for (const auto &info : m_ams_info)
add_info(info);
for (auto &info : m_ext_info) {
const int virtual_tray_id = std::atoi(info.ams_id.c_str());
if (virtual_tray_id <= VIRTUAL_TRAY_MAIN_ID &&
virtual_tray_id >= VIRTUAL_TRAY_MAIN_ID - static_cast<int>(pane_count) + 1)
info.nozzle_id = VIRTUAL_TRAY_MAIN_ID - virtual_tray_id;
add_info(info);
}
for (auto &pane : m_nozzle_panes) {
if (pane.ams_book && pane.ams_book->GetPageCount() > 0)
pane.ams_book->SetSelection(0);
if (pane.preview_sizer) pane.preview_sizer->Layout();
if (pane.preview_panel) pane.preview_panel->FitInside();
if (pane.ams_book) pane.ams_book->Layout();
if (pane.page) pane.page->Layout();
if (!pane.current_ams.empty()) {
auto preview = m_ams_preview_list.find(pane.current_ams);
if (preview != m_ams_preview_list.end() && preview->second)
preview->second->OnSelected();
}
}
if (m_nozzle_book && pane_count > 0) {
m_nozzle_book->SetSelection(0);
m_current_ams = m_nozzle_panes[0].current_ams;
if (!m_current_ams.empty())
SwitchAms(m_current_ams);
else {
m_down_road->UpdateLeft(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
m_down_road->UpdateRight(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
}
}
m_amswin->Layout();
m_amswin->Fit();
// Generic N Nozzles
}
void AMSControl::CreateAmsDoubleNozzle(const std::string &series_name, const std::string &printer_type)
{
restore_legacy_ams_layout();
std::vector<AMSinfo> single_info_left;
std::vector<AMSinfo> single_info_right;
@@ -751,6 +926,8 @@ void AMSControl::CreateAmsDoubleNozzle(const std::string &series_name, const std
void AMSControl::CreateAmsSingleNozzle(const std::string &series_name, const std::string &printer_type)
{
restore_legacy_ams_layout();
std::vector<int>m_item_nums{0,0};
std::vector<AMSinfo> single_info;
@@ -979,6 +1156,17 @@ void AMSControl::UpdateAms(const std::string &series_name,
if (m_ams_info[i].routes_to_main_extruder() != ams_info[i].routes_to_main_extruder()) {
fresh = true;
}
if (m_ams_info[i].nozzle_id != ams_info[i].nozzle_id) {
fresh = true;
}
}
for (int i = 0; i < static_cast<int>(m_ext_info.size()); ++i) {
if (m_ext_info[i].ams_id != ext_info[i].ams_id ||
m_ext_info[i].nozzle_id != ext_info[i].nozzle_id ||
m_ext_info[i].ext_type != ext_info[i].ext_type) {
fresh = true;
}
}
}
else{
@@ -993,11 +1181,16 @@ void AMSControl::UpdateAms(const std::string &series_name,
m_dev_id = dev_id;
if (fresh){
ClearAms();
if (m_total_ext_count >= 2){
CreateAmsDoubleNozzle(series_name, printer_type);
}else{
CreateAmsSingleNozzle(series_name, printer_type);
bool is_bbl = wxGetApp().preset_bundle && wxGetApp().preset_bundle->is_bbl_vendor();
if (is_bbl) {
if (m_total_ext_count == 1)
CreateAmsSingleNozzle(series_name, printer_type);
else if (m_total_ext_count == 2)
CreateAmsDoubleNozzle(series_name, printer_type);
}
else
CreateAmsMultiNozzle(series_name, printer_type);
SetSize(wxSize(FromDIP(578), -1));
SetMinSize(wxSize(FromDIP(578), -1));
Layout();
@@ -1070,7 +1263,7 @@ void AMSControl::UpdateAms(const std::string &series_name,
}
/*update ams extruder*/
if (m_extruder->updateNozzleNum(m_total_ext_count, series_name))
if (m_total_ext_count <= 2 && m_extruder->updateNozzleNum(m_total_ext_count, series_name))
{
m_amswin->Layout();
}
@@ -1106,7 +1299,15 @@ void AMSControl::AddAmsPreview(AMSinfo info, AMSModel type)
{
AMSPreview *ams_prv = nullptr;
if (info.routes_to_main_extruder())
// Match AddAms: the generic multi-nozzle layout's preview panel is the visible one for
// non-Bambu vendors at any extruder count, not only above two.
if (use_multi_nozzle_layout()) {
const int nozzle_id = std::clamp(info.nozzle_id, 0, m_total_ext_count - 1);
auto &pane = m_nozzle_panes[nozzle_id];
ams_prv = new AMSPreview(pane.preview_panel, wxID_ANY, info, type);
pane.preview_sizer->Add(ams_prv, 0, wxALIGN_CENTER | wxLEFT, FromDIP(6));
}
else if (info.routes_to_main_extruder())
{
ams_prv = new AMSPreview(m_panel_prv_right, wxID_ANY, info, type);
m_sizer_prv_right->Add(ams_prv, 0, wxALIGN_CENTER | wxLEFT, FromDIP(6));
@@ -1129,6 +1330,54 @@ void AMSControl::AddAmsPreview(AMSinfo info, AMSModel type)
void AMSControl::createAms(wxSimplebook* parent, int& idx, AMSinfo info, AMSPanelPos pos) {
auto ams_item = new AmsItem(parent, info, info.ams_type, pos);
parent->InsertPage(idx, ams_item, wxEmptyString, true);
// Generic AMS cards may be wider than the legacy 264 DIP page. Propagate
// that calculated width through the page and simplebook so 5-8 lanes are
// laid out fully instead of being clipped by the old minimum width.
if (info.ams_type == AMSModel::GENERIC_AMS) {
const int required_width = ams_item->GetMinSize().x;
if (required_width > 0) {
const int page_width = std::max(parent->GetMinSize().x, required_width);
parent->SetMinSize(wxSize(page_width, -1));
if (auto *page = parent->GetParent()) {
page->SetMinSize(wxSize(std::max(page->GetMinSize().x, page_width), -1));
if (auto *nozzle_book = page->GetParent())
nozzle_book->SetMinSize(wxSize(std::max(nozzle_book->GetMinSize().x, page_width), -1));
}
}
// Above eight lanes, AmsItem is a fixed-minimum viewport over a wider
// lane strip. Expand the viewport through its page's sizers rather
// than propagating the strip's virtual width into the card/book.
// Reset back to the centered fixed layout for eight or fewer lanes so a
// later small unit is not left in expand mode.
const bool generic_lane_layout = !wxGetApp().preset_bundle || !wxGetApp().preset_bundle->is_bbl_vendor();
if (generic_lane_layout) {
if (auto *page = parent->GetParent()) {
auto pane = std::find_if(m_nozzle_panes.begin(), m_nozzle_panes.end(),
[page](const NozzleAmsPane &candidate) { return candidate.page == page; });
if (pane != m_nozzle_panes.end() && m_nozzle_book) {
const bool expand_viewport = info.cans.size() > 8;
if (auto *book_item = pane->ams_area->GetItem(pane->ams_book)) {
book_item->SetProportion(expand_viewport ? 1 : 0);
book_item->SetFlag(expand_viewport ? wxEXPAND : wxALIGN_CENTER);
}
// Keep the centring spacers (items 0 and 2) elastic only in the centred layout;
// in the >8-lane viewport they must collapse so the book can fill ams_area.
if (auto *lead = pane->ams_area->GetItem(static_cast<size_t>(0)))
lead->SetProportion(expand_viewport ? 0 : 1);
if (auto *trail = pane->ams_area->GetItem(static_cast<size_t>(2)))
trail->SetProportion(expand_viewport ? 0 : 1);
if (auto *area_item = pane->page_sizer->GetItem(pane->ams_area))
area_item->SetFlag(wxEXPAND | wxTOP);
if (auto *nozzle_book_item = m_sizer_ams_items->GetItem(m_nozzle_book))
nozzle_book_item->SetFlag(expand_viewport ? wxEXPAND : wxALIGN_CENTER);
if (auto *items_item = m_sizer_body->GetItem(m_sizer_ams_items))
items_item->SetFlag(expand_viewport ? wxEXPAND : wxALIGN_CENTER);
m_amswin->Layout();
}
}
}
}
ams_item->set_selection(idx);
idx++;
@@ -1153,7 +1402,10 @@ AMSRoadShowMode AMSControl::findFirstMode(AMSPanelPos pos) {
if (item->second->get_ams_model() == AMSModel::EXT_AMS && item->second->get_ext_type() == AMSModelOriginType::LITE_EXT) return AMSRoadShowMode::AMS_ROAD_MODE_AMS_LITE;
return AMSRoadShowMode::AMS_ROAD_MODE_FOUR;
}
else{
else if (item->second->get_can_count() > GENERIC_AMS_SLOT_NUM) {
return AMSRoadShowMode::AMS_ROAD_MODE_GENERIC;
}
else {
for (auto ids : pair_id){
if (ids.first == ams_id || ids.second == ams_id){
return AMSRoadShowMode::AMS_ROAD_MODE_DOUBLE;
@@ -1172,8 +1424,13 @@ void AMSControl::createAmsPanel(wxSimplebook *parent, int &idx, std::vector<AMSi
wxPanel* book_panel = new wxPanel(parent);
wxBoxSizer* book_sizer = new wxBoxSizer(wxHORIZONTAL);
book_panel->SetBackgroundColour(StateColor::darkModeColorFor(AMS_CONTROL_DEF_LIB_BK_COLOUR));
book_panel->SetSize(AMS_PANEL_SIZE);
book_panel->SetMinSize(AMS_PANEL_SIZE);
// Size the page to the AMS book it becomes, not to the legacy fixed width: an AMS card is
// added directly as the book page (so it always spans the book), while a single external
// spool is wrapped here. Matching the book keeps the Ext card centred on the same axis as
// the AMS cards and the shared down-road connector anchored below them.
const wxSize book_panel_size(std::max(parent->GetMinSize().x, AMS_PANEL_SIZE.x), AMS_PANEL_SIZE.y);
book_panel->SetSize(book_panel_size);
book_panel->SetMinSize(book_panel_size);
AmsItem* ams1 = nullptr, * ams2 = nullptr;
ams1 = new AmsItem(book_panel, infos[0], infos[0].ams_type, pos);
@@ -1200,6 +1457,14 @@ void AMSControl::createAmsPanel(wxSimplebook *parent, int &idx, std::vector<AMSi
//book_sizer->Add(ams1, 0, wxALIGN_CENTER_HORIZONTAL, 0);
book_sizer->Add(ams1, 0, wxLEFT, (book_panel->GetSize().x - ams1->GetSize().x) / 2);
}
else if (pos == AMSPanelPos::SINGLE_PANEL) {
// Centre the single external-spool card explicitly, using the same fixed-margin
// technique as the Lite-EXT branch above. Two stretch spacers centred the card
// against the wrapper's own width rather than the AMS book, which left the card
// (and the connector drawn below it) off the book/connector axis.
book_sizer->Add(ams1, 0, wxLEFT,
std::max(0, (book_panel_size.x - ams1->GetSize().x) / 2));
}
else{
auto ext_image = new AMSExtImage(book_panel, pos, m_total_ext_count, false);
book_sizer->Add(ams1, 0, wxLEFT, FromDIP(30));
@@ -1228,7 +1493,20 @@ void AMSControl::createAmsPanel(wxSimplebook *parent, int &idx, std::vector<AMSi
void AMSControl::AddAms(AMSinfo info, AMSPanelPos pos)
{
if (m_total_ext_count > 1){
// The generic multi-nozzle layout (non-Bambu vendors) owns the only visible book; a unit must
// land there for any extruder count. Keying off m_total_ext_count instead sent 1- and 2-extruder
// generic printers into the legacy simplebooks that CreateAmsMultiNozzle hides.
if (use_multi_nozzle_layout()) {
const int nozzle_id = std::clamp(info.nozzle_id, 0, m_total_ext_count - 1);
auto &pane = m_nozzle_panes[nozzle_id];
pane.item_ids.push_back(info.ams_id);
m_item_ids[nozzle_id].push_back(info.ams_id);
if (pane.current_ams.empty()) pane.current_ams = info.ams_id;
createAms(pane.ams_book, pane.page_index, info, AMSPanelPos::SINGLE_PANEL);
pane.ams_book->Fit();
pane.ams_book->Layout();
}
else if (m_total_ext_count == 2){
if (info.routes_to_main_extruder()){
createAms(m_simplebook_ams_right, m_right_page_index, info, AMSPanelPos::RIGHT_PANEL);
}
@@ -1239,6 +1517,9 @@ void AMSControl::AddAms(AMSinfo info, AMSPanelPos pos)
else if (m_total_ext_count == 1){
createAms(m_simplebook_ams_left, m_left_page_index, info, AMSPanelPos::LEFT_PANEL);
}
else {
// Generic N Nozzles
}
m_simplebook_ams_left->Layout();
m_simplebook_ams_right->Layout();
m_simplebook_ams_left->Refresh();
@@ -1269,7 +1550,19 @@ void AMSControl::AddAms(std::vector<AMSinfo> single_info, const std::string &ser
if (single_info.size() <= 0){
return;
}
if (m_total_ext_count == 2) {
if (use_multi_nozzle_layout()) {
const int nozzle_id = std::clamp(single_info.front().nozzle_id, 0, m_total_ext_count - 1);
auto &pane = m_nozzle_panes[nozzle_id];
for (const auto &info : single_info) {
pane.item_ids.push_back(info.ams_id);
m_item_ids[nozzle_id].push_back(info.ams_id);
}
if (pane.current_ams.empty()) pane.current_ams = single_info.front().ams_id;
createAmsPanel(pane.ams_book, pane.page_index, single_info, series_name, printer_type, AMSPanelPos::SINGLE_PANEL, m_total_ext_count);
pane.ams_book->Fit();
pane.ams_book->Layout();
}
else if (m_total_ext_count == 2) {
if (single_info[0].routes_to_main_extruder()) {
createAmsPanel(m_simplebook_ams_right, m_right_page_index, single_info, series_name, printer_type, AMSPanelPos::RIGHT_PANEL, m_total_ext_count);
}
@@ -1285,6 +1578,9 @@ void AMSControl::AddAms(std::vector<AMSinfo> single_info, const std::string &ser
createAmsPanel(m_simplebook_ams_left, m_left_page_index, single_info, series_name, printer_type, AMSPanelPos::LEFT_PANEL, m_total_ext_count);
}
}
else {
// Generic N Nozzles
}
m_simplebook_ams_left->Layout();
m_simplebook_ams_right->Layout();
@@ -1322,6 +1618,21 @@ void AMSControl::AddAms(std::vector<AMSinfo> single_info, const std::string &ser
void AMSControl::AddAmsPreview(std::vector<AMSinfo>single_info, AMSPanelPos pos) {
if (single_info.size() <= 0) return;
if (use_multi_nozzle_layout()) {
const int nozzle_id = std::clamp(single_info.front().nozzle_id, 0, m_total_ext_count - 1);
auto &pane = m_nozzle_panes[nozzle_id];
for (const auto &info : single_info) {
auto *ams_prv = new AMSPreview(pane.preview_panel, wxID_ANY, info, info.ams_type);
pane.preview_sizer->Add(ams_prv, 0, wxALIGN_CENTER | wxLEFT, FromDIP(6));
ams_prv->Bind(wxEVT_LEFT_DOWN, [this, ams_prv](wxMouseEvent &e) {
SwitchAms(ams_prv->get_ams_id());
e.Skip();
});
m_ams_preview_list[info.ams_id] = ams_prv;
}
return;
}
AMSPreview* ams_prv = nullptr;
AMSPreview* ams_prv2 = nullptr;
if (pos == AMSPanelPos::RIGHT_PANEL){
@@ -1360,8 +1671,97 @@ void AMSControl::AddAmsPreview(std::vector<AMSinfo>single_info, AMSPanelPos pos)
}
}
void AMSControl::SelectNozzle(int nozzle_id)
{
if (nozzle_id < 0 || nozzle_id >= static_cast<int>(m_nozzle_panes.size()))
return;
if (m_active_nozzle_id == nozzle_id &&
(!m_nozzle_book || m_nozzle_book->GetSelection() == nozzle_id))
return;
m_active_nozzle_id = nozzle_id;
if (m_nozzle_book && m_nozzle_book->GetSelection() != nozzle_id)
m_nozzle_book->SetSelection(nozzle_id);
m_current_ams = m_nozzle_panes[nozzle_id].current_ams;
if (!m_current_ams.empty()) {
SwitchAms(m_current_ams);
return;
}
if (m_down_road) {
m_down_road->UpdateLeft(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
m_down_road->UpdateRight(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
}
if (m_extruder)
m_extruder->OnAmsLoading(false, 0);
post_event(SimpleEvent(EVT_AMS_SWITCH));
}
void AMSControl::SwitchAms(std::string ams_id)
{
if (use_multi_nozzle_layout()) {
NozzleAmsPane *pane = nullptr;
int nozzle_id = -1;
for (int index = 0; index < static_cast<int>(m_nozzle_panes.size()); ++index) {
auto &candidate = m_nozzle_panes[index];
if (std::find(candidate.item_ids.begin(), candidate.item_ids.end(), ams_id) != candidate.item_ids.end()) {
pane = &candidate;
nozzle_id = index;
break;
}
}
if (!pane) return;
m_active_nozzle_id = nozzle_id;
if (m_nozzle_book && m_nozzle_book->GetSelection() != nozzle_id)
m_nozzle_book->SetSelection(nozzle_id);
pane->current_ams = ams_id;
m_current_ams = ams_id;
m_current_select = ams_id;
for (const auto &id : pane->item_ids) {
auto preview = m_ams_preview_list.find(id);
if (preview == m_ams_preview_list.end() || !preview->second) continue;
if (id == ams_id) preview->second->OnSelected();
else preview->second->UnSelected();
}
auto item = m_ams_item_list.find(ams_id);
if (item != m_ams_item_list.end() && item->second) {
if (pane->ams_book)
pane->ams_book->SetSelection(item->second->get_selection());
AMSRoadShowMode road_mode = AMSRoadShowMode::AMS_ROAD_MODE_SINGLE;
if (item->second->get_can_count() == GENERIC_AMS_SLOT_NUM)
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_FOUR;
else if (item->second->get_can_count() > GENERIC_AMS_SLOT_NUM)
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_GENERIC;
else if (item->second->get_ams_model() == AMSModel::N3S_AMS)
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_SINGLE_N3S;
else if (item->second->get_ams_model() == AMSModel::AMS_LITE ||
(item->second->get_ams_model() == AMSModel::EXT_AMS &&
item->second->get_ext_type() == AMSModelOriginType::LITE_EXT))
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_AMS_LITE;
if (m_down_road) {
if (pane->panel_pos == AMSPanelPos::LEFT_PANEL) {
m_down_road->UpdateLeft(1, road_mode);
m_down_road->UpdateRight(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
} else {
m_down_road->UpdateLeft(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
m_down_road->UpdateRight(1, road_mode);
}
m_down_road->UpdatePassRoad(pane->panel_pos, -1, AMSPassRoadSTEP::AMS_ROAD_STEP_NONE);
}
if (m_extruder)
m_extruder->OnAmsLoading(false, 0);
}
post_event(SimpleEvent(EVT_AMS_SWITCH));
return;
}
if(ams_id == m_current_show_ams_left || ams_id == m_current_show_ams_right){return;}
bool is_in_right = IsAmsInRightPanel(ams_id);
@@ -1432,13 +1832,17 @@ void AMSControl::SwitchAms(std::string ams_id)
else {
AMSRoadShowMode mode = AMSRoadShowMode::AMS_ROAD_MODE_SINGLE;
if (item->get_ams_model() == AMSModel::N3S_AMS)
if (item->get_can_count() > GENERIC_AMS_SLOT_NUM)
mode = AMSRoadShowMode::AMS_ROAD_MODE_GENERIC;
else if (item->get_ams_model() == AMSModel::N3S_AMS)
mode = AMSRoadShowMode::AMS_ROAD_MODE_SINGLE_N3S;
for (auto it : pair_id) {
if (it.first == ams_id || it.second == ams_id) {
mode = AMSRoadShowMode::AMS_ROAD_MODE_DOUBLE;
break;
if (item->get_can_count() <= GENERIC_AMS_SLOT_NUM) {
for (auto it : pair_id) {
if (it.first == ams_id || it.second == ams_id) {
mode = AMSRoadShowMode::AMS_ROAD_MODE_DOUBLE;
break;
}
}
}
pos == AMSPanelPos::LEFT_PANEL ? m_down_road->UpdateLeft(m_total_ext_count, mode)
@@ -1500,6 +1904,20 @@ void AMSControl::SetExtruder(bool on_off, int nozzle_id, std::string ams_id, std
AmsItem *item = nullptr;
if (m_ams_item_list.find(ams_id) != m_ams_item_list.end()) { item = m_ams_item_list[ams_id]; }
if (use_multi_nozzle_layout()) {
if (nozzle_id < 0 || nozzle_id >= static_cast<int>(m_nozzle_panes.size()))
return;
if (!m_extruder)
return;
if (on_off && item)
m_extruder->OnAmsLoading(true, 0, item->GetTagColr(slot_id));
else
m_extruder->OnAmsLoading(false, 0);
return;
}
if (on_off && item) {
auto col = item->GetTagColr(slot_id);
m_extruder->OnAmsLoading(true, nozzle_id, col);
@@ -1624,6 +2042,69 @@ void AMSControl::SetAmsStep(std::string ams_id, std::string canid, AMSPassRoadTy
else{
return;
}
if (use_multi_nozzle_layout()) {
const int nozzle_id = std::clamp(ams->get_nozzle_id(), 0, m_total_ext_count - 1);
if (nozzle_id >= static_cast<int>(m_nozzle_panes.size()))
return;
auto &pane = m_nozzle_panes[nozzle_id];
AMSRoadShowMode road_mode = AMSRoadShowMode::AMS_ROAD_MODE_SINGLE;
if (ams->get_can_count() == GENERIC_AMS_SLOT_NUM)
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_FOUR;
else if (ams->get_can_count() > GENERIC_AMS_SLOT_NUM)
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_GENERIC;
else if (ams->get_ams_model() == AMSModel::N3S_AMS)
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_SINGLE_N3S;
else if (ams->get_ams_model() == AMSModel::AMS_LITE ||
(ams->get_ams_model() == AMSModel::EXT_AMS && ams->get_ext_type() == AMSModelOriginType::LITE_EXT))
road_mode = AMSRoadShowMode::AMS_ROAD_MODE_AMS_LITE;
if (can_index >= 0 && can_index < static_cast<int>(info.cans.size()) && m_down_road)
m_down_road->SetPassRoadColour(pane.panel_pos == AMSPanelPos::LEFT_PANEL,
info.cans[can_index].material_colour);
if (m_down_road) {
if (pane.panel_pos == AMSPanelPos::LEFT_PANEL) {
m_down_road->UpdateLeft(1, road_mode);
m_down_road->UpdateRight(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
} else {
m_down_road->UpdateLeft(1, AMSRoadShowMode::AMS_ROAD_MODE_NONE);
m_down_road->UpdateRight(1, road_mode);
}
}
int road_length = 0;
if (step == AMSPassRoadSTEP::AMS_ROAD_STEP_NONE) {
ams->SetAmsStep(ams_id, canid, type, AMSPassRoadSTEP::AMS_ROAD_STEP_NONE);
if (m_down_road)
m_down_road->UpdatePassRoad(pane.panel_pos, -1, AMSPassRoadSTEP::AMS_ROAD_STEP_NONE);
if (m_extruder)
m_extruder->OnAmsLoading(false, 0);
}
else if (step == AMSPassRoadSTEP::AMS_ROAD_STEP_COMBO_LOAD_STEP1) {
ams->SetAmsStep(ams_id, canid, type, AMSPassRoadSTEP::AMS_ROAD_STEP_1);
if (m_down_road)
m_down_road->UpdatePassRoad(pane.panel_pos, road_length, AMSPassRoadSTEP::AMS_ROAD_STEP_1);
if (m_extruder)
m_extruder->OnAmsLoading(false, 0);
}
else if (step == AMSPassRoadSTEP::AMS_ROAD_STEP_COMBO_LOAD_STEP2) {
ams->SetAmsStep(ams_id, canid, type, AMSPassRoadSTEP::AMS_ROAD_STEP_2);
if (m_down_road)
m_down_road->UpdatePassRoad(pane.panel_pos, road_length, AMSPassRoadSTEP::AMS_ROAD_STEP_2);
if (m_extruder)
m_extruder->OnAmsLoading(true, 0, ams->GetTagColr(canid));
}
else if (step == AMSPassRoadSTEP::AMS_ROAD_STEP_COMBO_LOAD_STEP3) {
ams->SetAmsStep(ams_id, canid, type, AMSPassRoadSTEP::AMS_ROAD_STEP_3);
if (m_down_road)
m_down_road->UpdatePassRoad(pane.panel_pos, road_length, AMSPassRoadSTEP::AMS_ROAD_STEP_3);
if (m_extruder)
m_extruder->OnAmsLoading(true, 0, ams->GetTagColr(canid));
}
return;
}
if (can_index >= 0 && can_index < info.cans.size())
{
m_down_road->SetPassRoadColour(left, info.cans[can_index].material_colour);
+27
View File
@@ -47,6 +47,20 @@ public:
void on_retry();
protected:
struct NozzleAmsPane
{
wxPanel* page{nullptr};
wxBoxSizer* page_sizer{nullptr};
wxScrolledWindow* preview_panel{nullptr};
wxBoxSizer* preview_sizer{nullptr};
wxSimplebook* ams_book{nullptr};
wxBoxSizer* ams_area{nullptr};
AMSPanelPos panel_pos{AMSPanelPos::LEFT_PANEL};
std::vector<std::string> item_ids;
std::string current_ams;
int page_index{0};
};
std::string m_current_ams;
std::string m_current_slot_left;
std::string m_current_slot_right;
@@ -63,6 +77,9 @@ protected:
std::string m_dev_id;
std::vector<std::vector<std::string>> m_item_ids{ {}, {} };
std::vector<NozzleAmsPane> m_nozzle_panes;
wxSimplebook* m_nozzle_book{nullptr};
int m_active_nozzle_id{0};
std::vector<std::pair<std::string, std::string>> pair_id;
int m_total_ext_count = 1;
@@ -103,6 +120,7 @@ protected:
wxSimplebook *m_simplebook_ams_left{nullptr};
wxSimplebook *m_simplebook_ams_right{ nullptr };
wxSimplebook *m_simplebook_bottom{nullptr};
wxSizerItem *m_sizer_ams_gap{nullptr};
wxPanel *m_panel_down_road{ nullptr };
int m_left_page_index = 0;
int m_right_page_index = 0;
@@ -156,6 +174,7 @@ public:
void createAms(wxSimplebook* parent, int& idx, AMSinfo info, AMSPanelPos pos);
void createAmsPanel(wxSimplebook *parent, int &idx, std::vector<AMSinfo> infos, const std::string &series_name, const std::string &printer_type, AMSPanelPos pos, int total_ext_num);
AMSRoadShowMode findFirstMode(AMSPanelPos pos);
void restore_legacy_ams_layout();
AMSModel m_ams_model{AMSModel::EXT_AMS};
AMSModel m_ext_model{AMSModel::EXT_AMS};
@@ -183,6 +202,7 @@ public:
void UpdatePassRoad(std::string ams_id, AMSPassRoadType type, AMSPassRoadSTEP step);
void CreateAms();
void CreateAmsMultiNozzle(const std::string& series_name, const std::string& printer_type);
void CreateAmsDoubleNozzle(const std::string &series_name, const std::string& printer_type);
void CreateAmsSingleNozzle(const std::string &series_name, const std::string &printer_type);
void ClearAms();
@@ -210,6 +230,8 @@ public:
void SetExtruder(bool on_off, int nozzle_id, std::string ams_id, std::string slot_id);
void SetAmsStep(std::string ams_id, std::string canid, AMSPassRoadType type, AMSPassRoadSTEP step);
void SwitchAms(std::string ams_id);
void SelectNozzle(int nozzle_id);
int GetActiveNozzleId() const { return m_active_nozzle_id; }
void msw_rescale();
void on_filament_load(wxCommandEvent &event);
@@ -234,6 +256,11 @@ public:
private:
std::string get_filament_id(const std::string& ams_id, const std::string& can_id);
// True when AMS units are laid out on the generic multi-nozzle book. Bambu
// printers keep the legacy count-based single/double-nozzle layout at any
// extruder count; other vendors use the book created for them.
bool use_multi_nozzle_layout() const;
public:
std::string m_current_select;
};
+317 -54
View File
@@ -44,6 +44,7 @@
#include <boost/log/trivial.hpp>
#include <wx/timer.h>
#include <wx/sizer.h>
#include <wx/scrolwin.h>
#include "CalibUtils.hpp"
#include "slic3r/GUI/wxExtensions.hpp"
@@ -78,6 +79,48 @@ namespace Slic3r { namespace GUI {
//#define AMS_SINGLE_CAN_SIZE wxSize(FromDIP(78), 144)
#define AMS_CANS_WINDOW_SIZE wxSize(FromDIP(264), FromDIP(174))
#define AMS_SINGLE_CAN_SIZE wxSize(FromDIP(78), FromDIP(174))
int generic_ams_layout_width(wxWindow *window, int lane_count)
{
const int base_width = window->FromDIP(264);
const int lane_width = window->FromDIP(52);
const int lane_gap = window->FromDIP(4);
if (lane_count <= 4)
return base_width;
// Lane centers are distributed as width * (index + 1) / (count + 1).
// Keep at least one card width plus the requested gap between centers.
return std::max(base_width, (lane_count + 1) * (lane_width + lane_gap));
}
std::vector<int> generic_ams_lane_centers(int lane_count, int width)
{
std::vector<int> centers;
if (lane_count <= 0)
return centers;
centers.reserve(lane_count);
for (int lane = 0; lane < lane_count; ++lane)
centers.push_back(width * (lane + 1) / (lane_count + 1));
return centers;
}
int generic_ams_preview_width(wxWindow *window, int lane_count)
{
const int base_width = window->FromDIP(52);
const int lane_width = window->FromDIP(9);
const int lane_gap = window->FromDIP(4);
if (lane_count <= 0)
return base_width;
return std::max(base_width, lane_count * lane_width + (lane_count + 1) * lane_gap);
}
bool use_generic_ams_layout(AMSModel model)
{
return model == AMSModel::GENERIC_AMS &&
(!wxGetApp().preset_bundle || !wxGetApp().preset_bundle->is_bbl_vendor());
}
bool AMSinfo::parse_ams_info(MachineObject *obj, DevAms *ams, bool remain_flag, bool humidity_flag)
{
if (!ams) return false;
@@ -172,10 +215,14 @@ void AMSinfo::parse_ext_info(MachineObject* obj, DevAmsTray tray) {
info.can_id = std::to_string(0);
this->cans.clear();
if (tray.id == std::to_string(VIRTUAL_TRAY_MAIN_ID))
try {
const int virtual_tray_id = std::stoi(tray.id);
if (virtual_tray_id >= 0 && virtual_tray_id <= VIRTUAL_TRAY_MAIN_ID)
this->nozzle_id = VIRTUAL_TRAY_MAIN_ID - virtual_tray_id;
}
catch (...) {
this->nozzle_id = 0;
else if (tray.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID))
this->nozzle_id = 1;
}
if (tray.is_tray_info_ready()) {
info.ctype = tray.ctype;
@@ -858,7 +905,11 @@ AMSextruder::AMSextruder(wxWindow *parent, wxWindowID id, int nozzle_num, const
void AMSextruder::TurnOff()
{
m_left_extruder->TurnOff();
if (m_left_extruder) m_left_extruder->TurnOff();
if (m_right_extruder) m_right_extruder->TurnOff();
for (auto *extruder : m_nozzle_extruders) {
if (extruder) extruder->TurnOff();
}
}
void AMSextruder::create(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, int nozzle_num)
@@ -884,7 +935,7 @@ void AMSextruder::OnAmsLoading(bool load, int nozzle_id, wxColour col /*= AMS_CO
m_left_extruder->OnAmsLoading(load, col);
if (load) m_current_colur_deputy = col;
}
else if (m_nozzle_num > 1){
else if (m_nozzle_num == 2){
if (nozzle_id == MAIN_EXTRUDER_ID) {
m_right_extruder->OnAmsLoading(load, col);
if (m_current_colur != col){
@@ -898,6 +949,15 @@ void AMSextruder::OnAmsLoading(bool load, int nozzle_id, wxColour col /*= AMS_CO
}
}
}
else if (m_nozzle_num > 2) {
if (nozzle_id >= 0 && nozzle_id < static_cast<int>(m_nozzle_extruders.size())) {
m_nozzle_extruders[nozzle_id]->OnAmsLoading(load, col);
if (load) m_current_colur = col;
}
}
else {
// Generic N Nozzles
}
}
/*return true if something is updated*/
@@ -907,12 +967,26 @@ bool AMSextruder::updateNozzleNum(int nozzle_num, const std::string& series_name
m_series_name = series_name;
m_nozzle_num = nozzle_num;
this->DestroyChildren();
m_left_extruder = nullptr;
m_right_extruder = nullptr;
m_nozzle_extruders.clear();
m_right_extruder = new AMSextruderImage(this, wxID_ANY, "right_nozzle", AMS_EXTRUDER_DOUBLE_NOZZLE_BITMAP_SIZE);
m_bitmap_sizer = new wxBoxSizer(wxHORIZONTAL);
if (m_nozzle_num == 1) {
if (MachineObject::is_series_n(m_series_name)) {
m_left_extruder = new AMSextruderImage(this, wxID_ANY, "single_nozzle_n", AMS_EXTRUDER_SINGLE_NOZZLE_N_SIZE);
} else if (MachineObject::is_series_x(m_series_name) || MachineObject::is_series_p(m_series_name)) {
m_left_extruder = new AMSextruderImage(this, wxID_ANY, "single_nozzle_xp", AMS_EXTRUDER_SINGLE_NOZZLE_XP_SIZE);
} else {
m_left_extruder = new AMSextruderImage(this, wxID_ANY, "single_nozzle_xp", AMS_EXTRUDER_SINGLE_NOZZLE_XP_SIZE);
}
wxBoxSizer* m_bitmap_sizer = new wxBoxSizer(wxHORIZONTAL);
if (m_nozzle_num >= 2)
m_left_extruder->setShowState(true);
m_bitmap_sizer->Add(m_left_extruder, 0, wxALIGN_LEFT | wxALIGN_TOP, 0);
}
else if (m_nozzle_num == 2)
{
m_right_extruder = new AMSextruderImage(this, wxID_ANY, "right_nozzle", AMS_EXTRUDER_DOUBLE_NOZZLE_BITMAP_SIZE);
m_left_extruder = new AMSextruderImage(this, wxID_ANY, "left_nozzle", AMS_EXTRUDER_DOUBLE_NOZZLE_BITMAP_SIZE);
m_left_extruder->setShowState(true);
m_right_extruder->setShowState(true);
@@ -920,25 +994,17 @@ bool AMSextruder::updateNozzleNum(int nozzle_num, const std::string& series_name
m_bitmap_sizer->Add(m_right_extruder, 0, wxLEFT | wxALIGN_TOP, FromDIP(2));
m_bitmap_sizer->AddSpacer(2);
}
else if (m_nozzle_num > 2) {
for (int nozzle_id = 0; nozzle_id < m_nozzle_num; ++nozzle_id) {
auto *extruder = new AMSextruderImage(this, wxID_ANY, "single_nozzle_n", AMS_EXTRUDER_SINGLE_NOZZLE_N_SIZE);
extruder->setShowState(true);
m_nozzle_extruders.push_back(extruder);
m_bitmap_sizer->Add(extruder, 0, wxALIGN_LEFT | wxALIGN_TOP, 0);
}
}
else
{
if (MachineObject::is_series_n(m_series_name))
{
m_left_extruder = new AMSextruderImage(this, wxID_ANY, "single_nozzle_n", AMS_EXTRUDER_SINGLE_NOZZLE_N_SIZE);
}
else if(MachineObject::is_series_x(m_series_name) || MachineObject::is_series_p(m_series_name))
{
m_left_extruder = new AMSextruderImage(this, wxID_ANY, "single_nozzle_xp", AMS_EXTRUDER_SINGLE_NOZZLE_XP_SIZE);
}
else
{
m_left_extruder = new AMSextruderImage(this, wxID_ANY, "single_nozzle_xp", AMS_EXTRUDER_SINGLE_NOZZLE_XP_SIZE);
}
m_left_extruder->setShowState(true);
m_right_extruder->setShowState(false);
m_bitmap_sizer->Add(m_left_extruder, 0, wxALIGN_LEFT | wxALIGN_TOP, 0);
m_bitmap_sizer->Add(m_right_extruder, 0, wxLEFT | wxALIGN_TOP, FromDIP(3));
// Generic N Nozzles
}
SetSizer(m_bitmap_sizer);
@@ -953,6 +1019,9 @@ void AMSextruder::msw_rescale()
//m_amsSextruder->msw_rescale();
if (m_left_extruder) m_left_extruder->msw_rescale();
if (m_right_extruder) m_right_extruder->msw_rescale();
for (auto *extruder : m_nozzle_extruders) {
if (extruder) extruder->msw_rescale();
}
Layout();
Update();
Refresh();
@@ -2140,7 +2209,10 @@ AMSRoadUpPart::AMSRoadUpPart(wxWindow* parent, wxWindowID id, AMSinfo info, AMSM
m_ams_model = model;
if (m_ams_model == AMSModel::GENERIC_AMS){
create(parent, id, pos, wxSize(FromDIP(264), FromDIP(34)));
const int width = use_generic_ams_layout(m_ams_model)
? generic_ams_layout_width(this, static_cast<int>(m_amsinfo.cans.size()))
: FromDIP(264);
create(parent, id, pos, wxSize(width, FromDIP(34)));
}
else{
create(parent, id, pos, wxSize(FromDIP(78), FromDIP(34)));
@@ -2252,6 +2324,38 @@ void AMSRoadUpPart::doRender(wxDC& dc)
dc.SetPen(wxPen(AMS_CONTROL_GRAY500, 2, wxPENSTYLE_SOLID));
dc.SetBrush(wxBrush(*wxTRANSPARENT_BRUSH));
if (use_generic_ams_layout(m_ams_model)) {
const int lane_count = static_cast<int>(m_amsinfo.cans.size());
if (lane_count <= 0)
return;
const auto lane_centers = generic_ams_lane_centers(lane_count, size.x);
const int height = FromDIP(21);
for (const int lane_center : lane_centers)
dc.DrawLine(lane_center, 0, lane_center, height);
if (lane_centers.size() > 1)
dc.DrawLine(lane_centers.front(), height, lane_centers.back(), height);
dc.DrawLine(size.x / 2, height, size.x / 2, size.y);
if (m_load_step != AMSPassRoadSTEP::AMS_ROAD_STEP_NONE &&
m_load_slot_index >= 0 && m_load_slot_index < lane_count) {
const int lane_center = lane_centers[m_load_slot_index];
dc.SetPen(wxPen(_get_diff_clr(this, m_amsinfo.cans[m_load_slot_index].material_colour), 4, wxPENSTYLE_SOLID));
dc.DrawLine(lane_center, 0, lane_center, height);
dc.DrawLine(std::min(lane_center, size.x / 2), height,
std::max(lane_center, size.x / 2), height);
if (m_load_step == AMSPassRoadSTEP::AMS_ROAD_STEP_2 ||
m_load_step == AMSPassRoadSTEP::AMS_ROAD_STEP_3)
dc.DrawLine(size.x / 2, height, size.x / 2, size.y);
}
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(wxColour(194, 194, 194)));
dc.DrawRectangle(size.x / 2 - FromDIP(14), height - FromDIP(5), FromDIP(28), FromDIP(10));
return;
}
if ((m_ams_model == N3S_AMS || m_ams_model == EXT_AMS) && m_amsinfo.cans.size() != 4){
dc.DrawLine(((float)size.x / 2), (0), ((float)size.x / 2), (size.y));
if (m_load_step == AMSPassRoadSTEP::AMS_ROAD_STEP_2 || m_load_step == AMSPassRoadSTEP::AMS_ROAD_STEP_3){
@@ -2377,6 +2481,25 @@ void AMSRoadDownPart::UpdateRight(int nozzle_num, AMSRoadShowMode mode)
Refresh();
}
void AMSRoadDownPart::SetNozzleCount(int nozzle_num)
{
const int new_nozzle_num = std::max(1, nozzle_num);
if (m_nozzle_num == new_nozzle_num) return;
m_nozzle_num = new_nozzle_num;
Refresh();
}
void AMSRoadDownPart::SetSingleSideLayout(bool enabled, AMSPanelPos pos)
{
if (m_single_side_layout == enabled && m_single_side_pos == pos)
return;
m_single_side_layout = enabled;
m_single_side_pos = pos;
Refresh();
}
void AMSRoadDownPart::OnVamsLoading(bool load, wxColour col /*= AMS_CONTROL_GRAY500*/)
{
/*m_vams_loading = load;
@@ -2450,8 +2573,14 @@ void AMSRoadDownPart::doRender(wxDC& dc)
/*if (m_road_color.Alpha() == 0) { dc.SetPen(wxPen(*wxWHITE, m_passroad_width, wxPENSTYLE_SOLID)); }
else { dc.SetPen(wxPen(m_road_color, m_passroad_width, wxPENSTYLE_SOLID)); }*/
dc.SetPen(wxPen(AMS_CONTROL_GRAY500, 2, wxPENSTYLE_SOLID));
auto xpos = left_nozzle_pos.x;
if (m_left_rode_mode == AMSRoadShowMode::AMS_ROAD_MODE_NONE || m_right_rode_mode == AMSRoadShowMode::AMS_ROAD_MODE_NONE){
if (m_single_side_layout) {
dc.DrawLine(size.x / 2, 0, size.x / 2, size.y);
}
else if (
(m_left_rode_mode == AMSRoadShowMode::AMS_ROAD_MODE_NONE ||
m_right_rode_mode == AMSRoadShowMode::AMS_ROAD_MODE_NONE)){
auto length = 50;
if (m_left_rode_mode == AMSRoadShowMode::AMS_ROAD_MODE_AMS_LITE || m_right_rode_mode == AMSRoadShowMode::AMS_ROAD_MODE_AMS_LITE)
length = -13;
@@ -2482,6 +2611,9 @@ void AMSRoadDownPart::doRender(wxDC& dc)
case AMSRoadShowMode::AMS_ROAD_MODE_AMS_LITE:
dc.DrawLine(left_nozzle_pos.x, 0, left_nozzle_pos.x, size.y);
break;
case AMSRoadShowMode::AMS_ROAD_MODE_GENERIC:
// Generic N-lane geometry is intentionally left unimplemented.
break;
default:
break;
}
@@ -2509,23 +2641,41 @@ void AMSRoadDownPart::doRender(wxDC& dc)
dc.DrawLine(left_nozzle_pos.x, (size.y / 2), left_nozzle_pos.x + FromDIP(145), (size.y / 2));
dc.DrawLine(left_nozzle_pos.x + FromDIP(145), 0, left_nozzle_pos.x + FromDIP(145), (size.y / 2));
break;
case AMSRoadShowMode::AMS_ROAD_MODE_GENERIC:
// Generic N-lane geometry is intentionally left unimplemented.
break;
default:
break;
}
}
if (m_single_side_layout) {
if (m_pass_road_left_step == AMSPassRoadSTEP::AMS_ROAD_STEP_2 ||
m_pass_road_left_step == AMSPassRoadSTEP::AMS_ROAD_STEP_3) {
dc.SetPen(wxPen(_get_diff_clr(this, m_road_color[1]), 4, wxPENSTYLE_SOLID));
dc.DrawLine(size.x / 2, 0, size.x / 2, size.y);
}
return;
}
if (m_right_rode_mode != AMSRoadShowMode::AMS_ROAD_MODE_AMS_LITE){
if (m_nozzle_num == 2) {
if (m_nozzle_num == 1) {
if (m_right_rode_mode != AMSRoadShowMode::AMS_ROAD_MODE_NONE && m_left_rode_mode != AMSRoadShowMode::AMS_ROAD_MODE_NONE) {
dc.DrawLine((left_nozzle_pos.x), (size.y / 2), (right_nozzle_pos.x), (size.y / 2));
}
const auto &nozzle_pos = m_single_side_layout && m_single_side_pos == AMSPanelPos::RIGHT_PANEL
? right_nozzle_pos
: left_nozzle_pos;
dc.DrawLine(nozzle_pos.x, (size.y / 2), nozzle_pos.x, (size.y));
}
else if (m_nozzle_num == 2) {
/*dc.DrawLine(FromDIP(left_nozzle_pos.x), FromDIP(size.y / 2), FromDIP(left_nozzle_pos.x), FromDIP(size.y));
dc.DrawLine(FromDIP(right_nozzle_pos.x), FromDIP(size.y / 2), FromDIP(right_nozzle_pos.x), FromDIP(size.y));*/
dc.DrawLine((left_nozzle_pos.x), (size.y / 2), (left_nozzle_pos.x), (size.y));
dc.DrawLine((right_nozzle_pos.x), (size.y / 2), (right_nozzle_pos.x), (size.y));
}
else {
if (m_right_rode_mode != AMSRoadShowMode::AMS_ROAD_MODE_NONE && m_left_rode_mode != AMSRoadShowMode::AMS_ROAD_MODE_NONE) {
dc.DrawLine((left_nozzle_pos.x), (size.y / 2), (right_nozzle_pos.x), (size.y / 2));
}
dc.DrawLine((left_nozzle_pos.x), (size.y / 2), (left_nozzle_pos.x), (size.y));
// Generic N Nozzles
}
}
@@ -2550,10 +2700,16 @@ void AMSRoadDownPart::doRender(wxDC& dc)
dc.DrawLine(xpos, size.y / 2, xpos, size.y);*/
int x = left_nozzle_pos.x;
int len = m_right_road_length;
if (m_nozzle_num == 2) {
if (m_nozzle_num == 1) {
// Keep the single-nozzle path anchored at the left nozzle.
}
else if (m_nozzle_num == 2) {
x = right_nozzle_pos.x;
len = len - 14;
}
else {
// Generic N Nozzles
}
dc.DrawLine(((x)), (size.y / 2), x + FromDIP(len), (size.y / 2));
dc.DrawLine(x + FromDIP(len), (0), x + FromDIP(len), (size.y / 2));
dc.DrawLine((x), (size.y / 2), (x), (size.y));
@@ -2561,10 +2717,16 @@ void AMSRoadDownPart::doRender(wxDC& dc)
else{
int x = left_nozzle_pos.x;
int len = m_right_road_length;
if (m_nozzle_num == 2) {
if (m_nozzle_num == 1) {
// Keep the single-nozzle path anchored at the left nozzle.
}
else if (m_nozzle_num == 2) {
x = right_nozzle_pos.x;
len = len - 14;
}
else {
// Generic N Nozzles
}
dc.DrawLine(((x)), (size.y / 2), x + FromDIP(len), (size.y / 2));
dc.DrawLine(x + FromDIP(len), (0), x + FromDIP(len), (size.y / 2));
dc.DrawLine((x), (size.y / 2), (x), (size.y));
@@ -2588,7 +2750,7 @@ void AMSRoadDownPart::doRender(wxDC& dc)
}
void AMSRoadDownPart::UpdatePassRoad(AMSPanelPos pos, int len, AMSPassRoadSTEP step) {
if (m_nozzle_num >= 2){
if (m_nozzle_num == 2){
if (pos == AMSPanelPos::LEFT_PANEL){
if (m_left_road_length == len && m_pass_road_left_step == step){ return; }
m_left_road_length = len;;
@@ -2600,7 +2762,7 @@ void AMSRoadDownPart::UpdatePassRoad(AMSPanelPos pos, int len, AMSPassRoadSTEP s
m_pass_road_right_step = step;
}
}
else{
else if (m_nozzle_num == 1){
if (pos == AMSPanelPos::LEFT_PANEL) {
if (m_left_road_length == len && m_pass_road_left_step == step && m_right_road_length == -1) { return; }
m_left_road_length = len;
@@ -2614,6 +2776,9 @@ void AMSRoadDownPart::UpdatePassRoad(AMSPanelPos pos, int len, AMSPassRoadSTEP s
m_pass_road_right_step = step;
}
}
else {
// Generic N Nozzles
}
Refresh();
}
@@ -2632,7 +2797,10 @@ AMSPreview::AMSPreview(wxWindow* parent, wxWindowID id, AMSinfo amsinfo, AMSMode
{
wxWindow::Create(parent, id, pos);
if (itemType == AMSModel::GENERIC_AMS || itemType == AMSModel::AMS_LITE || itemType == AMSModel::N3F_AMS) {
create(parent, id, pos, AMS_PREV_FOUR_SIZE);
if (itemType == AMSModel::GENERIC_AMS && use_generic_ams_layout(itemType))
create(parent, id, pos, wxSize(generic_ams_preview_width(this, static_cast<int>(amsinfo.cans.size())), AMS_PREV_FOUR_SIZE.y));
else
create(parent, id, pos, AMS_PREV_FOUR_SIZE);
}
else {
create(parent, id, pos, AMS_PREV_SINGLE_SIZE);
@@ -2667,7 +2835,13 @@ void AMSPreview::UpdateInfo(AMSinfo amsinfo)
m_amsinfo = amsinfo;
m_ams_item_type = amsinfo.ams_type;
if (m_ams_item_type == AMSModel::GENERIC_AMS || m_ams_item_type == AMSModel::AMS_LITE || m_ams_item_type == AMSModel::N3F_AMS) {
if (m_ams_item_type == AMSModel::GENERIC_AMS && use_generic_ams_layout(m_ams_item_type)) {
const wxSize generic_size(generic_ams_preview_width(this, static_cast<int>(m_amsinfo.cans.size())), AMS_PREV_FOUR_SIZE.y);
SetSize(generic_size);
SetMinSize(generic_size);
SetMaxSize(generic_size);
}
else if (m_ams_item_type == AMSModel::GENERIC_AMS || m_ams_item_type == AMSModel::AMS_LITE || m_ams_item_type == AMSModel::N3F_AMS) {
SetMinSize(AMS_PREV_FOUR_SIZE);
SetMaxSize(AMS_PREV_FOUR_SIZE);
} else {
@@ -2775,8 +2949,41 @@ void AMSPreview::doRender(wxDC &dc)
dc.DrawRoundedRectangle(0, 0, size.x, size.y, FromDIP(3));
auto left = 0;
//four slot
if (m_ams_item_type != AMSModel::EXT_AMS && m_ams_item_type != AMSModel::N3S_AMS){
// Generic AMS previews use the same normalized lane centers as the card row and connector.
if (m_ams_item_type == AMSModel::GENERIC_AMS && use_generic_ams_layout(m_ams_item_type)) {
const int lane_count = static_cast<int>(m_amsinfo.cans.size());
const auto lane_centers = generic_ams_lane_centers(lane_count, size.x);
const int cube_width = FromDIP(9);
const int cube_height = FromDIP(14);
for (int lane = 0; lane < lane_count; ++lane) {
const Caninfo &can = m_amsinfo.cans[lane];
const int cube_left = lane_centers[lane] - cube_width / 2;
const int cube_top = (size.y - cube_height) / 2;
wxRect rect(cube_left, cube_top, cube_width, cube_height);
if (can.material_colour.Alpha() == 0) {
if (wxGetApp().dark_mode())
dc.DrawBitmap(m_ts_bitmap_cube_dark.bmp(), cube_left - FromDIP(1), (size.y - m_ts_bitmap_cube_dark.GetBmpHeight()) / 2);
else
dc.DrawBitmap(m_ts_bitmap_cube.bmp(), cube_left - FromDIP(1), (size.y - m_ts_bitmap_cube.GetBmpHeight()) / 2);
}
else if (can.material_state == AMSCanType::AMS_CAN_TYPE_EMPTY) {
dc.SetPen(wxPen(wxColor(0, 0, 0)));
dc.DrawLine(rect.GetRight() - FromDIP(1), rect.GetTop() + FromDIP(1),
rect.GetLeft() + FromDIP(1), rect.GetBottom() - FromDIP(1));
}
else {
wxColour color = can.material_colour;
change_the_opacity(color);
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
dc.SetBrush(wxBrush(color));
dc.DrawRoundedRectangle(rect, 2);
}
}
}
// four slot
else if (m_ams_item_type != AMSModel::EXT_AMS && m_ams_item_type != AMSModel::N3S_AMS){
left = FromDIP(8);
for (std::vector<Caninfo>::iterator iter = m_amsinfo.cans.begin(); iter != m_amsinfo.cans.end(); iter++) {
@@ -3183,10 +3390,19 @@ AmsItem::AmsItem(wxWindow *parent,AMSinfo info, AMSModel model, AMSPanelPos pos
m_panel_pos = pos;
if (m_ams_model == AMSModel::GENERIC_AMS){
wxWindow::Create(parent, wxID_ANY, wxDefaultPosition, AMS_CANS_WINDOW_SIZE);
SetSize(AMS_CANS_WINDOW_SIZE);
SetMinSize(AMS_CANS_WINDOW_SIZE);
SetMaxSize(AMS_CANS_WINDOW_SIZE);
const int lane_count = static_cast<int>(m_info.cans.size());
const int width = use_generic_ams_layout(m_ams_model) && lane_count <= 8
? generic_ams_layout_width(this, lane_count)
: FromDIP(264);
const wxSize generic_size(width, FromDIP(174));
wxWindow::Create(parent, wxID_ANY, wxDefaultPosition, generic_size);
SetSize(generic_size);
SetMinSize(generic_size);
// For large lane counts the card is a viewport, not the lane strip. Let
// its parent allocate the available width; the scrolled child retains
// the wider virtual content size.
if (!(use_generic_ams_layout(m_ams_model) && lane_count > 8))
SetMaxSize(generic_size);
}
else{
wxWindow::Create(parent, wxID_ANY, wxDefaultPosition, AMS_SINGLE_CAN_SIZE);
@@ -3212,12 +3428,43 @@ void AmsItem::create(wxWindow *parent)
if (m_ams_model != AMSModel::AMS_LITE) {
sizer_can = new wxBoxSizer(wxHORIZONTAL);
sizer_item = new wxBoxSizer(wxVERTICAL);
auto it = m_info.cans.begin();
for (; it != m_info.cans.end(); it++) {
AddCan(*it, m_can_count, m_info.cans.size(), sizer_can);
const bool generic_layout = use_generic_ams_layout(m_ams_model);
const int lane_count = static_cast<int>(m_info.cans.size());
const int content_width = generic_layout ? generic_ams_layout_width(this, lane_count) : 0;
const bool scroll_lanes = generic_layout && lane_count > 8;
const auto lane_centers = generic_layout
? generic_ams_lane_centers(lane_count, content_width)
: std::vector<int>();
m_can_parent = this;
if (scroll_lanes) {
m_can_scroll = new wxScrolledWindow(this, wxID_ANY);
m_can_scroll->SetScrollRate(FromDIP(10), 0);
m_can_scroll->SetMinSize(wxSize(FromDIP(264), FromDIP(143)));
m_can_scroll->SetBackgroundColour(StateColor::darkModeColorFor(AMS_CONTROL_DEF_LIB_BK_COLOUR));
m_can_scroll_sizer = new wxBoxSizer(wxVERTICAL);
m_can_scroll->SetSizer(m_can_scroll_sizer);
m_can_parent = m_can_scroll;
}
int previous_right = 0;
for (int can_index = 0; can_index < lane_count; ++can_index) {
if (generic_layout) {
const int left = lane_centers[can_index] - FromDIP(26);
sizer_can->AddSpacer(std::max(0, left - previous_right));
AddCan(m_info.cans[can_index], m_can_count, m_info.cans.size(), sizer_can);
previous_right = left + FromDIP(52);
}
else {
AddCan(m_info.cans[can_index], m_can_count, m_info.cans.size(), sizer_can);
}
m_can_count++;
}
it = m_info.cans.begin();
if (generic_layout)
sizer_can->AddSpacer(std::max(0, content_width - previous_right));
if (generic_layout)
sizer_can->SetMinSize(wxSize(content_width, -1));
//auto road_panel = new wxWindow(this, wxID_ANY);
//auto road_panel = new wxPanel(this, wxID_ANY);
//road_panel->SetSize(AMS_CAN_ROAD_SIZE);
@@ -3232,11 +3479,18 @@ void AmsItem::create(wxWindow *parent)
sizer_item->Add(m_ext_image, 0, wxALIGN_CENTER, 0);
}
}
m_panel_road = new AMSRoadUpPart(this, wxID_ANY, m_info, m_ams_model);
m_panel_road = new AMSRoadUpPart(m_can_parent, wxID_ANY, m_info, m_ams_model);
sizer_item->Add(sizer_can, 0, wxALIGN_CENTER_HORIZONTAL, 0);
//sizer_item->Add(m_panel_road, 0, wxALIGN_CENTER_HORIZONTAL, 0);
sizer_item->Add(m_panel_road, 1, wxEXPAND);
if (scroll_lanes) {
m_can_scroll_sizer->Add(sizer_can, 0, wxALIGN_LEFT, 0);
m_can_scroll_sizer->Add(m_panel_road, 0, wxALIGN_LEFT, 0);
sizer_item->Add(m_can_scroll, 1, wxEXPAND);
}
else {
sizer_item->Add(sizer_can, 0, wxALIGN_CENTER_HORIZONTAL, 0);
//sizer_item->Add(m_panel_road, 0, wxALIGN_CENTER_HORIZONTAL, 0);
sizer_item->Add(m_panel_road, 1, wxEXPAND);
}
SetSizer(sizer_item);
}
@@ -3260,13 +3514,17 @@ void AmsItem::create(wxWindow *parent)
Layout();
Fit();
if (m_can_scroll) {
m_can_scroll->Layout();
m_can_scroll->FitInside();
}
Thaw();
//Refresh();
}
void AmsItem::AddCan(Caninfo caninfo, int canindex, int maxcan, wxBoxSizer* sizer)
{
auto amscan = new wxWindow(this, wxID_ANY);
auto amscan = new wxWindow(m_can_parent ? m_can_parent : this, wxID_ANY);
amscan->SetSize(wxSize(FromDIP(52), FromDIP(109)));
amscan->SetMinSize(wxSize(FromDIP(52), FromDIP(109)));
@@ -3347,7 +3605,8 @@ void AmsItem::AddCan(Caninfo caninfo, int canindex, int maxcan, wxBoxSizer* size
amscan->Layout();
amscan->Fit();
sizer->Add(amscan, 0, wxUP | wxLEFT | wxRIGHT, FromDIP(5));
const int margin = use_generic_ams_layout(m_ams_model) ? 0 : FromDIP(5);
sizer->Add(amscan, 0, wxUP | wxLEFT | wxRIGHT, margin);
m_can_lib_list[caninfo.can_id] = m_panel_lib;
//m_can_road_list[caninfo.can_id] = m_panel_road;
@@ -3472,6 +3731,10 @@ void AmsItem::UpdateInfo(AMSinfo info)
}
Layout();
if (m_can_scroll) {
m_can_scroll->Layout();
m_can_scroll->FitInside();
}
}
void AmsItem::SetDefSelectCan()
+10
View File
@@ -30,6 +30,7 @@
#include <wx/string.h>
#include <wx/timer.h>
#include <wx/sizer.h>
#include <wx/scrolwin.h>
#include "slic3r/GUI/DeviceCore/DevFilaSwitch.h" // Orca: DevFilaSwitch::SwitchPos for inlet-aware AMS placement
@@ -96,6 +97,7 @@ enum class AMSRoadShowMode : int {
AMS_ROAD_MODE_SINGLE,
AMS_ROAD_MODE_SINGLE_N3S,
AMS_ROAD_MODE_AMS_LITE,
AMS_ROAD_MODE_GENERIC,
AMS_ROAD_MODE_NONE
};
@@ -480,6 +482,7 @@ public:
//AMSextruderImage *m_amsSextruder{nullptr};
AMSextruderImage* m_left_extruder = nullptr;
AMSextruderImage* m_right_extruder = nullptr;
std::vector<AMSextruderImage*> m_nozzle_extruders;
AMSextruder(wxWindow *parent, wxWindowID id, int nozzle_num, const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize);
~AMSextruder();
@@ -681,6 +684,8 @@ public:
// void Update(AMSRoadDownPartMode nozzle, AMSRoadShowMode left_mode, AMSRoadShowMode right_mode, int left_len, int right_len);
void UpdateLeft(int nozzle_num, AMSRoadShowMode mode);
void UpdateRight(int nozzle_num, AMSRoadShowMode mode);
void SetNozzleCount(int nozzle_num);
void SetSingleSideLayout(bool enabled, AMSPanelPos pos = AMSPanelPos::LEFT_PANEL);
void OnVamsLoading(bool load, wxColour col = AMS_CONTROL_GRAY500);
void SetPassRoadColour(bool left, wxColour col);
@@ -698,6 +703,8 @@ private:
AMSRoadShowMode m_left_rode_mode = {AMSRoadShowMode::AMS_ROAD_MODE_FOUR};
AMSRoadShowMode m_right_rode_mode = {AMSRoadShowMode::AMS_ROAD_MODE_FOUR};
bool m_selected = {false};
bool m_single_side_layout = {false};
AMSPanelPos m_single_side_pos = {AMSPanelPos::LEFT_PANEL};
int m_left_road_length = {-1};
int m_right_road_length = {-1};
@@ -864,6 +871,9 @@ private:
std::map<std::string, AMSLib*> m_can_lib_list;
//std::map<std::string, AMSRoad*> m_can_road_list;
wxScrolledWindow* m_can_scroll = { nullptr };
wxBoxSizer* m_can_scroll_sizer = { nullptr };
wxWindow* m_can_parent = { nullptr };
AMSRoadUpPart* m_panel_road = { nullptr };
std::map<std::string, AMSrefresh*> m_can_refresh_list;
AMSHumidity* m_humidity = { nullptr };
+18 -3
View File
@@ -420,6 +420,14 @@ ExtruderBadge::ExtruderBadge(wxWindow* parent) : wxPanel(parent)
void ExtruderBadge::SetExtruderInfo(int extruder_id, const std::string& diameter, const NozzleVolumeType& volume_type)
{
// The badge renders exactly two extruders (left/right). A generic non-BBL printer with
// N > 2 can report an out-of-range id here (see MultiNozzleStatusTable::UpdateRackInfo);
// reject it rather than writing past the two-element lists.
if (extruder_id < 0 || extruder_id >= static_cast<int>(m_diameter_list.size())) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": out-of-range extruder_id " << extruder_id;
return;
}
m_diameter_list[extruder_id] = diameter;
m_volume_type_list[extruder_id] = volume_type;
@@ -867,14 +875,21 @@ void MultiNozzleStatusTable::UpdateRackInfo(std::weak_ptr<DevNozzleRack> rack)
bool has_right = false;
for (auto& elem : nozzles_in_extruder) {
auto& nozzle = elem.second;
int extruder_id = nozzle.AtLeftExtruder() ? 0 : 1;
if (nozzle.AtRightExtruder())
has_right = true;
int extruder_id{};
if (wxGetApp().preset_bundle && wxGetApp().preset_bundle->is_bbl_vendor()) {
extruder_id = nozzle.AtLeftExtruder() ? 0 : 1;
if (nozzle.AtRightExtruder())
has_right = true;
}
else
extruder_id = nozzle.GetExtruderId();
NozzleVolumeType volume_type = DevNozzle::ToNozzleVolumeType(nozzle.m_nozzle_flow);
m_badge->SetExtruderInfo(extruder_id, format_diameter_to_str(nozzle.GetNozzleDiameter()), volume_type);
}
// TODO: Update for N extruders
m_badge->SetExtruderValid(has_right);
}
}
+58 -37
View File
@@ -8,6 +8,7 @@
#include <wx/anybutton.h>
#include <wx/checklst.h>
#include "slic3r/GUI/Widgets/StateHandler.hpp"
#include <algorithm>
#include <wx/dcclient.h>
#include <wx/dc.h>
#include <wx/dcgraph.h>
@@ -184,6 +185,9 @@ void TempInput::SetFinish()
wxCommandEvent event(wxCUSTOMEVT_SET_TEMP_FINISH);
event.SetInt(temp_type);
event.SetString(wxString::Format("%d", m_input_type));
// N-extruder temp controls all share TEMP_OF_NORMAL_TYPE, so the string payload above can't
// tell them apart; carry widget identity so the listener can find which one fired.
event.SetEventObject(this);
wxPostEvent(this->GetParent(), event);
}
@@ -242,6 +246,37 @@ void TempInput::SetCurrType(TempInputType type) {
m_input_type = type;
}
wxString TempInput::badge_text() const
{
if (m_input_type == TEMP_OF_MAIN_NOZZLE_TYPE)
return "R";
if (m_input_type == TEMP_OF_DEPUTY_NOZZLE_TYPE)
return "L";
return m_display_id_text;
}
int TempInput::badge_width(wxDC &dc) const
{
const wxString text = badge_text();
if (text.IsEmpty())
return 0;
// The explicit Tn id sits in the same compact control as the L/R badge, so it uses the smaller
// body font. Keep this formula identical to render() so measurement and painting cannot drift.
dc.SetFont(has_tool_id_badge() ? ::Label::Body_10 : ::Label::Body_12);
const wxSize text_size = dc.GetMultiLineTextExtent(text);
const wxSize icon_size = round_scale_hint_icon.GetBmpSize();
return std::max(icon_size.x, text_size.x + FromDIP(2));
}
void TempInput::SetDisplayIdText(const wxString &text)
{
if (m_display_id_text == text)
return;
m_display_id_text = text;
messureMiniSize();
Refresh();
}
void TempInput::Warning(bool warn, WarningType type)
{
warning_mode = warn;
@@ -389,10 +424,8 @@ void TempInput::DoSetSize(int x, int y, int width, int height, int sizeFlags)
// interval
left += 9;
if (m_input_type == TEMP_OF_MAIN_NOZZLE_TYPE || m_input_type == TEMP_OF_DEPUTY_NOZZLE_TYPE) {
wxSize szIcon = round_scale_hint_icon.GetBmpSize();
left += szIcon.x + 3;
}
if (const int badge = badge_width(dc); badge > 0)
left += badge + 3;
// label
dc.SetFont(::Label::Head_14);
@@ -471,34 +504,30 @@ void TempInput::render(wxDC &dc)
ScalableBitmap round_icon(this, "round", 16);/*icon diffs in normal and dark mode*/
round_icon.msw_rescale();
if (round_icon.bmp().IsOk() && m_input_type == TEMP_OF_DEPUTY_NOZZLE_TYPE){
wxSize szIcon = round_icon.GetBmpSize();
pt.y = (size.y - szIcon.y) / 2;
dc.DrawBitmap(round_icon.bmp(), pt);
const wxString display_id = badge_text();
if (round_icon.bmp().IsOk() && !display_id.IsEmpty()) {
const wxSize icon_size = round_icon.GetBmpSize();
dc.SetFont(has_tool_id_badge() ? ::Label::Body_10 : ::Label::Body_12);
const wxSize text_size = dc.GetMultiLineTextExtent(display_id);
// L/R and single-digit tool ids fit the round icon; a wider id (e.g. T10) gets a pill
// with the same fill and white lettering.
const int badge_width = std::max(icon_size.x, text_size.x + FromDIP(2));
dc.SetFont(::Label::Body_12);
auto sepSize = dc.GetMultiLineTextExtent(wxString("L"));
pt.y = (size.y - icon_size.y) / 2;
if (badge_width > icon_size.x) {
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(wxColour("#262E30")));
dc.DrawRoundedRectangle(pt.x, pt.y, badge_width, icon_size.y, icon_size.y / 2);
} else {
dc.DrawBitmap(round_icon.bmp(), pt);
}
dc.SetBrush(*wxTRANSPARENT_BRUSH);
const wxColour& clr = Slic3r::GUI::wxGetApp().dark_mode() ? StateColor::darkModeColorFor(*wxWHITE) : *wxWHITE;
dc.SetTextForeground(clr);
dc.SetTextBackground(clr);
dc.DrawText(wxString("L"), pt.x + (szIcon.x - sepSize.x) / 2, (size.y - sepSize.y) / 2);
pt.x += szIcon.x + 3;
}
if (round_icon.bmp().IsOk() && m_input_type == TEMP_OF_MAIN_NOZZLE_TYPE) {
wxSize szIcon = round_icon.GetBmpSize();
pt.y = (size.y - szIcon.y) / 2;
dc.DrawBitmap(round_icon.bmp(), pt);
dc.SetFont(::Label::Body_12);
auto sepSize = dc.GetMultiLineTextExtent(wxString("R"));
const wxColour& clr = Slic3r::GUI::wxGetApp().dark_mode() ? StateColor::darkModeColorFor(*wxWHITE) : *wxWHITE;
dc.SetTextForeground(clr);
dc.SetTextBackground(clr);
dc.DrawText(wxString("R"), pt.x + (szIcon.x - sepSize.x) / 2, (size.y - sepSize.y) / 2);
pt.x += szIcon.x + 3;
dc.DrawText(display_id, pt.x + (badge_width - text_size.x) / 2, (size.y - text_size.y) / 2);
pt.x += badge_width + 3;
}
// label
@@ -567,11 +596,7 @@ void TempInput::messureMiniSize()
// interval
width += 9;
if (m_input_type == TEMP_OF_MAIN_NOZZLE_TYPE || m_input_type == TEMP_OF_DEPUTY_NOZZLE_TYPE) {
wxSize szIcon = round_scale_hint_icon.GetBmpSize();
width += szIcon.x;
}
width += 3;
width += badge_width(dc) + 3;
// label
dc.SetFont(::Label::Head_14);
@@ -627,11 +652,7 @@ void TempInput::messureSize()
// interval
width += 9;
if (m_input_type == TEMP_OF_MAIN_NOZZLE_TYPE || m_input_type == TEMP_OF_DEPUTY_NOZZLE_TYPE) {
wxSize szIcon = round_scale_hint_icon.GetBmpSize();
width += szIcon.x;
}
width += 3;
width += badge_width(dc) + 3;
// label
dc.SetFont(::Label::Head_14);
+12 -1
View File
@@ -53,7 +53,16 @@ class TempInput : public wxNavigationEnabled<StaticBox>
int min_temp = 0;
std::unordered_set<int> additional_temps;
bool warning_mode = false;
TempInputType m_input_type;
TempInputType m_input_type = TEMP_OF_NORMAL_TYPE;
wxString m_display_id_text; // optional badge (T1, T2, ...) beside the icon
// Badge shown over the round icon: L/R for Bambu's main/deputy nozzle, or the explicit tool id.
wxString badge_text() const;
bool has_tool_id_badge() const
{
return m_input_type == TEMP_OF_NORMAL_TYPE && !m_display_id_text.IsEmpty();
}
int badge_width(wxDC &dc) const;
int padding_left = 0;
static const int TempInputWidth = 200;
@@ -123,6 +132,8 @@ public:
wxString GetCurrTemp() { return GetLabel(); }
int get_max_temp() { return max_temp; }
void SetLabel(const wxString &label) override;
// Tool identity shown in the round badge (for printers with more than two toolheads).
void SetDisplayIdText(const wxString &text);
void SetTextColor(StateColor const &color);
+613 -88
View File
@@ -11,6 +11,7 @@
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/DeviceCore/DevFilaSystem.h"
#include "slic3r/GUI/DeviceCore/DevManager.h"
#include "slic3r/GUI/DeviceCore/DevDefs.h"
#include "../GUI/DeviceCore/DevStorage.h"
#include "../GUI/DeviceCore/DevFirmware.h"
#include "nlohmann/json.hpp"
@@ -45,6 +46,7 @@
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cctype>
#include <ios>
@@ -68,6 +70,42 @@ namespace websocket = beast::websocket;
namespace net = boost::asio;
using tcp = net::ip::tcp;
// Decimal value of a string that is nothing but digits, or -1 when it is empty or not a number.
int decimal_number(const std::string& digits)
{
if (digits.empty() || !std::all_of(digits.begin(), digits.end(), [](unsigned char c) { return std::isdigit(c) != 0; }))
return -1;
try {
return std::stoi(digits);
} catch (...) {
return -1;
}
}
// Lane database keys address a lane either as "T<N>" (0-based tool index) or as "lane<N>"
// (1-based, the AFC/Happy Hare style). Returns -1 when the key carries no usable lane number.
int lane_index_from_key(const std::string& lane_key)
{
if (lane_key.size() > 1 && (lane_key[0] == 'T' || lane_key[0] == 't'))
return decimal_number(lane_key.substr(1));
if (lane_key.size() > 4 && lane_key.compare(0, 4, "lane") == 0) {
const int lane_number = decimal_number(lane_key.substr(4));
return lane_number > 0 ? lane_number - 1 : -1;
}
return -1;
}
// AFC names toolheads "extruder", "extruder1", ... and derives the toolhead index from the name
// itself (AFC_lane.lane_extruder_index): no suffix is 0, otherwise the suffix is the index.
int extruder_index_from_name(const std::string& toolhead)
{
if (toolhead == "extruder")
return 0;
if (toolhead.size() > 8 && toolhead.compare(0, 8, "extruder") == 0)
return decimal_number(toolhead.substr(8));
return -1;
}
struct WsEndpoint
{
std::string host;
@@ -475,19 +513,7 @@ int MoonrakerPrinterAgent::set_queue_on_main_fn(QueueOnMainFn fn)
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays)
{
// Look up MachineObject via DeviceManager
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
if (!dev_manager) {
return;
}
MachineObject* obj = dev_manager->get_my_machine(device_info.dev_id);
if (!obj) {
return;
}
// Build BBL-format JSON for DevFilaSystemParser::ParseV1_0
nlohmann::json ams_json = nlohmann::json::object();
nlohmann::json ams_array = nlohmann::json::array();
// Calculate ams_exist_bits and tray_exist_bits
@@ -495,11 +521,9 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
unsigned long tray_exist_bits = 0;
for (int ams_id = 0; ams_id < ams_count; ++ams_id) {
ams_exist_bits |= (1 << ams_id);
nlohmann::json ams_unit = nlohmann::json::object();
ams_unit["id"] = std::to_string(ams_id);
ams_unit["info"] = "0002"; // treat as AMS_LITE
if (ams_id < 32) {
ams_exist_bits |= (1UL << ams_id);
}
nlohmann::json tray_array = nlohmann::json::array();
int max_slot_in_this_ams = std::min(3, max_lane_index - ams_id * 4);
@@ -515,43 +539,147 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
}
}
nlohmann::json tray_json = nlohmann::json::object();
tray_json["id"] = std::to_string(slot_id);
tray_json["tag_uid"] = "0000000000000000";
if (tray && tray->has_filament) {
tray_exist_bits |= (1 << slot_index);
tray_json["tray_info_idx"] = tray->tray_info_idx;
tray_json["tray_type"] = tray->tray_type;
tray_json["tray_color"] = normalize_color_value(tray->tray_color);
// Add temperature data if provided
if (tray->bed_temp > 0) {
tray_json["bed_temp"] = std::to_string(tray->bed_temp);
}
if (tray->nozzle_temp > 0) {
tray_json["nozzle_temp_max"] = std::to_string(tray->nozzle_temp);
}
} else {
tray_json["tray_info_idx"] = "";
tray_json["tray_type"] = "";
tray_json["tray_color"] = "00000000";
tray_json["tray_slot_placeholder"] = "1";
if (tray && tray->has_filament && slot_index < 32) {
tray_exist_bits |= (1UL << slot_index);
}
tray_array.push_back(tray_json);
static const AmsTrayData empty_tray;
tray_array.push_back(make_ams_tray_json(std::to_string(slot_id), tray ? *tray : empty_tray));
}
nlohmann::json ams_unit = nlohmann::json::object();
ams_unit["id"] = std::to_string(ams_id);
// Generic unit: type 1 (AMS) maps to AMSModel::GENERIC_AMS, which drives the
// variable-lane rendering. This is not a real Bambu AMS-Lite.
ams_unit["info"] = "0001";
ams_unit["tray"] = tray_array;
ams_array.push_back(ams_unit);
}
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays";
submit_ams_payload(ams_array, ams_exist_bits, tray_exist_bits);
}
void MoonrakerPrinterAgent::build_ams_payload_grouped(const std::vector<AmsTrayData>& trays)
{
// One AMS box per extruder, best effort. Lane-data changers (AFC, recent Happy Hare) report an
// extruder per lane but no physical box layout, so a box here is a logical feed group, not a
// discovered unit. Lanes without an extruder fall back to the main extruder.
std::map<int, std::vector<const AmsTrayData*>> boxes;
for (const auto& tray : trays) {
boxes[tray.extruder_id].push_back(&tray);
}
nlohmann::json ams_array = nlohmann::json::array();
unsigned long ams_exist_bits = 0;
unsigned long tray_exist_bits = 0;
int ams_id = 0;
// Cumulative lane index across all boxes, in box then slot order. A lane maps to exactly one
// extruder, so this is the lane's identity; the parser derives the same index for generic units.
int lane_index = 0;
for (auto& [extruder_id, lanes] : boxes) {
if (ams_id < 32) {
ams_exist_bits |= (1UL << ams_id);
}
// Keep a deterministic slot order within the box.
std::stable_sort(lanes.begin(), lanes.end(),
[](const AmsTrayData* a, const AmsTrayData* b) { return a->slot_index < b->slot_index; });
nlohmann::json tray_array = nlohmann::json::array();
for (size_t slot = 0; slot < lanes.size(); ++slot) {
const AmsTrayData& tray = *lanes[slot];
// A logical feed group is not a four-slot Bambu AMS: the bit is the lane's cumulative
// index, not ams_id*4+slot. DevFilaSystemParser::ParseV1_0 reads the same index.
const int bit = lane_index++;
if (tray.has_filament && bit < 32) {
tray_exist_bits |= (1UL << bit);
}
tray_array.push_back(make_ams_tray_json(std::to_string(slot), tray));
}
nlohmann::json ams_unit = nlohmann::json::object();
ams_unit["id"] = std::to_string(ams_id);
// info: bits 0-3 = unit type (1 = AMS, i.e. AMSModel::GENERIC_AMS), bits 8-11 = bound
// extruder id. A logical feed group is a generic unit, not a Bambu AMS-Lite.
const int info_val = 1 | ((extruder_id & 0x0F) << 8);
const char* hex_digits = "0123456789ABCDEF";
std::string info = "0000";
info[3] = hex_digits[info_val & 0x0F];
info[2] = hex_digits[(info_val >> 4) & 0x0F];
info[1] = hex_digits[(info_val >> 8) & 0x0F];
info[0] = hex_digits[(info_val >> 12) & 0x0F];
ams_unit["info"] = info;
ams_unit["tray"] = tray_array;
ams_array.push_back(ams_unit);
++ams_id;
}
size_t has_filament_count = 0;
for (const auto& tray : trays) {
if (tray.has_filament) ++has_filament_count;
}
std::ostringstream box_summary;
bool first_box = true;
for (const auto& [extruder_id, lanes] : boxes) {
if (!first_box) box_summary << ", ";
box_summary << "ext" << extruder_id << ":" << lanes.size();
first_box = false;
}
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload_grouped: input trays " << trays.size()
<< " extruders " << boxes.size() << " has_filament " << has_filament_count
<< " boxes [" << box_summary.str() << "]";
submit_ams_payload(ams_array, ams_exist_bits, tray_exist_bits);
}
nlohmann::json MoonrakerPrinterAgent::make_ams_tray_json(const std::string& tray_id, const AmsTrayData& tray)
{
nlohmann::json tray_json = nlohmann::json::object();
tray_json["id"] = tray_id;
tray_json["tag_uid"] = "0000000000000000";
if (tray.has_filament) {
tray_json["tray_info_idx"] = tray.tray_info_idx;
tray_json["tray_type"] = tray.tray_type;
tray_json["tray_color"] = normalize_color_value(tray.tray_color);
// Add temperature data if provided
if (tray.bed_temp > 0) {
tray_json["bed_temp"] = std::to_string(tray.bed_temp);
}
if (tray.nozzle_temp > 0) {
tray_json["nozzle_temp_max"] = std::to_string(tray.nozzle_temp);
}
} else {
tray_json["tray_info_idx"] = "";
tray_json["tray_type"] = "";
tray_json["tray_color"] = "00000000";
tray_json["tray_slot_placeholder"] = "1";
}
return tray_json;
}
void MoonrakerPrinterAgent::submit_ams_payload(const nlohmann::json& ams_array, unsigned long ams_exist_bits, unsigned long tray_exist_bits)
{
// Look up MachineObject via DeviceManager
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
if (!dev_manager) {
return;
}
MachineObject* obj = dev_manager->get_my_machine(device_info.dev_id);
if (!obj) {
return;
}
// Format as hex strings (matching BBL protocol)
std::ostringstream ams_exist_ss;
ams_exist_ss << std::hex << std::uppercase << ams_exist_bits;
std::ostringstream tray_exist_ss;
tray_exist_ss << std::hex << std::uppercase << tray_exist_bits;
nlohmann::json ams_json = nlohmann::json::object();
ams_json["ams"] = ams_array;
ams_json["ams_exist_bits"] = ams_exist_ss.str();
ams_json["tray_exist_bits"] = tray_exist_ss.str();
@@ -560,9 +688,42 @@ void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index,
nlohmann::json print_json = nlohmann::json::object();
print_json["ams"] = ams_json;
// Diagnostic: log the AMS-box boundary input (box count and per-box tray counts) so what the
// parser produces can be compared against what the agent actually submitted.
std::ostringstream box_trays;
for (size_t i = 0; i < ams_array.size(); ++i) {
if (i > 0) box_trays << ", ";
box_trays << "box" << ams_array[i].value("id", std::string()) << ":"
<< ((ams_array[i].contains("tray") && ams_array[i]["tray"].is_array()) ? ams_array[i]["tray"].size() : 0);
}
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::submit_ams_payload: boundary boxes " << ams_array.size()
<< " trays [" << box_trays.str() << "]";
// Call the parser to populate DevFilaSystem
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays";
// ParseV1_0 handles only the AMS boxes, so grow the external-spool slots directly here too:
// pull mode bypasses MachineObject::parse_json(), the only place the push-status vir_slot is
// applied. Each Moonraker extruder gets one empty native virtual tray (id = 255 - extruder_id),
// kept at its id-derived index so a non-contiguous extruder set does not shift slots. Slots that
// already carry reported spool metadata are left untouched (only missing slots are added).
const auto extruder_status = collect_extruder_status();
for (const auto& entry : extruder_status) {
const int extruder_id = entry.first;
if (extruder_id < 0 || extruder_id > VIRTUAL_TRAY_MAIN_ID) {
continue;
}
// Address the slot by its virtual tray id (VIRTUAL_TRAY_MAIN_ID - extruder_id), which places
// it at vector index extruder_id. Append-only, so a reported spool and the extruder
// association survive refreshes.
grow_virtual_tray_slots(obj->vt_slot, static_cast<std::size_t>(VIRTUAL_TRAY_MAIN_ID - extruder_id));
auto& tray = obj->vt_slot[extruder_id];
tray.is_slot_placeholder = !tray.is_exists && tray.setting_id.empty() && tray.m_fila_type.empty();
}
if (!extruder_status.empty()) {
// A native virtual tray implies external-spool support, matching parse_vt_tray().
obj->ams_support_virtual_tray = true;
}
// Set printer_type so update_sync_status() can match it against the preset's printer type.
// Without this, the comparison fails and all sync badges are cleared.
@@ -602,17 +763,15 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
if (fetch_moonraker_filament_data(trays, max_lane_index)) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Moonraker filament system with "
<< (max_lane_index + 1) << " lanes";
int ams_count = (max_lane_index + 4) / 4;
build_ams_payload(ams_count, max_lane_index, trays);
build_ams_payload_grouped(trays);
return true;
}
// Attempt Happy Hare first (more widely adopted, supports more filament changers)
// Fall back to the Happy Hare `mmu` object for HH builds that do not publish lane_data
if (fetch_hh_filament_info(trays, max_lane_index)) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Happy Hare MMU with "
<< (max_lane_index + 1) << " gates";
int ams_count = (max_lane_index + 4) / 4;
build_ams_payload(ams_count, max_lane_index, trays);
build_ams_payload_grouped(trays);
return true;
}
@@ -718,6 +877,30 @@ int MoonrakerPrinterAgent::safe_json_int(const nlohmann::json& obj, const char*
return 0;
}
int MoonrakerPrinterAgent::safe_json_int_flexible(const nlohmann::json& obj, const char* key, int default_value)
{
auto it = obj.find(key);
if (it == obj.end())
return default_value;
if (it->is_number_integer())
return it->get<int>();
if (it->is_string()) {
const std::string s = it->get<std::string>();
if (s.empty())
return default_value;
try {
std::size_t consumed = 0;
const int value = std::stoi(s, &consumed);
if (consumed != s.size()) // reject partial parses such as "2oops"
return default_value;
return value;
} catch (...) {
return default_value;
}
}
return default_value;
}
std::string MoonrakerPrinterAgent::safe_array_string(const nlohmann::json& arr, int idx)
{
if (arr.is_array() && idx >= 0 && idx < static_cast<int>(arr.size()) && arr[idx].is_string())
@@ -820,29 +1003,60 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayDat
const auto& value = json["result"]["value"];
trays.clear();
max_lane_index = 0;
size_t skipped_records = 0;
// The lane_data namespace carries no extruder field; read the lane -> toolhead binding from the
// AFC printer objects so lanes on different toolheads end up in different AMS boxes.
std::map<std::string, int> lane_extruder;
fetch_afc_lane_extruders(lane_extruder);
// Happy Hare also writes lane_data, where the lane number is the gate index. Its mmu_machine
// status maps gates to units, and each unit's config maps to an extruder. Probe that mapping
// only when a lane has no AFC/extruder field, preserving the existing AFC/generic behavior.
std::map<int, int> hh_gate_extruder;
bool hh_probed = false;
auto hh_extruder_for_lane = [&](int lane) {
if (!hh_probed) {
hh_probed = true;
fetch_hh_gate_extruders(hh_gate_extruder);
}
const auto it = hh_gate_extruder.find(lane);
return it != hh_gate_extruder.end() ? it->second : 0;
};
for (const auto& [lane_key, lane_obj] : value.items()) {
if (!lane_obj.is_object()) {
++skipped_records;
continue;
}
// Extract lane index from the "lane" field (tool number, 0-based)
std::string lane_str = safe_json_string(lane_obj, "lane");
int lane_index = -1;
if (!lane_str.empty()) {
try {
lane_index = std::stoi(lane_str);
} catch (...) {
lane_index = -1;
}
}
// Lane tool number (0-based). AFC reports it as a string in the documented sample but it
// can also be a number; accept either and skip lanes without a usable index. Databases
// written without a "lane" field fall back to the key, which names the lane either as
// "T<N>" (0-based) or as "lane<N>" (1-based).
int lane_index = safe_json_int_flexible(lane_obj, "lane", -1);
if (lane_index < 0) {
continue;
lane_index = lane_index_from_key(lane_key);
if (lane_index < 0) {
++skipped_records;
continue;
}
}
AmsTrayData tray;
tray.slot_index = lane_index;
// AFC exposes the lane's extruder/toolhead as "extruder_index"; tolerate "extruder_id" as
// an alias. lane_data has neither, so fall back to the AFC lane objects, then to the Happy
// Hare unit layout, and only then to the main extruder (best effort).
const int source_extruder = safe_json_int_flexible(lane_obj, "extruder_index",
safe_json_int_flexible(lane_obj, "extruder_id", -1));
const char* source_extruder_field = lane_obj.contains("extruder_index") ? "extruder_index"
: lane_obj.contains("extruder_id") ? "extruder_id" : "none";
tray.extruder_id = source_extruder;
if (tray.extruder_id < 0) {
const auto it = lane_extruder.find(lane_key);
tray.extruder_id = it != lane_extruder.end() ? it->second : hh_extruder_for_lane(lane_index);
}
tray.tray_color = safe_json_string(lane_obj, "color");
tray.tray_type = safe_json_string(lane_obj, "material");
tray.bed_temp = safe_json_int(lane_obj, "bed_temp");
@@ -855,8 +1069,18 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayDat
max_lane_index = std::max(max_lane_index, lane_index);
trays.push_back(tray);
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: lane " << lane_index
<< " src_extruder " << source_extruder_field << "=" << source_extruder
<< " mapped_extruder " << tray.extruder_id
<< " material " << tray.tray_type
<< " color " << tray.tray_color
<< " preset_resolved " << (!tray.tray_info_idx.empty());
}
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: lane_data records " << value.size()
<< " parsed " << trays.size() << " skipped " << skipped_records
<< " max_lane_index " << max_lane_index;
if (trays.empty()) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: No lanes found";
return false;
@@ -947,52 +1171,234 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector<AmsTrayData>& tra
return false;
}
// Parse gate data
// Parse gate data. Every gate becomes a lane so empty/unknown gates surface
// as empty AMS slots rather than vanishing. Each gate is grouped by the toolhead its
// mmu_machine unit feeds; when the unit layout is unavailable every gate keeps the previous
// fallback of feed group 0.
std::map<int, int> gate_extruder;
fetch_hh_gate_extruders(gate_extruder);
trays.clear();
max_lane_index = 0;
for (int gate_idx = 0; gate_idx < num_gates; ++gate_idx) {
// Check gate_status: -1 = unknown, 0 = empty, 1 or 2 = available
int status = safe_array_int(gate_status, gate_idx);
if (status <= 0) {
continue; // Skip unknown or empty gates
}
// Extract gate data
std::string material = safe_array_string(gate_material, gate_idx);
std::string color = safe_array_string(gate_color, gate_idx);
int nozzle_temp = safe_array_int(gate_temperature, gate_idx);
// Skip if no material type (empty gate)
if (material.empty()) {
continue;
}
AmsTrayData tray;
tray.slot_index = gate_idx;
tray.tray_type = material;
tray.tray_color = color;
tray.nozzle_temp = nozzle_temp;
tray.bed_temp = 0; // HH doesn't provide bed temp in gate arrays
tray.has_filament = true;
const auto extruder_it = gate_extruder.find(gate_idx);
tray.extruder_id = extruder_it != gate_extruder.end() ? extruder_it->second : 0;
auto* bundle = GUI::wxGetApp().preset_bundle;
tray.tray_info_idx = bundle
? bundle->filaments.filament_id_by_type(tray.tray_type)
: map_filament_type_to_generic_id(tray.tray_type);
if (status > 0 && !material.empty()) {
tray.tray_type = material;
tray.tray_color = color;
tray.nozzle_temp = nozzle_temp;
tray.bed_temp = 0; // HH doesn't provide bed temp in gate arrays
tray.has_filament = true;
auto* bundle = GUI::wxGetApp().preset_bundle;
tray.tray_info_idx = bundle
? bundle->filaments.filament_id_by_type(tray.tray_type)
: map_filament_type_to_generic_id(tray.tray_type);
} else {
tray.has_filament = false;
}
max_lane_index = std::max(max_lane_index, gate_idx);
trays.push_back(tray);
}
if (trays.empty()) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_hh_filament_info: No valid HH gates found";
max_lane_index = num_gates - 1;
return true;
}
// GET a Moonraker path (relative to the device base URL) with the device's API key and parse the
// response body. Returns false on a non-200 status or a body that is not valid JSON.
bool MoonrakerPrinterAgent::http_get_json(const std::string& path, nlohmann::json& out) const
{
auto http = Http::get(join_url(device_info.base_url, path));
if (!device_info.api_key.empty()) {
http.header("X-Api-Key", device_info.api_key);
}
std::string body;
unsigned status_code = 0;
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string response, unsigned status) {
body = std::move(response);
status_code = status;
})
.perform_sync();
if (status_code != 200) {
return false;
}
out = nlohmann::json::parse(body, nullptr, false, true);
return !out.is_discarded();
}
// AFC writes its filament data into the lane_data database namespace, and the records of the
// fixture/agent write path carry no extruder field, so the lane -> toolhead binding has to be read
// from the printer objects: each AFC_lane <name>.extruder names the toolhead its lane feeds, and
// the toolhead's name carries its own index. Best effort: on failure every lane keeps the fallback
// of the main extruder.
bool MoonrakerPrinterAgent::fetch_afc_lane_extruders(std::map<std::string, int>& lane_extruder)
{
nlohmann::json afc_json;
if (!http_get_json("/printer/objects/query?AFC", afc_json) || !afc_json.contains("result") ||
!afc_json["result"].contains("status") || !afc_json["result"]["status"].contains("AFC")) {
return false;
}
const auto& afc = afc_json["result"]["status"]["AFC"];
const auto& lanes = afc.contains("lanes") ? afc["lanes"] : nlohmann::json::array();
if (!lanes.is_array() || lanes.empty()) {
return false;
}
// Query every lane object in one request; the object names are "AFC_lane <lane name>".
std::string query_string;
for (const auto& lane : lanes) {
if (!lane.is_string()) {
return false;
}
if (!query_string.empty()) {
query_string += "&";
}
query_string += "AFC_lane%20" + lane.get<std::string>();
}
nlohmann::json lanes_json;
if (!http_get_json("/printer/objects/query?" + query_string, lanes_json) || !lanes_json.contains("result") ||
!lanes_json["result"].contains("status")) {
return false;
}
const auto& status = lanes_json["result"]["status"];
for (const auto& lane : lanes) {
const std::string name = lane.get<std::string>();
const std::string key = "AFC_lane " + name;
if (!status.contains(key) || !status[key].is_object() || !status[key].contains("extruder") ||
!status[key]["extruder"].is_string()) {
continue;
}
const std::string toolhead = status[key]["extruder"].get<std::string>();
const int toolhead_index = extruder_index_from_name(toolhead);
if (toolhead_index >= 0) {
lane_extruder[name] = toolhead_index;
}
}
return !lane_extruder.empty();
}
// Happy Hare maps its gates to toolheads through mmu_machine units: each unit entry carries
// {name, first_gate, num_gates}, and the matching [mmu_unit <name>] config section names the
// extruder the unit is attached to. Only Happy Hare publishes mmu_machine, so a generic
// lane_data / AFC printer is never reassigned from here. Best effort: returns false and leaves the
// map empty when Happy Hare or its unit data is not available.
bool MoonrakerPrinterAgent::fetch_hh_gate_extruders(std::map<int, int>& gate_extruder)
{
gate_extruder.clear();
nlohmann::json machine_json;
if (!http_get_json("/printer/objects/query?mmu_machine", machine_json) ||
!machine_json.contains("result") || !machine_json["result"].contains("status") ||
!machine_json["result"]["status"].contains("mmu_machine") ||
!machine_json["result"]["status"]["mmu_machine"].is_object()) {
return false;
}
const auto& machine = machine_json["result"]["status"]["mmu_machine"];
if (machine.empty()) {
return false;
}
// Happy Hare publishes its config through the configfile object; read every unit's section from
// one fetch. An absent or unreadable configfile leaves every unit at the main extruder.
nlohmann::json sections;
nlohmann::json config_json;
if (http_get_json("/printer/objects/query?configfile", config_json) &&
config_json.contains("result") && config_json["result"].contains("status") &&
config_json["result"]["status"].contains("configfile") &&
config_json["result"]["status"]["configfile"].is_object()) {
const auto& configfile = config_json["result"]["status"]["configfile"];
if (configfile.contains("config") && configfile["config"].is_object()) {
sections = configfile["config"];
}
}
// One gate range -> extruder resolution per unit, reused for every gate the unit carries.
std::map<std::string, int> unit_extruder;
for (const auto& [unit_key, unit_obj] : machine.items()) {
// A unit entry is the one carrying a gate range; skip scalars such as num_units.
if (!unit_obj.is_object() || !unit_obj.contains("first_gate") || !unit_obj.contains("num_gates")) {
continue;
}
const int first_gate = safe_json_int_flexible(unit_obj, "first_gate", -1);
const int num_gates = safe_json_int_flexible(unit_obj, "num_gates", -1);
if (first_gate < 0 || num_gates <= 0) {
continue;
}
// If status omits the unit name, use the unit key as a best-effort config section suffix.
std::string unit_name = safe_json_string(unit_obj, "name");
if (unit_name.empty() && unit_key.rfind("unit_", 0) == 0) {
unit_name = unit_key;
}
if (unit_name.empty()) {
continue;
}
auto it = unit_extruder.find(unit_name);
if (it == unit_extruder.end()) {
it = unit_extruder.emplace(unit_name, hh_unit_extruder_from_config(sections, unit_name)).first;
}
for (int gate = first_gate; gate < first_gate + num_gates; ++gate) {
gate_extruder[gate] = it->second;
}
}
if (gate_extruder.empty()) {
return false;
}
BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent::fetch_hh_gate_extruders: Mapped "
<< gate_extruder.size() << " Happy Hare gate(s) across "
<< unit_extruder.size() << " unit(s)";
return true;
}
// Read the [mmu_unit <name>] section of a configfile.config map and map its extruder_name to a
// toolhead index ("extruder" -> 0, "extruderN" -> N). Happy Hare v4 defaults a missing
// extruder_name to the main extruder, so an absent setting and an absent section both fall back to
// 0 rather than dropping the unit.
int MoonrakerPrinterAgent::hh_unit_extruder_from_config(const nlohmann::json& sections, const std::string& unit_name)
{
constexpr int default_extruder = 0;
if (!sections.is_object()) {
return default_extruder;
}
// Happy Hare names its unit sections "mmu_unit <name>" (e.g. "mmu_unit unit0").
const auto it = sections.find("mmu_unit " + unit_name);
if (it == sections.end() || !it->is_object()) {
return default_extruder;
}
const std::string extruder_name = safe_json_string(*it, "extruder_name");
if (extruder_name.empty()) {
return default_extruder;
}
const int extruder_index = extruder_index_from_name(extruder_name);
return extruder_index >= 0 ? extruder_index : default_extruder;
}
int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::string& json_str)
{
auto json = nlohmann::json::parse(json_str, nullptr, false);
@@ -1091,6 +1497,25 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::
}
}
// Tool selection is printer-specific G-code. Moonraker forwards the
// conventional T<index> command to Klipper, where the printer can
// implement logical selection or a physical toolhead attach macro.
if (cmd == "select_extruder") {
if (!json["print"].contains("extruder_index") || !json["print"]["extruder_index"].is_number_integer()) {
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: select_extruder missing integer extruder_index, full json: " << json_str;
return BAMBU_NETWORK_ERR_INVALID_RESULT;
}
const int extruder_idx = json["print"]["extruder_index"].get<int>();
if (extruder_idx < 0) {
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: select_extruder received negative extruder_index: " << extruder_idx;
return BAMBU_NETWORK_ERR_INVALID_RESULT;
}
send_gcode_async(dev_id, "T" + std::to_string(extruder_idx));
return BAMBU_NETWORK_SUCCESS;
}
if (cmd == "home") {
send_gcode_async(dev_id, "G28");
return BAMBU_NETWORK_SUCCESS;
@@ -1187,7 +1612,7 @@ bool MoonrakerPrinterAgent::query_printer_status(const std::string& base_url,
nlohmann::json& status,
std::string& error) const
{
std::string url = join_url(base_url, "/printer/objects/query?print_stats&virtual_sdcard&extruder&heater_bed&fan");
std::string url = join_url(base_url, "/printer/objects/query?print_stats&virtual_sdcard&toolhead&extruder&heater_bed&fan");
std::string response_body;
bool success = false;
@@ -1705,11 +2130,12 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
}
for (const auto& name : this->available_objects) {
if (name == "extruder" || name.rfind("extruder", 0) == 0) {
const bool is_main_extruder = name == "extruder";
const bool is_additional_extruder =
name.rfind("extruder", 0) == 0 && name.size() > 8 &&
std::all_of(name.begin() + 8, name.end(), [](unsigned char c) { return std::isdigit(c) != 0; });
if (is_main_extruder || is_additional_extruder) {
subscribe_objects.insert(name);
if (name == "extruder") {
break;
}
}
}
} else {
@@ -1926,6 +2352,31 @@ void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
}
}
// Snapshot Moonraker toolhead status objects ("extruder", "extruder1", ...) keyed by extruder id.
// An id is the suffix of the object name, so a non-contiguous set keeps its real ids.
std::map<int, nlohmann::json> MoonrakerPrinterAgent::collect_extruder_status() const
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
std::map<int, nlohmann::json> extruder_status;
for (const auto& item : status_cache.items()) {
int extruder_id = -1;
if (item.key() == "extruder") {
extruder_id = 0;
} else if (item.key().rfind("extruder", 0) == 0 && item.key().size() > 8 &&
std::all_of(item.key().begin() + 8, item.key().end(), [](unsigned char c) { return std::isdigit(c) != 0; })) {
try {
extruder_id = std::stoi(item.key().substr(8));
} catch (...) {
extruder_id = -1;
}
}
if (extruder_id >= 0 && item.value().is_object()) {
extruder_status.emplace(extruder_id, item.value());
}
}
return extruder_status;
}
nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const
{
nlohmann::json payload;
@@ -2008,6 +2459,80 @@ nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const
}
}
// MachineObject::parse_new_info() only parses device.extruder when the four
// legacy new-protocol fields are present. Moonraker has no equivalents, so
// empty values act as compatibility markers without enabling any features.
const auto extruder_status = collect_extruder_status();
if (!extruder_status.empty()) {
auto& device_extruder = payload["print"]["device"]["extruder"];
int current_extruder_id = 0;
if (status_cache.contains("toolhead") && status_cache["toolhead"].is_object() &&
status_cache["toolhead"].contains("extruder") && status_cache["toolhead"]["extruder"].is_string()) {
const std::string active_extruder = status_cache["toolhead"]["extruder"].get<std::string>();
if (active_extruder != "extruder" && active_extruder.rfind("extruder", 0) == 0 && active_extruder.size() > 8 &&
std::all_of(active_extruder.begin() + 8, active_extruder.end(), [](unsigned char c) { return std::isdigit(c) != 0; })) {
try {
const int parsed_id = std::stoi(active_extruder.substr(8));
if (parsed_id >= 0 && parsed_id < 16 && extruder_status.count(parsed_id) != 0) {
current_extruder_id = parsed_id;
}
} catch (...) {
// Keep the main extruder as the fallback for malformed status data.
}
}
}
const int extruder_count = std::min<int>(static_cast<int>(extruder_status.size()), 0xF);
device_extruder["state"] = extruder_count | (current_extruder_id << 4);
device_extruder["info"] = nlohmann::json::array();
auto encoded_temperature = [](const nlohmann::json& status, const char* key) {
if (!status.contains(key) || !status[key].is_number()) {
return 0;
}
const double temperature = status[key].get<double>();
return std::clamp(static_cast<int>(temperature + 0.5), 0, 65535);
};
for (const auto& [extruder_id, status] : extruder_status) {
const int current_temperature = encoded_temperature(status, "temperature");
const int target_temperature = encoded_temperature(status, "target");
device_extruder["info"].push_back({
{"id", extruder_id},
{"filam_bak", nlohmann::json::array()},
{"info", 1 << 3}, // The configured Moonraker extruder has a toolhead.
{"temp", current_temperature | (target_temperature << 16)},
{"spre", 0},
{"snow", 0},
{"star", 0},
{"stat", 0},
{"hnow", 0},
});
}
payload["print"]["cfg"] = "";
payload["print"]["fun"] = "";
payload["print"]["aux"] = "";
payload["print"]["stat"] = "";
// External-spool placeholders. Moonraker reports the extruders but no external-spool
// filament, so emit one empty native virtual tray per extruder (id = 255 - extruder_id).
// Emitting vir_slot makes the parser grow vt_slot and set ams_support_virtual_tray, giving
// every extruder a load slot instead of only the main one. Ids come from the extruder_status
// map, so a non-contiguous extruder set keeps its id mapping (the parser fills the holes
// with placeholders) rather than shifting every slot down.
auto& vir_slot = payload["print"]["vir_slot"];
vir_slot = nlohmann::json::array();
for (const auto& entry : extruder_status) {
if (entry.first < 0 || entry.first > VIRTUAL_TRAY_MAIN_ID) {
continue;
}
vir_slot.push_back({{"id", std::to_string(VIRTUAL_TRAY_MAIN_ID - entry.first)}});
}
}
if (status_cache.contains("heater_bed") && status_cache["heater_bed"].is_object()) {
const auto& bed = status_cache["heater_bed"];
if (bed.contains("temperature") && bed["temperature"].is_number()) {
@@ -8,6 +8,7 @@
#include <chrono>
#include <cstdint>
#include <atomic>
#include <map>
#include <memory>
#include <mutex>
#include <set>
@@ -99,6 +100,7 @@ protected:
// Tray data for AMS payload building
struct AmsTrayData {
int slot_index = 0; // 0-based slot index
int extruder_id = 0; // Extruder this lane feeds (AFC extruder_index); 0 when unknown
bool has_filament = false;
std::string tray_type; // Material type (e.g., "PLA", "ASA")
std::string tray_color; // Raw color (#RRGGBB, 0xRRGGBB, or RRGGBBAA)
@@ -110,6 +112,10 @@ protected:
// Build ams JSON and call parser
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
// Build ams JSON with one box per extruder (best effort, for lane_data-based filament
// changers such as AFC) and call the parser. Boxes are ordered by extruder id.
void build_ams_payload_grouped(const std::vector<AmsTrayData>& trays);
// Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
@@ -153,6 +159,8 @@ private:
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
// Snapshot Moonraker "extruder"/"extruderN" status objects from the cache, keyed by extruder id.
std::map<int, nlohmann::json> collect_extruder_status() const;
// Print control helpers
int pause_print(const std::string& dev_id);
@@ -177,10 +185,31 @@ private:
// System-specific filament fetch methods
bool fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index);
// Lane name -> toolhead index, read from the AFC printer objects (the lane_data database has no
// extruder field). Returns false when AFC is not present or the objects cannot be read.
bool fetch_afc_lane_extruders(std::map<std::string, int>& lane_extruder);
// Gate -> toolhead index, read from the Happy Hare mmu_machine units (gate ranges) and each
// unit's configured extruder_name. Returns false when Happy Hare is not present.
bool fetch_hh_gate_extruders(std::map<int, int>& gate_extruder);
// Read the extruder_name of the [mmu_unit <name>] section from a configfile.config map and map
// it to a toolhead index. Missing section/setting falls back to the main extruder (Happy Hare's
// own default).
static int hh_unit_extruder_from_config(const nlohmann::json& sections, const std::string& unit_name);
// GET a Moonraker path with the device's API key and parse the JSON body.
bool http_get_json(const std::string& path, nlohmann::json& out) const;
// Build one BBL tray JSON object (placeholder when the tray has no filament).
static nlohmann::json make_ams_tray_json(const std::string& tray_id, const AmsTrayData& tray);
// Wrap the AMS boxes into the payload ParseV1_0 understands and apply the pull-mode
// readiness state (printer_type, push counters, storage, module versions).
void submit_ams_payload(const nlohmann::json& ams_array, unsigned long ams_exist_bits, unsigned long tray_exist_bits);
// JSON helper methods
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
static int safe_json_int(const nlohmann::json& obj, const char* key);
// Accepts either a JSON number or a numeric string (AFC reports some fields as strings).
static int safe_json_int_flexible(const nlohmann::json& obj, const char* key, int default_value);
static std::string safe_array_string(const nlohmann::json& arr, int idx);
static int safe_array_int(const nlohmann::json& arr, int idx);
static std::string normalize_color_value(const std::string& color);
+340 -133
View File
@@ -11,6 +11,7 @@
#include <boost/log/trivial.hpp>
#include <algorithm>
#include <cassert>
#include <cctype>
#include <cstddef>
#include <cstdlib>
@@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name)
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
}
// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole
// operation, so the raising frame bounds everything the operation does. Every other mapped event
// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly
// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2
// are raised in iglob(), which returns its lazy generator before any directory is listed.
static const std::unordered_set<std::string> operation_root_events{
"urllib.Request", "http.client.connect", "os.walk", "os.fwalk",
"glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob",
"subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp",
"shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree",
"shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive",
"shutil.unpack_archive",
};
// True for the categories whose targets are filesystem paths, as opposed to a network
// address or a process command line -- the deny-path and allowed-root checks only make sense
// against a path.
@@ -146,6 +161,24 @@ static bool is_fs_category(AuditEventCategory category)
}
}
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
static const char* category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
case AuditEventCategory::None: break;
}
return "None";
}
// ---------------------------------------------------------------------------
// Path safety
// ---------------------------------------------------------------------------
@@ -215,26 +248,50 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
thread_local std::string PluginAuditManager::m_current_capability_name = "";
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
thread_local int PluginAuditManager::m_releasing_frames = 0;
thread_local bool PluginAuditManager::m_audit_denial_pending = false;
thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name)
: m_previous_id(PluginAuditManager::instance().current_plugin())
: m_plugin_key(plugin_key)
, m_capability(capability_name)
, m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
, m_previous_root_frames(std::move(PluginAuditManager::m_root_frames))
{
PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::m_scoped_allowed_roots.clear();
PluginAuditManager::m_root_frames.clear();
}
ScopedPluginAuditContext::~ScopedPluginAuditContext()
{
PluginAuditManager::instance().set_current_plugin(m_previous_id);
PluginAuditManager::instance().set_current_capability(m_previous_capability);
PluginAuditManager& mgr = PluginAuditManager::instance();
// Let this scope's root frames go while it is still the current context, and as its own
// plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer
// must never be judged as the outer plugin, or bypass the audit when there is none. A
// finalizer may record a new root frame, so repeat until none is held.
if (!PluginAuditManager::m_root_frames.empty()) {
assert(PyGILState_Check());
mgr.set_current_plugin(m_plugin_key);
mgr.set_current_capability(m_capability);
while (!PluginAuditManager::m_root_frames.empty()) {
std::vector<PyObject*> frames = std::move(PluginAuditManager::m_root_frames);
PluginAuditManager::m_root_frames.clear();
mgr.release_frames(frames);
}
}
mgr.set_current_plugin(m_previous_id);
mgr.set_current_capability(m_previous_capability);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
}
// ---------------------------------------------------------------------------
@@ -575,34 +632,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
return true;
}
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const
{
if (plugin_key.empty() || call_site_ids.empty())
return false;
std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_approved_call_sites.find(plugin_key);
if (it == m_approved_call_sites.end())
return false;
for (const auto& id : call_site_ids)
if (it->second.count(id))
return true;
return false;
}
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{
if (plugin_key.empty() || call_site_ids.empty())
return;
std::lock_guard<std::mutex> lock(m_mutex);
auto& approved = m_approved_call_sites[plugin_key];
approved.insert(call_site_ids.begin(), call_site_ids.end());
}
// ---------------------------------------------------------------------------
// The C-level audit hook
// ---------------------------------------------------------------------------
@@ -663,40 +692,88 @@ std::string python_str(PyObject* object)
return result;
}
std::vector<std::string> call_site_identities(const std::string& plugin_root)
{
std::vector<std::string> ids;
if (plugin_root.empty())
return ids;
// The interpreter's stdlib folders, and the site-packages folders inside them that hold
// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
// touched while the GIL is held.
static std::vector<boost::filesystem::path> s_stdlib_dirs;
static std::vector<boost::filesystem::path> s_site_package_dirs;
static std::unordered_map<std::string, bool> s_stdlib_file_verdicts; // co_filename -> is stdlib
void collect_stdlib_dirs()
{
s_stdlib_dirs.clear();
s_site_package_dirs.clear();
s_stdlib_file_verdicts.clear();
PyObject* sysconfig = PyImport_ImportModule("sysconfig");
PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr;
if (paths && PyDict_Check(paths)) {
const auto collect = [paths](const char* key, std::vector<boost::filesystem::path>& out) {
const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed
if (dir.empty())
return;
boost::system::error_code ec;
const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec);
out.push_back(ec ? boost::filesystem::path(dir) : resolved);
};
collect("stdlib", s_stdlib_dirs);
collect("platstdlib", s_stdlib_dirs);
collect("purelib", s_site_package_dirs);
collect("platlib", s_site_package_dirs);
}
Py_XDECREF(paths);
Py_XDECREF(sysconfig);
if (PyErr_Occurred()) {
PyErr_Clear();
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig";
}
}
bool is_stdlib_frame(PyObject* frame)
{
PyCodeObject* code = PyFrame_GetCode(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const std::string filename = python_unicode(code->co_filename);
Py_DECREF(code);
return PluginAuditManager::is_stdlib_file(filename);
}
// The frames on this thread's Python stack, innermost first. The pointers stay valid while the
// caller runs, because a running activation keeps its frame object alive.
std::vector<PyObject*> stack_frames()
{
std::vector<PyObject*> frames;
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
Py_XINCREF(frame); // normalize to an owned reference for the loop below
while (frame) {
PyCodeObject* code = PyFrame_GetCode(frame); // new reference
const std::string filename = python_unicode(reinterpret_cast<PyObject*>(code->co_filename));
const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root);
std::string id;
if (!is_plugin_frame && !filename.empty()) {
const std::string funcname = python_unicode(reinterpret_cast<PyObject*>(code->co_name));
id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno);
}
Py_DECREF(code);
frames.push_back(reinterpret_cast<PyObject*>(frame));
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
Py_DECREF(frame);
frame = back;
if (is_plugin_frame)
break;
if (!id.empty())
ids.push_back(std::move(id));
}
return frames;
}
Py_XDECREF(frame); // only holds a reference here if the loop exited via break
// Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended
// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so
// it stays held until the scope ends.
void take_returned_frames(std::vector<PyObject*>& held, std::vector<PyObject*>& dropped)
{
if (held.empty())
return;
return ids;
const std::vector<PyObject*> stack = stack_frames();
const auto running = [&stack](PyObject* frame) {
if (std::find(stack.begin(), stack.end(), frame) != stack.end())
return true;
// NULL once the generator has finished or been deleted: its frame then owns its own data.
PyObject* generator = PyFrame_GetGenerator(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const bool suspended = generator != nullptr;
Py_XDECREF(generator);
return suspended;
};
const auto returned = std::stable_partition(held.begin(), held.end(), running);
dropped.insert(dropped.end(), returned, held.end());
held.erase(returned, held.end());
}
static const std::unordered_set<std::string> two_path_fs_events{
@@ -717,12 +794,15 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
{"socket.getnameinfo", {0}},
{"socket.getservbyname", {0}},
{"socket.getservbyport", {0}},
{"socket.sendto", {1}},
{"socket.sendmsg", {1}},
{"os.system", {0}},
{"subprocess.Popen", {1, 0}},
{"os.posix_spawn", {1, 0}},
{"os.spawn", {2, 1}},
{"os.startfile", {0}},
{"os.startfile/2", {0}},
{"pty.spawn", {0}},
{"_winapi.CreateProcess", {1, 0}},
{"_posixsubprocess.fork_exec", {0}},
@@ -767,7 +847,11 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) {
for (const Py_ssize_t index : it->second) {
std::string value = python_str(tuple_item(args, index));
// None is an argument left out (sendmsg without an address), not a target named "None".
PyObject* const item = tuple_item(args, index);
if (item == Py_None)
continue;
std::string value = python_str(item);
if (!value.empty()) {
targets.push_back(std::move(value));
break;
@@ -778,8 +862,14 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
return targets;
}
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions)
// The saved list that grants an event: its category's target list, or the event-grant list for
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
// os.fork grant is not an os.exec of a command named "os.fork").
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
{
if (by_event)
return &permissions.events;
switch (category) {
case AuditEventCategory::FsRead: return &permissions.fs_read;
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
@@ -792,6 +882,13 @@ std::vector<std::string>* permission_list_for(AuditEventCategory category, Plugi
}
}
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
{
return std::string(category_name(category)) + ":" + event_name;
}
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
{
return std::find(granted.begin(), granted.end(), target) != granted.end();
@@ -902,22 +999,25 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
// freshly because the async prompt outlives the caller's stack copy of it.
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets)
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
{
PluginInstallState state;
if (!PluginManager::instance().get_install_state(plugin_key, state))
return;
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions);
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
if (!permission_list)
return;
for (const auto& target : targets)
persist_permission(plugin_key, state, *permission_list, target);
for (const auto& key : keys)
persist_permission(plugin_key, state, *permission_list, key);
}
// Requests permission for an audited event, returning true when it is already granted or the user
// approves an inline prompt.
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
// no target (by_event), which the prompt does not show as a target.
//
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
@@ -929,56 +1029,205 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
// re-prompting.
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
const std::string& event_name, const std::vector<std::string>& unresolved)
const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
{
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown);
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
if (wxIsMainThread())
return prompt_for_targets(category, plugin_name, event_name, unresolved);
return prompt_for_targets(category, plugin_name, event_name, shown);
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() {
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return;
if (prompt_for_targets(category, plugin_name, event_name, unresolved))
persist_grant(plugin_key, category, unresolved);
if (prompt_for_targets(category, plugin_name, event_name, shown))
persist_grant(plugin_key, category, by_event, keys);
});
return false;
}
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
// otherwise the user is asked and a Yes is saved.
int decide_audited_event(PluginAuditManager& mgr,
PluginInstallState& state,
const std::string& plugin_key,
const std::string& plugin_name,
const std::string& event_name,
AuditEventCategory category,
const std::vector<std::string>& targets,
std::vector<std::string>* permission_list,
const std::vector<std::string>& call_site_ids)
const std::vector<std::string>& targets)
{
std::vector<std::string> unresolved = targets;
const bool by_event = targets.empty();
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
std::vector<std::string> unresolved = keys;
if (permission_list) {
unresolved.clear();
for (const auto& target : targets)
if (!has_permission(*permission_list, target))
unresolved.push_back(target);
if (!targets.empty() && unresolved.empty())
for (const auto& key : keys)
if (!has_permission(*permission_list, key))
unresolved.push_back(key);
if (unresolved.empty())
return 0;
}
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved))
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
// where no dialog may open: what they do must already be granted.
if (mgr.releasing_frames())
return report_denied(mgr, event_name, {false, "audit permission required"});
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
return report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list)
for (const auto& target : unresolved)
persist_permission(plugin_key, state, *permission_list, target);
mgr.record_approved_call_sites(plugin_key, call_site_ids);
for (const auto& key : unresolved)
persist_permission(plugin_key, state, *permission_list, key);
return 0;
}
// Judges an audited event raised while a plugin context is active.
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
{
const bool fs_category = is_fs_category(event_type);
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
// unconditional block: checked before the running-operation check below, so no granted
// operation can launder access to one, and before the allowed-root shortcut further down,
// so an allowed root (e.g. the read-only resources folder) cannot make a denied path
// underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr.is_denied_path(boost::filesystem::path(target)))
return report_denied(mgr, event_name, {false, "denied path"}, target);
}
}
// A nested event of an operation already granted, e.g. socket.connect inside an approved
// urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a
// redirect or a nested urlopen() inside a running request is not covered by it.
if (event_name != "urllib.Request" && mgr.covered_by_root_frame())
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
PluginInstallState state;
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
if (!have_install_state) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
}
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets);
// A granted operation covers its nested events while it runs. A targetless grant names no
// operation, so it covers nothing else.
if (result == 0 && !targets.empty() && operation_root_events.count(event_name))
mgr.record_root_frame();
return result;
}
} // namespace PluginAuditDetail
void PluginAuditManager::record_root_frame()
{
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
if (!frame || !PluginAuditDetail::is_stdlib_frame(frame))
return;
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return; // glob.glob and glob.glob/2 share a frame
Py_INCREF(frame);
m_root_frames.push_back(frame);
}
bool PluginAuditManager::covered_by_root_frame() const
{
if (m_root_frames.empty())
return false;
// Only stdlib frames may sit between the event and the operation: plugin or library code
// the operation calls back into (an os.walk onerror, a urllib handler) acts on its own.
for (PyObject* frame : PluginAuditDetail::stack_frames()) {
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return true;
if (!PluginAuditDetail::is_stdlib_frame(frame))
return false;
}
return false;
}
bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; }
bool PluginAuditManager::is_stdlib_file(const std::string& filename)
{
// Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename.
if (boost::algorithm::starts_with(filename, "<frozen "))
return true;
if (filename.empty() || filename.front() == '<')
return false;
// Both sides are resolved: co_filename follows the Python home as it was given, which may
// be a symlink.
const auto [it, inserted] = PluginAuditDetail::s_stdlib_file_verdicts.try_emplace(filename, false);
if (inserted) {
const boost::filesystem::path file(filename);
const auto inside_any = [&file](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); });
};
it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
return it->second;
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{
if (frames.empty())
return;
// A finalizer's own denial must neither replace the PermissionError of the event being
// decided nor leave a stale denial behind for the next call.
PyObject* const error = PyErr_GetRaisedException();
const bool denial_pending = m_audit_denial_pending;
const bool has_violation = m_has_last_violation;
const AuditViolation violation = m_last_violation;
std::vector<PyObject*> released;
released.swap(frames);
++m_releasing_frames;
for (PyObject* frame : released)
Py_DECREF(frame);
--m_releasing_frames;
m_audit_denial_pending = denial_pending;
m_has_last_violation = has_violation;
m_last_violation = violation;
PyErr_SetRaisedException(error);
}
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
{
PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt);
}
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{
auto* mgr = static_cast<PluginAuditManager*>(user_data);
@@ -1006,62 +1255,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
if (event_type == AuditEventCategory::None)
return 0;
const bool fs_category = is_fs_category(event_type);
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
// unconditional block: checked before the ancestor-cascade check below, so a cascade
// approval recorded for an unrelated action can never launder access to one, and before
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources
// folder) cannot make a denied path underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr->is_denied_path(boost::filesystem::path(target)))
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target);
}
}
PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
// was already approved, socket.connect won't trigger another permission dialog.
const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
PluginInstallState state;
const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state);
if (!have_install_state) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin();
}
const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name;
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name,
event_type, targets, permission_list, call_site_ids);
// Root frames whose operation has returned are let go once this event is decided, so that a
// finalizer they run re-enters a hook whose held frames are already consistent.
std::vector<PyObject*> returned_frames;
PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
mgr->release_frames(returned_frames);
return result;
}
void PluginAuditManager::install_hook()
{
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
PluginAuditDetail::collect_stdlib_dirs();
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It
// covers the plugin system's own workflow needs -- each plugin's storage folder
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
+34 -13
View File
@@ -4,10 +4,9 @@
// Via pybind11 so this file requests the same python3xx.lib as everything else.
#include <boost/filesystem/path.hpp>
#include <pybind11/conduit/wrap_include_python_h.h>
#include <functional>
#include <mutex>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <boost/filesystem.hpp>
@@ -144,15 +143,26 @@ public:
void clear_last_violation();
bool last_violation(AuditViolation& violation) const;
// --- call-site cascade cache ---
// A single plugin action often fires several nested CPython audit events as it passes
// through stdlib layers (urllib.request calling http.client calling socket, for example).
// Once the user approves one event, every stdlib frame still on the stack for that call
// is recorded here by (filename, function, first line) identity. A later event whose own
// ancestor chain still contains one of those frames is the same logical action seen from
// a deeper layer, so it is auto-approved instead of prompting again.
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
// --- operation-root frames ---
// One stdlib operation fires several nested audit events as it passes through lower layers:
// urlopen() raises urllib.Request, then http.client and socket events. When an operation's
// own root event is granted (by a saved grant or the user), the stdlib frame that raised it
// is held, and a later event whose stack reaches that frame through stdlib frames only is
// part of the same operation, so it is allowed without a prompt of its own. Plugin or
// library code the operation calls back into is judged on its own. A frame is let go once it
// returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
// run finalizers of objects its locals held; they are judged as the plugin and never prompt.
void record_root_frame();
bool covered_by_root_frame() const;
bool releasing_frames() const;
// True when code from `filename` (a code object's co_filename) is the interpreter's own
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
static bool is_stdlib_file(const std::string& filename);
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
bool verbose_events = true;
@@ -163,24 +173,32 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data);
// Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames);
static thread_local std::string m_current_plugin_key;
static thread_local std::string m_current_capability_name;
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
static thread_local std::vector<PyObject*> m_root_frames; // strong references
static thread_local int m_releasing_frames;
static thread_local bool m_audit_denial_pending;
static thread_local bool m_has_last_violation;
static thread_local AuditViolation m_last_violation;
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
// mutable: is_denied_filename() is a const query that must lock.
mutable std::mutex m_mutex;
std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords;
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
};
// RAII guard that sets the current plugin key and capability name, restoring the previous
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
// single capability.
//
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
// the scope after the GIL guard, to be destroyed before it.
class ScopedPluginAuditContext
{
public:
@@ -194,9 +212,12 @@ public:
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
private:
std::string m_plugin_key;
std::string m_capability;
std::string m_previous_id;
std::string m_previous_capability;
std::vector<AllowedRoot> m_previous_scoped_roots;
std::vector<PyObject*> m_previous_root_frames;
};
} // namespace Slic3r
+2
View File
@@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
read_string_list("network_socket", parsed.permissions.network_socket);
read_string_list("process", parsed.permissions.process);
read_string_list("threading", parsed.permissions.threading);
read_string_list("events", parsed.permissions.events);
}
if (state.contains("enabled") && state["enabled"].is_boolean())
@@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
{"network_socket", state.permissions.network_socket},
{"process", state.permissions.process},
{"threading", state.permissions.threading},
{"events", state.permissions.events},
};
nlohmann::json capabilities = nlohmann::json::array();
+2
View File
@@ -104,6 +104,8 @@ struct PluginPermissions
std::vector<std::string> network_socket;
std::vector<std::string> process;
std::vector<std::string> threading;
// Grants for audit events that expose no target, keyed "<category>:<event>" (e.g. "Socket:socket.__new__").
std::vector<std::string> events;
};
struct PluginInstallState {
+1 -1
View File
@@ -102,9 +102,9 @@ public:
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
nlohmann::json get_default_config() const override
{
pybind11::gil_scoped_acquire gil;
ORCA_PY_AUDIT_SCOPE();
try {
pybind11::gil_scoped_acquire gil;
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
if (!override)
return Base::get_default_config();
@@ -282,11 +282,11 @@ public:
int request_bind_ticket(std::string* ticket) override
{
try {
ORCA_PY_AUDIT_SCOPE();
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
::Slic3r::PythonGILState gil;
if (!gil)
throw std::runtime_error("Python interpreter is shutting down");
ORCA_PY_AUDIT_SCOPE();
pybind11::function override =
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
if (!override)
+121
View File
@@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
}
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
using Catch::Matchers::WithinAbs;
SECTION("float") {
ConfigOptionFloat opt(7.5);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
}
SECTION("int") {
ConfigOptionInt opt(3);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK(opt.value == 3);
}
SECTION("percent") {
ConfigOptionPercent opt(40.);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
}
SECTION("float or percent") {
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
CHECK_FALSE(opt.percent);
}
SECTION("point") {
ConfigOptionPoint opt(Vec2d(1., 2.));
REQUIRE_FALSE(opt.deserialize("5,abc"));
CHECK(opt.value == Vec2d(1., 2.));
}
SECTION("point3") {
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
CHECK(opt.value == Vec3d(1., 2., 3.));
}
}
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
using Catch::Matchers::WithinAbs;
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE(opt.deserialize("50%"));
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
CHECK(opt.percent);
ConfigOptionPoint point;
REQUIRE(point.deserialize("3x4"));
CHECK(point.value == Vec2d(3., 4.));
}
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys;
@@ -1678,3 +1727,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
}
CHECK(enum_lists > 0);
}
TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]")
{
std::set<std::string> no_keys;
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_retract_before_wipe", "100%");
parent.set_deserialize_strict("filament_ironing_flow", "15%");
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
child.set_deserialize_strict("filament_ironing_flow", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil");
CHECK(parent.opt_serialize("filament_ironing_flow") == "nil");
}
SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]")
{
std::set<std::string> no_keys;
GIVEN("A two-variant filament parent") {
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%");
parent.set_deserialize_strict("filament_flow_ratio", "1,1");
WHEN("the child disables the override on one variant and leaves a plain option nil on it") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%");
child.set_deserialize_strict("filament_flow_ratio", "nil,1.1");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("the override column is disabled while the plain option's nil column inherits") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child lists the same variants in the other order") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil");
child.set_deserialize_strict("filament_flow_ratio", "1.1,nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("columns are matched by variant name, the disabled one included") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child was saved with a single variant") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("its one value applies to every variant of that extruder, as slicing does") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil");
}
}
}
}
TEST_CASE("Every listed filament override is nullable", "[Config]")
{
std::set<std::string> overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end());
overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" });
for (const auto &[key, def] : Slic3r::print_config_def.options) {
INFO(key);
CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1));
if (overrides.count(key) == 1)
CHECK(def.nullable);
}
}
@@ -2353,6 +2353,105 @@ TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament
}
}
namespace {
// One system "Generic PLA" the sync can resolve a reported "PLA" tray to, with a real
// filament_id so a tray that does report one still maps through the known-id path.
void add_generic_pla(PresetBundle &bundle)
{
Preset &pla = add_inmemory_preset(bundle.filaments, "Generic PLA");
pla.is_system = true;
pla.is_compatible = true;
pla.filament_id = "GFL99";
}
// A device tray as build_filament_ams_list builds it: a reported type, color and loaded flag,
// an optional preset id, and the placeholder flag pull-mode agents set on empty slots.
DynamicPrintConfig ams_tray(const std::string &filament_id, const std::string &filament_type, bool exist, bool placeholder)
{
DynamicPrintConfig tray;
tray.set_key_value("filament_id", new ConfigOptionStrings{filament_id});
tray.set_key_value("filament_type", new ConfigOptionStrings{filament_type});
tray.set_key_value("filament_colour", new ConfigOptionStrings{"#FF0000"});
tray.set_key_value("filament_colour_type", new ConfigOptionStrings{"1"});
tray.set_key_value("filament_multi_colour", new ConfigOptionStrings{});
tray.set_key_value("filament_exist", new ConfigOptionBools{exist});
tray.set_key_value("filament_slot_placeholder", new ConfigOptionBools{placeholder});
tray.set_key_value("ams_id", new ConfigOptionStrings{"0"});
tray.set_key_value("slot_id", new ConfigOptionStrings{"0"});
return tray;
}
// Run the mapped sync over the bundle's single tray and return the filament count it reports,
// which is what the sidebar uses to rebuild the Prepare filament list.
unsigned int sync_one_tray(PresetBundle &bundle, bool enable_append)
{
std::vector<std::pair<DynamicPrintConfig *, std::string>> unknowns;
std::map<int, AMSMapInfo> maps;
MergeFilamentInfo merge;
return bundle.sync_ams_list(unknowns, /*use_map=*/true, maps, enable_append, merge, /*color_only=*/false);
}
} // namespace
// A generic device reports type/color/loaded for a spool but no preset id. The Device tab shows
// that spool, so the Prepare sidebar has to count it: the sync appends the generic preset its
// reported type resolves to. A tray that only carried topology (no type, not loaded) must not.
TEST_CASE("A data-bearing AMS tray without a preset id appends a generic filament on sync", "[Preset][Bundle]")
{
PresetBundle bundle;
add_generic_pla(bundle);
bundle.set_num_filaments(1, std::string("#00FF00"));
bundle.filament_ams_list[0] = ams_tray(/*filament_id=*/{}, "PLA", /*exist=*/true, /*placeholder=*/false);
const size_t before = bundle.filament_presets.size();
const auto count = sync_one_tray(bundle, /*enable_append=*/true);
CHECK(count == before + 1);
CHECK(bundle.filament_presets.back() == "Generic PLA");
}
TEST_CASE("An empty synthetic AMS tray does not append a filament on sync", "[Preset][Bundle]")
{
PresetBundle bundle;
add_generic_pla(bundle);
bundle.set_num_filaments(1, std::string("#00FF00"));
bundle.filament_ams_list[0] = ams_tray(/*filament_id=*/{}, /*filament_type=*/{}, /*exist=*/false, /*placeholder=*/false);
const size_t before = bundle.filament_presets.size();
const auto count = sync_one_tray(bundle, /*enable_append=*/true);
CHECK(count == before);
}
TEST_CASE("An empty AMS placeholder does not append a filament on sync", "[Preset][Bundle]")
{
PresetBundle bundle;
add_generic_pla(bundle);
bundle.set_num_filaments(1, std::string("#00FF00"));
bundle.filament_ams_list[0] = ams_tray(/*filament_id=*/{}, /*filament_type=*/{}, /*exist=*/false, /*placeholder=*/true);
const size_t before = bundle.filament_presets.size();
const auto count = sync_one_tray(bundle, /*enable_append=*/true);
CHECK(count == before);
}
// The append preference still governs a data-bearing tray: with append off the sidebar count is
// unchanged, exactly as for a mapped-but-unlisted tray.
TEST_CASE("The append preference gates a data-bearing AMS tray without a preset id", "[Preset][Bundle]")
{
PresetBundle bundle;
add_generic_pla(bundle);
bundle.set_num_filaments(1, std::string("#00FF00"));
bundle.filament_ams_list[0] = ams_tray(/*filament_id=*/{}, "PLA", /*exist=*/true, /*placeholder=*/false);
const size_t before = bundle.filament_presets.size();
const auto count = sync_one_tray(bundle, /*enable_append=*/false);
CHECK(count == before);
}
// A mix is described by 1-based indices into the project's filament list, which Orca rebuilds
// from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every
// printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up
@@ -8417,3 +8516,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
}
TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]")
{
ScopedPresetDataDir data_dir;
PresetBundle bundle;
Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System");
parent.config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" };
parent.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values = { 100., 100. };
parent.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1., 1. };
// The child turns the override off on the first variant and changes a plain option on the second.
const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json";
Preset child(Preset::TYPE_FILAMENT, "Child PLA");
child.file = file.string();
child.version = *Semver::parse("1.0.0");
child.config = parent.config;
child.config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = parent.name;
child.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe")->set_at_to_nil(0);
child.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 1., 1.1 };
REQUIRE(child.save(&parent.config));
// On disk the override keeps every column, so its nil can only mean "off"; a plain
// option still stores nil for the columns that match the parent.
DynamicPrintConfig saved;
std::map<std::string, std::string> key_values;
std::string reason;
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
REQUIRE(reason.empty());
CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1");
PresetsConfigSubstitutions substitutions;
bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::EnableSilent);
const Preset *loaded = bundle.filaments.find_preset("Child PLA");
REQUIRE(loaded != nullptr);
CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1");
}
TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]")
{
PresetBundle bundle;
add_inmemory_preset(bundle.filaments, "Parent PLA @System");
Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System");
// Every override is nil in both parent and child, so none is in the plain diff.
std::map<std::string, std::string> key_values;
REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0);
// Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent.
CHECK(key_values.count("filament_retract_before_wipe") == 1);
CHECK(key_values["filament_retract_before_wipe"] == "nil");
// An ironing override defaults to nil: an absent key stays nil, so it is not sent.
CHECK(key_values.count("filament_ironing_flow") == 0);
}
+1
View File
@@ -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_device_access.cpp
test_moonraker_printer_agent.cpp
test_network_discovery.cpp
@@ -0,0 +1,161 @@
// 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 <cstddef>
// 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 "catch2/catch_test_macros.hpp"
#include "catch2/catch_message.hpp"
#include <slic3r/GUI/DeviceCore/DevDefs.h>
#include <string>
#include <utility>
#include <vector>
#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 <wx/timer.h>
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/DeviceCore/DevExtruderSystem.h"
#include <nlohmann/json.hpp>
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<int>(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<int>{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);
}
}
TEST_CASE("Legacy single-toolhead parser fills only the main extruder", "[DevExtderSystem]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
REQUIRE(obj.GetExtderSystem()->GetTotalExtderCount() == 1);
// ParseV1_0 is the legacy single/dual fallback. It returns unless the system reports exactly
// one toolhead, so it only ever writes MAIN_EXTRUDER_ID's fields.
const json legacy = {
{"nozzle_temper", 210},
{"nozzle_target_temper", 245},
{"ams", {{"tray_now", "2"}, {"tray_tar", "3"}}}
};
ExtderSystemParser::ParseV1_0(legacy, obj.GetExtderSystem());
const auto ext = obj.GetExtderSystem()->GetExtderById(MAIN_EXTRUDER_ID);
REQUIRE(ext.has_value());
CHECK(ext->GetCurrentTemp() == 210);
CHECK(ext->GetTargetTemp() == 245);
// Below 0x80 the tray pointer encodes ams_id = value / 4, slot_id = value % 4.
CHECK(ext->GetSlotNow().ams_id == "0");
CHECK(ext->GetSlotNow().slot_id == "2");
CHECK(ext->GetSlotTarget().ams_id == "0");
CHECK(ext->GetSlotTarget().slot_id == "3");
CHECK(obj.GetExtderSystem()->IsBusyLoading());
CHECK(obj.GetExtderSystem()->GetLoadingExtderId() == MAIN_EXTRUDER_ID);
}
// ParseV2_0 does not enforce the documented extruder dialect invariants: the count in `state` and
// the id ordering of `info[]` are trusted, not checked. This test pins the resulting behavior.
TEST_CASE("Malformed extruder payload is stored by info array position", "[DevExtderSystem]")
{
const auto extder_info = [](int id) {
// Every key ParseV2_0 reads must be present.
return json{ {"id", id}, {"filam_bak", json::array()}, {"info", 0}, {"temp", 0},
{"spre", 0}, {"snow", 0}, {"star", 0}, {"stat", 0}, {"hnow", 0} };
};
SECTION("state count disagrees with info length") {
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// `state` reports 2 toolheads but info[] carries 3.
json info = json::array();
for (int id = 0; id < 3; ++id)
info.push_back(extder_info(id));
ExtderSystemParser::ParseV2_0(json{ {"state", 2}, {"info", info} }, obj.GetExtderSystem());
// The info array wins: three toolheads are stored. m_total_extder_count still holds 2, so
// GetTotalExtderCount() would trip its debug assert; only the stored vector is inspected.
CHECK(obj.GetExtderSystem()->GetTotalExtderSize() == 3);
for (int id = 0; id < 3; ++id) {
const auto ext = obj.GetExtderSystem()->GetExtderById(id);
REQUIRE(ext.has_value());
CHECK(ext->GetExtId() == id);
}
}
SECTION("info id differs from the array index") {
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// ids 1 and 2 sit at array positions 0 and 1.
json info = json::array({extder_info(1), extder_info(2)});
ExtderSystemParser::ParseV2_0(json{ {"state", 2}, {"info", info} }, obj.GetExtderSystem());
// Toolheads are stored by array position; the payload id is kept but is not the lookup key.
const auto first = obj.GetExtderSystem()->GetExtderById(0);
REQUIRE(first.has_value());
CHECK(first->GetExtId() == 1);
const auto second = obj.GetExtderSystem()->GetExtderById(1);
REQUIRE(second.has_value());
CHECK(second->GetExtId() == 2);
}
}
+380 -1
View File
@@ -13,10 +13,15 @@
#include <Windows.h>
#endif
#include <cstddef>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "slic3r/GUI/DeviceCore/DevDefs.h"
#include "libslic3r/ProjectTask.hpp"
#include <vector>
#include <catch2/catch_message.hpp>
#include <catch2/catch_all.hpp>
@@ -65,6 +70,209 @@ TEST_CASE("AMS tray placeholder state follows the latest status", "[DevFilaSyste
CHECK_FALSE(tray->is_slot_placeholder);
}
TEST_CASE("Agent filament slots preserve every extruder and AMS binding", "[DevFilaSystem]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
const json filament = json::parse(R"({
"units": [
{ "id": "0", "extruder": 0, "slots": [
{ "index": 0, "loaded": true, "material": "PLA", "color": "FF5733FF" },
{ "index": 1, "loaded": true, "material": "PLA", "color": "33C1FFFF" },
{ "index": 2, "loaded": true, "material": "PLA", "color": "8D33FFFF" },
{ "index": 3, "loaded": false, "material": "", "color": "00000000" }
] },
{ "id": "1", "extruder": 1, "slots": [
{ "index": 0, "loaded": true, "material": "ASA", "color": "33C1FFFF" },
{ "index": 1, "loaded": true, "material": "ASA", "color": "8D33FFFF" },
{ "index": 2, "loaded": true, "material": "ASA", "color": "FF5733FF" },
{ "index": 3, "loaded": false, "material": "", "color": "00000000" }
] },
{ "id": "2", "extruder": 2, "slots": [
{ "index": 0, "loaded": true, "material": "ABS", "color": "8D33FFFF" },
{ "index": 1, "loaded": true, "material": "ABS", "color": "FF5733FF" },
{ "index": 2, "loaded": true, "material": "ABS", "color": "33C1FFFF" },
{ "index": 3, "loaded": false, "material": "", "color": "00000000" }
] },
{ "id": "3", "extruder": 3, "slots": [
{ "index": 0, "loaded": true, "material": "PETG", "color": "FF5733FF" },
{ "index": 1, "loaded": true, "material": "PETG", "color": "33C1FFFF" },
{ "index": 2, "loaded": true, "material": "PETG", "color": "8D33FFFF" },
{ "index": 3, "loaded": false, "material": "", "color": "00000000" }
] }
],
"external": [
{ "extruder": 3, "loaded": true, "material": "PETG", "preset_id": "P3" },
{ "extruder": 0, "loaded": true, "material": "PLA", "preset_id": "P0" },
{ "extruder": 2, "loaded": false, "material": "ABS", "preset_id": "P2" },
{ "extruder": 1, "loaded": true, "material": "ASA", "preset_id": "P1" }
]
})");
DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get());
REQUIRE(obj.vt_slot.size() == 4);
CHECK(obj.vt_slot[0].id == "255");
CHECK(obj.vt_slot[1].id == "254");
CHECK(obj.vt_slot[2].id == "253");
CHECK(obj.vt_slot[3].id == "252");
CHECK(obj.vt_slot[0].setting_id == "P0");
CHECK(obj.vt_slot[1].setting_id == "P1");
CHECK(obj.vt_slot[2].setting_id == "P2");
CHECK(obj.vt_slot[3].setting_id == "P3");
REQUIRE(obj.GetFilaSystem()->GetAmsCount() == 4);
for (int extruder = 0; extruder < 4; ++extruder) {
const auto& ams = obj.GetFilaSystem()->GetAmsList().at(std::to_string(extruder));
CHECK(ams->GetExtruderId() == extruder);
CHECK(ams->GetBindedExtruderSet() == std::set<int>{extruder});
REQUIRE(ams->GetTrays().size() == 4);
CHECK(ams->GetTrays().at("0")->color != ams->GetTrays().at("1")->color);
CHECK(ams->GetTrays().at("1")->color != ams->GetTrays().at("2")->color);
CHECK_FALSE(ams->GetTrays().at("3")->is_exists);
}
CHECK(obj.contains_tray("253", "0"));
CHECK(obj.get_tray("253", "0").setting_id == "P2");
CHECK(obj.GetFilaSystem()->GetExtruderIdByAmsId("253") == 2);
const auto tray_index_map = obj.GetFilaSystem()->GetTrayIndexMap();
REQUIRE(tray_index_map.count(252) == 1);
CHECK(tray_index_map.at(252).first == 252);
CHECK(tray_index_map.at(252).second == 0);
}
TEST_CASE("External virtual tray slots cover each extruder without shifting existing data", "[DevFilaSystem]")
{
std::vector<DevAmsTray> slots;
slots.emplace_back("255");
slots[0].setting_id = "main-spool";
// Ensure slots for three extruders, preserving the main spool's reported metadata.
for (int extruder_id = 0; extruder_id < 3; ++extruder_id) {
grow_virtual_tray_slots(slots, static_cast<std::size_t>(VIRTUAL_TRAY_MAIN_ID - extruder_id));
}
REQUIRE(slots.size() == 3);
CHECK(slots[0].id == "255");
CHECK(slots[1].id == "254");
CHECK(slots[2].id == "253");
CHECK(slots[0].setting_id == "main-spool");
// Repeating the topology update is idempotent and leaves existing tray data intact.
slots[2].setting_id = "third-spool";
for (int extruder_id = 0; extruder_id < 3; ++extruder_id) {
grow_virtual_tray_slots(slots, static_cast<std::size_t>(VIRTUAL_TRAY_MAIN_ID - extruder_id));
}
REQUIRE(slots.size() == 3);
CHECK(slots[2].setting_id == "third-spool");
}
TEST_CASE("Sparse external spool reports retain their extruder slot IDs", "[DevFilaSystem]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
const json filament = {
{"units", json::array()},
{"external", json::array({
{{"extruder", 2}, {"loaded", true}, {"material", "PETG"}, {"preset_id", "third-spool"}},
{{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"preset_id", "main-spool"}}
})}
};
DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get());
REQUIRE(obj.vt_slot.size() == 3);
CHECK(obj.vt_slot[0].id == "255");
CHECK(obj.vt_slot[0].setting_id == "main-spool");
CHECK(obj.vt_slot[1].id == "254");
CHECK(obj.vt_slot[2].id == "253");
CHECK(obj.vt_slot[2].setting_id == "third-spool");
}
TEST_CASE("Generic AMS tray index map uses cumulative lane counts", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// A well-formed generic payload pairs "units" with "external". An empty external array resets
// vt_slot, clearing the default virtual tray (255) MachineObject's constructor seeds; otherwise
// that phantom external tray lands in GetTrayIndexMap and inflates the map size.
json filament = { {"units", json::array()}, {"external", json::array()} };
for (int ams_id = 0; ams_id < 2; ++ams_id) {
json unit = {
{"id", std::to_string(ams_id)},
{"extruder", ams_id},
{"slots", json::array()}
};
for (int slot_id = 0; slot_id < 5; ++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() == 10);
CHECK(tray_index_map.at(0).first == 0);
CHECK(tray_index_map.at(0).second == 0);
CHECK(tray_index_map.at(4).first == 0);
CHECK(tray_index_map.at(4).second == 4);
CHECK(tray_index_map.at(5).first == 1);
CHECK(tray_index_map.at(5).second == 0);
CHECK(tray_index_map.at(9).first == 1);
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<int> slot_counts = {4, 12};
for (int ams_id = 0; ams_id < static_cast<int>(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<int> slot_counts = {8, 1};
for (int ams_id = 0; ams_id < static_cast<int>(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");
@@ -232,3 +440,174 @@ TEST_CASE("Switch-bound AMS with an invalid track is excluded from mapping", "[D
DevFilaSystemParser::ParseV1_0(print_push, &obj_no_switch, obj_no_switch.GetFilaSystem().get(), false);
REQUIRE(obj_no_switch.GetFilaSystem()->GetAmsList().count("0") == 0);
}
TEST_CASE("Generic external spools map by their virtual tray id", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// One external spool per extruder: 255 -> extruder 0, 254 -> 1, 253 -> 2, 252 -> 3.
const json filament = {
{"units", json::array()},
{"external", json::array({
{{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"color", "FF5733FF"}, {"preset_id", "P0"}},
{{"extruder", 1}, {"loaded", true}, {"material", "ASA"}, {"color", "33C1FFFF"}, {"preset_id", "P1"}},
{{"extruder", 2}, {"loaded", true}, {"material", "ABS"}, {"color", "8D33FFFF"}, {"preset_id", "P2"}},
{{"extruder", 3}, {"loaded", true}, {"material", "PETG"}, {"color", "44AA22FF"}, {"preset_id", "P3"}}
})}
};
DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get());
REQUIRE(obj.vt_slot.size() == 4);
REQUIRE(obj.vt_slot[2].id == "253");
// Matches only extruder 2's external spool (ABS / 8D33FFFF).
FilamentInfo fila;
fila.id = 0;
fila.type = "ABS";
fila.color = "8D33FFFF";
std::vector<FilamentInfo> result;
std::vector<bool> map_opt(4, false);
map_opt[MappingOption::USE_LEFT_EXT] = true;
map_opt[MappingOption::USE_RIGHT_EXT] = true;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false);
REQUIRE(result.size() == 1);
// The external spool keeps its virtual tray id (253), never the AMS ams_id * 4 + slot_id form.
CHECK(result[0].tray_id == 253);
CHECK(result[0].ams_id == "253");
}
TEST_CASE("Mapping validation accepts only reported generic virtual slots", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
const json filament = {
{"units", json::array()},
{"external", json::array({
{{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"preset_id", "P0"}},
{{"extruder", 1}, {"loaded", true}, {"material", "ASA"}, {"preset_id", "P1"}},
{{"extruder", 2}, {"loaded", true}, {"material", "ABS"}, {"preset_id", "P2"}}
})}
};
DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get());
REQUIRE(obj.vt_slot.size() == 3);
FilamentInfo reported;
reported.id = 0;
reported.tray_id = 253;
reported.ams_id = "253"; // extruder 2's external slot, present in vt_slot
reported.slot_id = "0";
{
std::vector<FilamentInfo> result{reported};
CHECK(DevMappingUtil::is_valid_mapping_result(&obj, result, false));
}
// A virtual id the printer never reported is rejected and reset.
FilamentInfo missing;
missing.id = 1;
missing.tray_id = 252;
missing.ams_id = "252";
missing.slot_id = "0";
{
std::vector<FilamentInfo> result{missing};
CHECK_FALSE(DevMappingUtil::is_valid_mapping_result(&obj, result, false));
CHECK(result[0].tray_id == -1);
}
}
TEST_CASE("Generic AMS units on every extruder join the mapping", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
const std::vector<std::string> materials = {"PLA", "ASA", "ABS", "PETG", "TPU"};
const std::vector<std::string> colors = {"FF5733FF", "33C1FFFF", "8D33FFFF", "44AA22FF", "FFAA00FF"};
json filament = { {"units", json::array()}, {"external", json::array()} };
for (int extruder = 0; extruder < 5; ++extruder) {
json unit = { {"id", std::to_string(extruder)}, {"extruder", extruder}, {"slots", json::array()} };
unit["slots"].push_back({{"index", 0}, {"loaded", true}, {"material", materials[extruder]}, {"color", colors[extruder]}});
filament["units"].push_back(std::move(unit));
}
DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get());
REQUIRE(obj.GetFilaSystem()->GetAmsCount() == 5);
// PETG / 44AA22FF is extruder 3's AMS slot, with cumulative generic tray index 3.
FilamentInfo fila;
fila.id = 0;
fila.type = "PETG";
fila.color = "44AA22FF";
// The sidebar sync enables both legacy AMS flags, so every extruder participates.
std::vector<FilamentInfo> result;
std::vector<bool> map_opt(4, false);
map_opt[MappingOption::USE_LEFT_AMS] = true;
map_opt[MappingOption::USE_RIGHT_AMS] = true;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false);
REQUIRE(result.size() == 1);
CHECK(result[0].ams_id == "3");
CHECK(result[0].tray_id == 3);
// SelectMachine's right-side split passes only the right flag: extruder 3's unit stays out and
// nothing in extruder 0's unit matches PETG, so the filament is left unmapped.
std::vector<FilamentInfo> split_result;
map_opt[MappingOption::USE_LEFT_AMS] = false;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, split_result, map_opt, {}, false);
REQUIRE(split_result.size() == 1);
CHECK(split_result[0].tray_id == -1);
}
TEST_CASE("Generic external spool is suppressed when its extruder has AMS filament", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
// One AMS unit on extruder 2 (red ABS) plus one external spool per extruder; extruder 2's
// external spool is green ABS, which is what the sliced filament matches exactly.
const json filament = {
{"units", json::array({
{{"id", "2"}, {"extruder", 2}, {"slots", json::array({
{{"index", 0}, {"loaded", true}, {"material", "ABS"}, {"color", "FF0000FF"}}
})}}
})},
{"external", json::array({
{{"extruder", 0}, {"loaded", true}, {"material", "PLA"}, {"color", "FF5733FF"}},
{{"extruder", 1}, {"loaded", true}, {"material", "ASA"}, {"color", "33C1FFFF"}},
{{"extruder", 2}, {"loaded", true}, {"material", "ABS"}, {"color", "00FF00FF"}},
{{"extruder", 3}, {"loaded", true}, {"material", "PETG"}, {"color", "44AA22FF"}},
{{"extruder", 4}, {"loaded", true}, {"material", "TPU"}, {"color", "FFAA00FF"}}
})}
};
DevFilaSystemParser::ParseAgentFilament(filament, &obj, obj.GetFilaSystem().get());
REQUIRE(obj.vt_slot.size() == 5);
REQUIRE(obj.vt_slot[2].id == "253");
FilamentInfo fila;
fila.id = 0;
fila.type = "ABS";
// FilamentInfo.color carries the "#RRGGBBAA" form the GUI builds for a sliced filament
// (SelectMachine/SyncAmsInfoDialog); tray colors are the bare RRGGBBAA DevAmsTray::decode_color
// consumes. A bare string here is not parsed by wxColour's hex branch and reads as black.
fila.color = "#00FF00FF";
std::vector<bool> map_opt(4, false);
map_opt[MappingOption::USE_LEFT_AMS] = true;
map_opt[MappingOption::USE_RIGHT_AMS] = true;
map_opt[MappingOption::USE_LEFT_EXT] = true;
map_opt[MappingOption::USE_RIGHT_EXT] = true;
// With suppression off the exact-match external spool (253) wins over the red AMS tray.
std::vector<FilamentInfo> result;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, result, map_opt, {}, false);
REQUIRE(result.size() == 1);
CHECK(result[0].tray_id == 253);
CHECK(result[0].ams_id == "253");
// With suppression on, extruder 2 already has AMS filament in this mapping, so its external
// spool is dropped and the AMS tray is the only candidate left. This fixture has only that
// single generic unit, so its cumulative tray index is 0.
std::vector<FilamentInfo> suppressed;
DevMappingUtil::ams_filament_mapping(&obj, {fila}, suppressed, map_opt, {}, true);
REQUIRE(suppressed.size() == 1);
CHECK(suppressed[0].ams_id == "2");
CHECK(suppressed[0].tray_id == 0);
}
+770
View File
@@ -1,22 +1,48 @@
#include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "libslic3r_version.h"
#include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/cast.h>
#include <pybind11/embed.h>
#include <pybind11/eval.h>
#include <pybind11/gil.h>
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <pybind11/stl.h>
#include <fstream>
#include <functional>
#include <ios>
#include <memory>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace py = pybind11;
namespace {
@@ -349,3 +375,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
}
}
// ---------------------------------------------------------------------------
// The audit hook, run in the embedded interpreter
// ---------------------------------------------------------------------------
//
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
// cases instead of using SECTIONs, which would restart the interpreter for every section.
namespace {
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Audit Plugin"
# description = "Plugin audit hook fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
)PY";
// A third-party library, installed outside the stdlib and outside the plugin.
const char* const LIBRARY_SOURCE = R"PY(
import socket
import urllib.request
def read_all(paths):
for path in paths:
with open(path) as f:
f.read()
def connect_from_host(port):
socket.gethostname()
socket.create_connection(("127.0.0.1", port)).close()
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
def fetch_each(urls):
for url in urls:
yield fetch(url)
class ReadingHandler(urllib.request.BaseHandler):
"""Reads a file while each response is processed."""
def __init__(self, path):
self.path = path
self.outcome = None
def http_response(self, request, response):
try:
open(self.path).close()
self.outcome = "ok"
except PermissionError:
self.outcome = "denied"
return response
)PY";
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
// HTTP server, the library, and every module the tests use imported up front, so that no import
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
// attempt() and fetch() stand in for plugin code.
const char* const AUDIT_SETUP = R"PY(
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
import urllib.request, zipfile
if os.name != "nt":
import pty
APPROVED = os.path.join(OUTSIDE, "approved.txt")
PRIVATE = os.path.join(OUTSIDE, "private.txt")
MISSING = os.path.join(OUTSIDE, "missing")
TREE = os.path.join(OUTSIDE, "tree")
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
for path in (APPROVED, PRIVATE):
with open(path, "w") as f:
f.write("x")
for sub in ("one", "two"):
os.makedirs(os.path.join(TREE, sub))
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
f.write("x")
LIBRARY = os.path.join(OUTSIDE, "library")
os.makedirs(LIBRARY)
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
f.write(LIBRARY_SOURCE)
sys.path.insert(0, LIBRARY)
import netlib
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", URL_B)
body = b""
else:
self.send_response(200)
body = b"ok"
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
PORT = server.server_address[1]
BASE = "http://127.0.0.1:%d" % PORT
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
fetch(URL_A) # urllib imports some modules on its first request
def attempt(action):
try:
action()
return "ok"
except PermissionError:
return "denied"
def spawn_checked(argv):
parent = os.getpid()
try:
return pty.spawn(argv)
finally:
if os.getpid() != parent:
os._exit(1) # a child whose exec was refused must not go on running the tests
finalizer_outcomes = []
class TrackedRequest(urllib.request.Request):
def __del__(self):
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY";
struct Prompt
{
std::string plugin;
std::string event;
std::vector<std::string> targets;
};
// One line per prompt, "event: target ...", so a failed check shows every prompt.
std::string describe(const std::vector<Prompt>& prompts)
{
std::string text;
for (const Prompt& prompt : prompts) {
text += prompt.event + ":";
for (const std::string& target : prompt.targets)
text += " " + target;
text += "\n";
}
return text;
}
// The plugin system with the audit hook installed, and the permission dialog replaced by a
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
struct AuditHarness
{
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
ScopedDataDir data{"plugin-audit-hook"};
bool initialized = PluginManager::instance().initialize();
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
std::vector<Prompt> prompts;
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(
[this](const std::string& event, const std::vector<std::string>& targets) {
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
return approve(prompts.back());
});
}
~AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
// Call before taking the GIL: discovery is not Python work.
void add_plugin(const std::string& key)
{
const fs::path root = data.plugins_dir() / key;
fs::create_directories(root);
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
save_permissions(key, {});
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
}
// Grants as if saved in an earlier session.
void save_permissions(const std::string& key, const PluginPermissions& permissions)
{
PluginInstallState state;
state.installed_from = "local";
state.installed_version = "1.0";
state.plugin_name = key;
state.permissions = permissions;
REQUIRE(write_install_state(data.plugins_dir() / key, state));
}
PluginPermissions saved_permissions(const std::string& key) const
{
PluginInstallState state;
read_install_state(data.plugins_dir() / key, state);
return state.permissions;
}
};
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
// server before the interpreter shuts down.
struct PythonSide
{
py::dict globals;
explicit PythonSide(const AuditHarness& harness)
{
globals["OUTSIDE"] = harness.outside.path().string();
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
exec(AUDIT_SETUP);
}
// A Python error fails the test here, as text: an error_already_set left to propagate would
// outlive the interpreter the harness shuts down.
void exec(const char* code)
{
std::string error;
try {
py::exec(code, globals);
} catch (py::error_already_set& raised) {
error = raised.what();
}
if (!error.empty())
FAIL(error);
}
~PythonSide()
{
try {
globals["server"].attr("shutdown")();
globals["server"].attr("server_close")();
} catch (...) {
}
}
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
// audit scope of its own.
void run_as(const std::string& plugin_key, const char* code)
{
ScopedPluginAuditContext audit(plugin_key);
exec(code);
}
};
PluginPermissions http_grants(std::vector<std::string> urls)
{
PluginPermissions permissions;
permissions.network_http = std::move(urls);
return permissions;
}
const char* const PLUGIN = "AuditPlugin";
} // namespace
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
CHECK(python.str("outcome") == "ok");
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
python.run_as(PLUGIN, R"PY(
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
)PY");
// The redirect target is asked for inside the approved redirecting request, and a library's
// wrapper or generator asks for the second URL as plain code does.
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
}
TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
handler = netlib.ReadingHandler(PRIVATE)
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
walk_outcomes = []
copy_outcomes = []
def on_walk_error(error):
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
def copy_file(source, destination):
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY");
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
harness.save_permissions(PLUGIN, permissions);
// A library handler that urllib calls while the approved request runs, the plugin's own
// os.walk error callback and copytree copy function (called once per file), and a library
// loop over a saved file and an unsaved one.
python.run_as(PLUGIN, R"PY(
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
for _ in os.walk(MISSING, onerror=on_walk_error):
pass
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
)PY");
CHECK(python.str("request_outcome") == "ok");
CHECK(python.eval("handler.outcome") == "denied");
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
CHECK(python.str("loop_outcome") == "denied");
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
}
TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
// next audited event lets go.
python.run_as(PLUGIN, R"PY(
walked = []
for root, dirs, files in os.walk(TREE):
walked.append(root)
open(APPROVED).close()
globbed = list(pathlib.Path(TREE).glob("**/*"))
abandoned = os.walk(TREE)
next(abandoned)
del abandoned
open(APPROVED).close()
for _ in pathlib.Path(TREE).glob("*"):
break
open(APPROVED).close()
)PY");
CHECK(py::len(python.globals["walked"]) == 3);
CHECK(py::len(python.globals["globbed"]) == 4);
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
struct Case
{
const char* prompts;
const char* code;
};
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
// FsDelete grants are never saved, so those operations are asked every time. Only the
// expected prompts are approved, so a nested event left uncovered fails the operation.
std::vector<Case> cases = {
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
// send() runs after connect() has returned, so its own grant is asked once.
{"http.client.connect\nhttp.client.send",
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
"connection.request('GET', '/a')\n"
"connection.getresponse().read()\n"
"connection.close()"},
};
#ifndef _WIN32
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
// denial would end it with status 1.
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
#endif
for (const Case& operation : cases) {
INFO(operation.code);
const std::string expected = std::string(operation.prompts) + "\n";
harness.approve = [&expected](const Prompt& prompt) {
return expected.find(prompt.event + "\n") != std::string::npos;
};
harness.prompts.clear();
python.run_as(PLUGIN, operation.code);
std::string asked;
for (const Prompt& prompt : harness.prompts)
asked += (asked.empty() ? "" : "\n") + prompt.event;
CHECK(asked == operation.prompts);
}
#ifndef _WIN32
CHECK(python.eval("spawn_status") == "0");
#endif
}
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.run_as(PLUGIN, R"PY(
socket.socket().close()
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
udp.close()
)PY");
// The socket is asked for once, without a target to show; the datagram's address is a target.
CHECK(python.str("sendto_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
harness.prompts.clear();
python.run_as(PLUGIN, "socket.socket().close()");
CHECK(describe(harness.prompts).empty());
// A grant for an event with no target covers nothing else: a library's approved
// gethostname() does not carry the connection it makes next.
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
CHECK(python.str("connect_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
harness.prompts.clear();
harness.approve = [](const Prompt&) { return true; };
#ifndef _WIN32
// sendmsg() without an address names no target either.
python.run_as(PLUGIN, R"PY(
left, right = socket.socketpair()
left.sendmsg([b"x"])
left.close()
right.close()
)PY");
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
CHECK(harness.saved_permissions(PLUGIN).events ==
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
#endif
}
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
READ_FD = os.open(APPROVED, os.O_RDONLY)
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
)PY");
// A file descriptor has no path to show or save: a granted read of one is not a write.
python.run_as(PLUGIN, R"PY(
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
)PY");
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
CHECK(describe(harness.prompts) == "open:\nopen:\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
// A saved os.fork event grant is not an os.system command named "os.fork".
PluginPermissions permissions;
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
harness.save_permissions(PLUGIN, permissions);
harness.prompts.clear();
python.run_as(PLUGIN, R"PY(
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
system_outcome = attempt(lambda: os.system("os.fork"))
os.close(READ_FD)
os.close(WRITE_FD)
)PY");
CHECK(python.str("reread_outcome") == "ok");
CHECK(python.str("system_outcome") == "denied");
REQUIRE(harness.prompts.size() == 1);
CHECK(harness.prompts.front().event == "os.system");
CHECK(harness.saved_permissions(PLUGIN).process.empty());
}
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
PluginAuditManager& mgr = PluginAuditManager::instance();
// Once the request returns, only its held frame keeps the request object alive, and the
// request's finalizer reads a file the plugin was never granted.
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
CHECK(describe(harness.prompts).empty());
CHECK_FALSE(mgr.audit_denial_pending());
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
open(APPROVED).close() # the next event lets the returned request's frame go
outcomes_before_scope_end = list(finalizer_outcomes)
)PY");
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts).empty());
// The finalizer's denial during another event's decision leaves that event's own denial intact.
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
try:
os.listdir(OUTSIDE)
outer_error = ""
except PermissionError as error:
outer_error = str(error)
)PY");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
AuditViolation violation;
REQUIRE(mgr.last_violation(violation));
CHECK(violation.event_name == "os.listdir");
CHECK(mgr.audit_denial_pending());
}
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin("PluginA");
harness.add_plugin("PluginB");
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE")};
harness.save_permissions("PluginA", permissions);
{
// Two walks plugin A started and suspended after listing the tree itself.
ScopedPluginAuditContext plugin_a("PluginA");
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
{
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
ScopedPluginAuditContext plugin_b("PluginB");
python.exec("listed_by_b = len(list(first))");
}
// Back in plugin A's scope, its walks are covered again.
python.exec("listed_by_a = len(list(second))");
}
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
REQUIRE(harness.prompts.size() == 2);
for (const Prompt& prompt : harness.prompts) {
CHECK(prompt.plugin == "PluginB");
CHECK(prompt.event == "os.scandir");
}
}
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
python.globals["orca"] = py::module_::import("orca");
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
// in its locals; the frame is let go when the capability call ends, which runs the marker's
// finalizer.
python.exec(R"PY(
class Marker:
def __init__(self, action=None):
self.action = action
def __del__(self):
finalized.append(self.action() if self.action else "ran")
finalized = []
def walk_and_drop(marker):
walker = os.walk(TREE, onerror=marker)
next(walker)
del walker
class ConfigCapability(orca.PythonPluginBase):
def get_name(self):
return "config_cap"
def get_default_config(self):
walk_and_drop(Marker())
return {"finalized_during_call": len(finalized)}
def has_config_ui(self):
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
next(walker)
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
del walker # the walk's frame is now held by the audit scope alone
raise RuntimeError("the capability fails after a denial")
class TicketAgent(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "ticket_agent"
def request_bind_ticket(self):
walk_and_drop(Marker())
return (0, "ticket")
)PY");
py::object config_instance = python.globals["ConfigCapability"]();
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
config_cap->set_audit_plugin_key("GilPlugin");
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
py::object agent_instance = python.globals["TicketAgent"]();
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
agent_cap->set_audit_plugin_key("GilPlugin");
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
REQUIRE(agent);
// A host call arrives without the GIL; both calls take it before their audit scope.
nlohmann::json config;
int ticket_result = -1;
std::string ticket;
{
py::gil_scoped_release released;
config = config_cap->get_default_config();
ticket_result = agent->request_bind_ticket(&ticket);
}
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
CHECK(ticket_result == 0);
CHECK(ticket == "ticket");
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
// was cleared: the finalizer's read is audited and denied without a prompt.
python.exec("finalized.clear()");
harness.prompts.clear();
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
}
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
const fs::path link = harness.outside.path() / "linked-stdlib";
boost::system::error_code ec;
fs::create_directory_symlink(stdlib, link, ec);
if (ec)
SKIP("Cannot create a directory symlink here: " + ec.message());
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
}