Compare commits

..

4 Commits

Author SHA1 Message Date
Andrew
8dfc7a14b9 Gate agent mode behind use_printer_agents toggle
Replace per-printer auto-activation
(is_current_printer_agent_plugin)
with a global experimental AppConfig
toggle, default off: legacy
print-host behavior is unchanged
until the user opts in. The toggle
drives device-tab routing, print
button defaults, connect-button
visibility and sidebar layout, and
dedups machine-select dialog opens.
2026-08-04 18:12:11 +08:00
Andrew
1534268183 Reset device selection on agent swap or unload (#124)
set_live_printer_agent centralizes
the swap: deselect the machine,
clear stale sidebar state and the
previous agent's Other Devices, then
install the new agent (or null when
its provider vanished). Plugin
load/unload callbacks refresh the
dropdown and re-run agent selection.
load_last_machine no longer falls
back to the first available machine.
2026-08-04 18:12:11 +08:00
Andrew
501af81ba9 Replace fake-enum printer agent dropdown (#121)
A dedicated PrinterAgentChoice field
reads rows straight from the live
agent registry and stores the agent
id string, replacing the fake-coEnum
index mapping. The field moves to
TabPrinter and registers with the
searcher so UnsavedChanges renders
it; the PhysicalPrinterDialog copy
and its update hook are removed
(#125). switch_printer_agent now
resolves ids via
resolve_printer_agent_id.
2026-08-04 18:12:10 +08:00
Ian Chua
16c44940d2 Add developer flag for printer agents 2026-08-04 18:12:10 +08:00
48 changed files with 310 additions and 6739 deletions

View File

@@ -1,72 +0,0 @@
# Printer agents
Printer agents let OrcaSlicer communicate with printers through a
standardized protocol. They translate between a printer's
native API and the application interfaces that the app already
uses.
This documentation explains the compatibility boundary, runtime ownership,
connection and status flow, command and feature behavior, built-in and plugin
agent implementations, and the testing evidence required for compatibility
claims.
## What printer agents do
A printer agent has two jobs:
1. Accept the app's existing commands and translate the ones its
printer supports.
2. Convert native printer status into correctly-shaped state that
`MachineObject` understands.
Currently, agents work at a compatibility boundary, i.e., making other vendors compatible with Bambu-shaped code, not a vendor-neutral one.
Some Bambu concepts remain part of the payload and command vocabulary.
End goal is to make the whole command and payload interfaces vendor-neutral.
## Vocabulary
Every chapter reuses these terms. The "Is not" column is the part that
causes confusion when it is left implicit.
| Term | Is | Selected by | Is not |
| --- | --- | --- | --- |
| Agent ID | Which printer agent implementation to use | `printer_agent` on the printer preset; empty is the legacy `bbl`-or-`orca` sentinel | Which printer |
| Printer agent | The live `IPrinterAgent` instance for that ID, created and cached once per ID by `NetworkAgentFactory` | Factory lookup on the agent ID | A connection, and not one object per printer |
| Device ID | One printer inside that implementation | Bind with Access Code for the Moonraker family, where the entered address becomes the ID; Bambu uses its own discovery identity | Which protocol |
| `MachineObject` | The Device tab's view of one selected printer | `DeviceManager::selected_machine`, which stores only an ID | Proof that a printer is reachable |
| Freshness | `is_connected()`, a test over the last-update time | Any reset of the update time, including one no status has followed | Proof that status arrived |
| Status-confirmed readiness | A push-status message has actually been parsed | The first real status message | The same thing as a successful `connect_printer()` |
Earlier drafts used "transport" for the printer agent instance. That term
is retired: the code selects an implementation, not a wire protocol.
## How to use this guide
- [Architecture](architecture.md) describes objects, ownership, lifetimes,
error handling, the feature gate, and compatibility contracts for printer
agents.
- [Connection and status](connection-and-status.md) describes how presets,
machines, access codes, status messages, and commands fit together at
runtime. Unlike Architecture, it follows the sequence of selecting an
agent, connecting, receiving status, and sending commands.
- [Printing](printing.md), [filament synchronization](filament.md), and
[camera support](camera.md) are separate chapters because they contain
per-feature detail rather than because they are universally special:
Printing has its send, preflight, recovery, and start contracts; Filament
covers acquisition, mapping selection, and print-time delivery; Camera
covers the distinct Bambu, Moonraker, and Snapmaker ownership models.
- [Built-in agents](agents.md) describes the Moonraker family and the Qidi,
Snapmaker variants.
- [Python plugin agents](plugin-agents.md) describes the plugin bridge and
lifecycle.
- [Testing and troubleshooting](testing.md) explains automated checks, manual
hardware work, known defects, and the evidence required for compatibility
claims.
- The [capability matrix](reference/capability-matrix.md) is the compact
feature reference. The [manual checklist](reference/manual-checklist.html)
is for a live-printer verification pass.
Treat source code as authoritative when it differs from this guide. In
particular, preserve the compatibility rules called out in each chapter:
they protect stored presets, existing profiles, and the Device tab's
assumptions.

View File

@@ -1,205 +0,0 @@
# Built-in printer agents
*Owns the per-vendor behavior of the built-in agents: what each subclass
changes and what it inherits unchanged. Defers the interface every agent
implements to [Architecture](architecture.md) and
[Python plugin agents](plugin-agents.md).*
This chapter covers the built-in Moonraker family: the general
`MoonrakerPrinterAgent` and the Qidi and Snapmaker variants. Creality
(`CrealityPrintAgent`) is also a member of this family and inherits the base
behavior, but has no section here; see the capability matrix for its
per-feature coverage. They share the same connection and status machinery.
Change the base class only when the behavior is valid for all of them.
Each subclass is thin. `MoonrakerPrinterAgent` holds the HTTP connection,
the WebSocket status subscription, the REST command worker, thumbnail
lookup, the chamber-light heuristic, and the upload-and-start path.
`QidiPrinterAgent` overrides filament discovery and adds multi-color box
mapping; `SnapmakerPrinterAgent` overrides filament discovery and camera
setup; `CrealityPrintAgent` overrides filament refresh. Each derives from
`MoonrakerPrinterAgent` and is `final`, which is why the guard rule below
must be type-based.
## Moonraker family
### Connection and commands
Moonraker-family agents use plain HTTP for the LAN connection. The connection
path deliberately ignores a TLS request because the supported printer stacks
serve Moonraker or a reverse proxy over HTTP. Restoring the caller's TLS
default can send a connection to an unavailable HTTPS endpoint.
Status is a Moonraker WebSocket subscription. Commands use REST. Command
translation happens immediately, but the resulting HTTP work runs through one
agent-owned FIFO worker. Each queued operation captures the current base URL
and API key before it is queued, so a later printer switch does not redirect
an earlier command. Keep this separation: network work on the UI path makes
controls feel stalled, and allowing a queued command to reread connection
state can send it to the wrong printer.
Pause, resume, and cancel use the dedicated Moonraker print endpoints. Do not
replace them with queued `PAUSE`, `RESUME`, or `CANCEL_PRINT` G-code. The
endpoints interrupt the print directly; a G-code command can wait behind the
active print or macro.
The request router accepts the Bambu-shaped JSON used by the native device
tab. Supply object-shaped namespaces such as `print` and `system`. A malformed
but parseable payload with a scalar where the router expects an object can
still fail before the unsupported-command fallback. The supported generic fan
status is the standard `fan` object, which represents the part fan only.
Ordinary part-fan control also works through the legacy `gcode_line` path,
which sends `M106` while `is_enable_np` is false. Auxiliary and chamber fans
are neither reported nor controlled.
Do not add `cfg`, `fun`, `aux`, and `stat` to the Moonraker status payload just
to make it look more complete. Together those fields set `is_enable_np` and
make the UI choose its structured fan and extruder commands instead. The
Moonraker agent does not translate those commands, so working controls become
unsupported no-ops. This is a UI-routing constraint, not a reason to expose
structured fan support.
### Status shown by the native device tab
The agent translates Moonraker status into the Bambu-shaped status payload the
existing Device tab understands. Some fields are necessarily synthetic:
- The virtual SD-card readiness bit and a basic software-version row make the
native UI consider the printer ready. Each pull payload also ensures
`m_push_count` and `m_full_msg_count` are at least one and refreshes
`last_push_time`. Together with the normal-storage state and a placeholder
module version, this satisfies the native `is_info_ready()` and printing
gates. These are compatibility scaffolding, not reports of physical storage
or OTA support.
- Current and total layers are emitted only when `print_stats.info` contains
numeric values. Moonraker may send `null`, and many profiles do not emit the
`SET_PRINT_STATS_INFO` data needed to populate them. Do not turn that gap
into a JSON conversion exception.
- Remaining time is estimated from elapsed print time and virtual-SD progress.
It is omitted below two percent progress because the early estimate is too
unstable. Do not derive an ETA by subtracting Moonraker duration counters:
both are elapsed counters, so their difference is overhead, not remaining
time.
- Temperature readings are available, but nozzle diameter and nozzle type are
not supplied in the status payload. The UI can therefore show an unknown
nozzle. Do not make print submission depend on those missing fields.
### Camera thumbnails and lights
For a running job, the agent asks Moonraker for thumbnails and chooses the
widest usable entry, rather than assuming the first entry is useful. It accepts
both thumbnail path spellings used by Moonraker versions, encodes each path
segment, and caches the result by filename. A failed transient lookup is tried
again only a bounded number of times; a clean response without a thumbnail is
cached as a negative result. The response shape handling is source-derived,
not hardware-verified.
> **Do not perform this HTTP lookup while holding `payload_mutex`.** The
> WebSocket thread builds the status payload under that mutex and the UI
> path also needs it, so a thumbnail timeout taken under the lock would
> stall status delivery or the UI. The lookup still blocks the WebSocket
> thread briefly, so move it to a worker if that becomes measurable.
Chamber-light control searches Moonraker objects for names that look like a
light or a standalone LED, then writes the first matching pin, LED, or macro.
The filter exists to avoid treating unrelated objects, such as a beeper, as a
lamp. It remains a heuristic. The incoming `led_node` is validated, but only
`chamber_light` is acted on; `chamber_light2` is deliberately ignored. A
printer with more than one lamp therefore has no reliable node-to-object map.
### Common maintenance limits
The same cache is reused for a selected agent ID, not per physical printer.
Qidi and Snapmaker inherit this behavior. A stateful feature added
to the base class must be reset carefully when a preset switches hosts.
> **Keep guards for this family type-based** - check whether an agent
> derives from `MoonrakerPrinterAgent` rather than comparing its ID to
> `moonraker`. An ID-based guard silently excludes Qidi, Snapmaker, and
> Creality, even though they share the base behavior.
The family has no generic implementation for firmware-specific AMS write
commands. Keep unsupported commands unsupported until the printer-side macro
or API is known. Reporting success for an untranslated command makes the
native UI claim that an action happened when it did not.
## Qidi
Qidi inherits the Moonraker connection, status, camera, and local-print path.
Its differences are Qidi filament discovery and the pre-print multi-color-box
mapping.
### Filament discovery
Discovery first reads the printer's device information to infer a Qidi series
identifier, then falls back to the configured Orca model if needed. Series
inference intentionally recognizes only a narrow set of known names. An
unknown model still produces usable generic filament data, but not a
series-specific preset identifier.
The agent reads a Qidi filament dictionary and the `save_variables` plus
slot-runout data. Failing to fetch the dictionary is non-fatal: slot discovery
continues with fallback material and colour values. Failing to fetch or parse
slot data is fatal to the refresh. A missing runout value means the agent
cannot prove filament is loaded, so it reports that slot as empty. This is an
ambiguity in the firmware data, not proof that the box is empty.
`save_variables.variables` must be an object. Qidi firmware can return `null`
there, and generic JSON value access can throw on a present null. The parser
rejects that shape without throwing. Preserve the null-slot tests whenever the
response parser changes.
### Multi-color mapping before a print
Before every Qidi print-start wrapper, the agent writes `enable_box` and, for
mapped tools, persistent `value_t<tool>` variables. These writes survive the
job. Invalid mapping JSON is checked only after `enable_box` has been written.
When the mapping is enabled, that failure can therefore leave `enable_box=1`.
There is no rollback for this or for a later per-tool write failure, so a
partial mapping can remain on the printer. An empty mapping is accepted when
the box is enabled. Single-colour jobs disable the box but leave old per-tool
assignments in place.
`enable_box` currently follows `task_use_ams`. That meaning has not been
verified against all Qidi firmware: if firmware treats it as "a box exists"
rather than "use the box for this job", this gate is wrong and needs hardware
evidence before it changes.
Only `start_local_print` reaches Moonraker's real upload-and-start path. The
other Qidi mapping wrappers currently return success stubs after applying the
mapping. Do not describe those wrappers as confirmed print paths.
Because the agent cache is keyed by agent type, a Qidi mapping can also become
stale when switching between Qidi printers. This is a generic Moonraker-family
state risk, made more consequential by Qidi's persistent firmware variables.
The configured `printer_type` can also be stale, so treat it as a fallback
hint rather than device truth.
## Snapmaker
Snapmaker uses the Moonraker base and overrides filament discovery and camera
setup. Neither path is hardware-verified in the current documentation set.
Filament information comes from parallel arrays in `print_task_config`.
`filament_exist` defines the number of slots; shorter type, subtype, colour,
vendor, or NFC arrays use safe fallback values. The agent first tries a visible
vendor, type, and colour preset, then a visible type match, and finally a
generic identifier when no preset bundle is available. An empty reported type
is changed to `PLA`, so an unknown occupied spool can look like confirmed PLA.
An unrecognized type can also reach the visible-preset fallback and be paired
with an unrelated visible preset. Treat the resulting preset as a suggestion,
not printer-ground truth.
Snapmaker U1 camera support starts the printer's monitor RPC, then serves the
still JPEG through a small local HTML page that reloads it after each load or
error. The wrapper is required because a direct still-image URL looks frozen.
The RPC is sent from a detached thread so the UI timer does not block on socket
I/O. That thread captures `this` directly, so agent destruction can race with
the camera command. Do not widen this pattern. Route future asynchronous work
through owned lifetime-managed work where possible.
## Source locations
- `src/slic3r/Utils/MoonrakerPrinterAgent.cpp`
- `src/slic3r/Utils/QidiPrinterAgent.cpp`
- `src/slic3r/Utils/SnapmakerPrinterAgent.cpp`

View File

@@ -1,158 +0,0 @@
# Architecture
*Owns the structural rules: what the objects are, who owns them, what an
agent must implement, and which behaviors are compatibility contracts.
Defers the runtime sequence - selecting, connecting, receiving status,
sending commands - to [Connection and status](connection-and-status.md).*
## The compatibility boundary
The Device tab was built around Bambu-style commands and status. A printer
agent is the translation boundary between that existing contract and a
vendor's native protocol:
```text
Device tab <-> MachineObject <-> NetworkAgent <-> IPrinterAgent
<-> vendor protocol
```
Note: end goal is to move beyond this and achieve a truly vendor-neutral translation layer.
The GUI builds commands and reads `MachineObject` state. An agent owns the
vendor request, response, connection, and status translation. Keep vendor
details on the agent side of this boundary.
Status translation is deliberately Bambu-shaped. Agents deliver payloads
through the callbacks used by the existing Bambu path, and
`MachineObject::parse_json()` interprets them. This preserves the Device
tab's established behavior, but it is not a vendor-neutral protocol.
Important (again): end goal is to move beyond this and achieve a truly vendor-neutral translation layer.
## Runtime objects and ownership
`NetworkAgent` is the facade used by the application. It holds one live
`IPrinterAgent` pointer, which is initially null and may return to null
when a selected ID is unavailable. Callers must handle the null case. An
absent agent is an inert state, not permission to fall back to another
printer agent. A fallback would connect to a different implementation than
the one selected by the preset, and could therefore send commands or status
work to the wrong printer.
`NetworkAgentFactory` registers built-in and plugin implementations by
agent ID. It creates and caches one implementation for each ID. The ID
selects a printer agent implementation, while a `MachineObject` selects one
printer by device ID. The resulting cardinality is one active agent to many
machines.
For example, suppose two Moonraker printers are on the LAN at
`192.168.1.20` and `192.168.1.21`. In the Device tab machine-select popup, the
user chooses **Bind with Access Code**; `PinCodePanel::on_mouse_left_up` opens
`InputIpAddressDialog`, and each entered address is bound as a separate
printer. Both presets store the same agent ID, `moonraker`, so
`NetworkAgentFactory::create_printer_agent_by_id` returns the same cached
`IPrinterAgent` pointer for both presets. Each printer nevertheless has its
own `MachineObject` and device ID. For the Moonraker family,
`MoonrakerPrinterAgent::bind_detect` calls `init_device_info` with the entered
address as both the device ID and address, so the two device IDs are the two
addresses.
That is what one active agent to many machines means. Per-printer state must
be keyed by device ID rather than held only on the agent instance, because one
agent object is shared by both printers. State stored only on that object
would be shared between two different machines and could route status or
commands to the wrong one. The same sharing explains why
`GUI_App::switch_printer_agent` compares device IDs even when the agent pointer
is unchanged: otherwise its unchanged-agent early return would skip
reselection when the user switches between these presets, leaving status and
filament work aimed at the previous printer.
> **Do not make an agent instance per printer just to hold device state.**
> Keep per-printer state keyed by device ID, because one agent object is
> shared by every printer of that type - state held on the instance would
> route status or commands to the wrong `MachineObject`.
> **Do not fall back to another printer agent when the live one is null.**
> An absent agent is an inert state. A fallback would connect to a
> different implementation than the preset selected.
## Commands and unsupported work
An agent must either translate a Device-tab command or return an explicit
error. `ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED` means no translation exists.
`ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE` means a translation exists but this
printer cannot use it. `MachineObject::publish_json()` turns either result
into the user-visible unsupported-command response.
Every Device-tab command must leave by one of these four exits. The fifth
path is the one to watch for in review:
```mermaid
flowchart TD
CMD["Device-tab command JSON"] --> PUSH{"pushing namespace?"}
PUSH -- yes --> OK1["Accept - the status stream already satisfies it"]
PUSH -- no --> TRANS{"Translation exists for this agent?"}
TRANS -- no --> E1["Return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED"]
TRANS -- yes --> CAP{"This printer can use it?"}
CAP -- no --> E2["Return ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE"]
CAP -- yes --> OK2["Translate and send to the printer"]
TRANS -. FORBIDDEN .-> BAD["Return success without translating"]
E1 --> PUB["MachineObject::publish_json turns both errors<br/>into the unsupported-command response"]
E2 --> PUB
BAD --> LIE["UI reports an action that never happened"]
```
> **Do not return success for an unhandled command.** That makes an
> unsupported button look as though it worked and hides missing coverage
> from both users and maintainers.
The `pushing` command namespace is the exception. Its request means
"send status"; an active status stream already satisfies it. The Device
Manager sends these requests repeatedly as a keepalive, so rejecting them
would surface a warning repeatedly even though no action is missing.
## Feature gate
`use_printer_agents` enables printer-agent routing. With the gate off,
agent code must have no observable effect. Released profiles can already
contain `printer_agent` values, so activating an agent while the gate is
off would change existing user behavior merely by loading a profile.
Keep the gate at the routing call sites. Do not fold it into general Bambu
vendor checks: slicing and hardware decisions such as AMS, lidar, bed
types, and G-code flavor still describe printer capabilities, not the
selected printer agent.
## Backward compatibility
`printer_agent` remains a `coString`, even when the ID is currently
unregistered. A preset may refer to an optional plugin that is not
installed. The unknown string must load, remain unchanged, and round-trip
without making the preset dirty. The UI may show it as missing, but must
not rewrite it to a fallback ID.
Keep the feature gate's off-path behavior unchanged, preserve stored agent
IDs, and treat Bambu-shaped payloads as a compatibility contract.
The reason these three are grouped is that each looks like a local code
change and is not. Switching which printer agent handles a preset edits no
profile and no project file, so it reads in review as contained to the
agent layer. But a user's stored presets and `.3mf` projects already carry
`printer_agent` values and were saved against the Bambu-shaped payload. So
a change that is local in the code is not local in effect: it reaches
every previously saved file. That is why the gate must be inert when off,
an unknown ID must survive untouched, and the payload shape is treated as
a contract rather than an implementation detail.
## Threading rule
Agents may perform network work on their own threads, but all mutations of
Device Manager maps and `MachineObject` UI state must run on the UI thread.
Queue incoming status before it reaches `parse_json()` or any operation
that adds, removes, selects, or changes a device. This prevents races
between background network callbacks and UI reads. For example, when a status
callback arrives on an agent's network thread, queue it to the UI thread
before it reaches `MachineObject::parse_json()` or changes a device map or
selection. The Device tab reads those same structures on the UI thread, so
parsing or adding, removing, or selecting a device from the network thread
could race with that read.

View File

@@ -1,96 +0,0 @@
# Camera support
*Owns the three camera ownership models and what each one renders through.
Defers the Snapmaker filament path to [Built-in agents](agents.md), even
though the same subclass owns both.*
Camera support has three ownership models. They share the Device panel,
but not a common frame or stream interface.
Two render surfaces, never one:
```mermaid
flowchart LR
BU["Bambu URL provider<br/>LAN or cloud, not from IPrinterAgent"]
MU["Moonraker webcam discovery<br/>/server/webcams/list stream URL"]
SU["Snapmaker camera page<br/>local HTML that polls monitor.jpg"]
BV["wxMediaCtrl2<br/>native Bambu media pipeline"]
WV["Device-panel wxWebView<br/>stream URL or local polling page"]
BU --> BV
MU --> WV
SU -- overrides normal discovery --> WV
```
## Bambu
Bambu playback uses `wxMediaCtrl2` and the native Bambu media pipeline.
The URL comes from the Bambu LAN or cloud path, not from
`IPrinterAgent`. A printer agent should not attempt to force a Bambu URL
through the Moonraker or WebView path.
## Moonraker live view
On connection, the Moonraker agent obtains the first enabled webcam URL
from `/server/webcams/list`. An absolute HTTP URL is used directly. A
relative URL is resolved against the printer's host web root, with the
Moonraker API port removed. This is necessary because a relative webcam
path may exist on the printer's web server but not on the API port.
The connection generation guards the result. A late request must not
replace the URL after the user has selected a different printer. Failed
discovery clears the URL, which prevents a prior camera from remaining
visible on a printer with no camera.
The agent places the discovered URL in its status payload. The Device
panel renders it in `wxWebView`. It reloads only when the URL changes and
resets the camera-start timestamp at that point. Reloading every update
would loop indefinitely for endpoints that redirect, so an unchanged URL
is shown again without calling `LoadURL()`.
The trade-off is intentional: a WebView that loses an unchanged stream
does not automatically reload. Camera controls beyond live viewing remain
out of scope for Moonraker. Recording, timelapse, settings, and virtual
camera are Bambu-oriented features and must not be presented as supported
merely because live view works.
## Snapmaker polling view
Snapmaker overrides normal webcam discovery. It writes a per-printer local
HTML page that polls the printer's `monitor.jpg` with a cache-busting URL.
Each next request starts after the prior image loads or fails, preventing
requests from piling up on a slow printer. A raw snapshot URL is not used,
because it would display one frozen frame instead of a live-looking view.
The printer must be asked to start its camera capture task. While the
camera view is visible, the Device panel requests this at first display
and then attempts another request every 300 seconds. Other agents reject
the command quietly, so the common timer does not create an error for
Bambu or ordinary Moonraker.
The Snapmaker command is sent from a detached thread because the request
can block on socket I/O and the printer responds over a different channel.
This avoids blocking the UI but leaves a raw-`this` lifetime risk: the
agent can be destroyed while the detached operation still refers to it.
Do not extend this path without addressing that ownership boundary.
The wrapper is written below the application cache with a name derived from
the printer IP. The source contains no cleanup path for those files, so they
can accumulate as different printer IPs are used. This is source-derived and
was not reproduced during this rewrite.
Source code proves 300-second renewal attempts only. The long-running
behavior of the shipped polling and renewal cycle has not yet been tested.
Do not claim that the attempt renews an active capture task or that it
prevents camera expiry until hardware verification establishes both.
## Maintenance checklist
- Keep the three ownership models separate.
- Preserve host-root resolution for relative Moonraker URLs.
- Keep generation guards and stale-URL clearing on every discovery path.
- Reload WebView content only after a URL change.
- Reset the camera-start timestamp when the camera URL changes.
- Treat Moonraker as live-view-only and Snapmaker lifecycle behavior as
not yet verified beyond the observed renewal attempts.

View File

@@ -1,248 +0,0 @@
# Connection and status
*Owns the runtime sequence in order: selecting an agent and machine,
starting a connection, receiving status, sending commands. Defers the
structural rules those steps must obey - ownership, no-fallback, unknown
IDs, threading - to [Architecture](architecture.md), and cites them at the
point where they bite.*
## The four runtime concepts
Keep these concepts separate when tracing a connection problem:
- A preset stores an agent ID and printer address.
- `NetworkAgent` holds the active printer agent for that agent ID.
- `MachineObject` represents the selected printer at that address.
- Freshness and status-confirmed readiness are separate states.
An agent ID selects a printer agent implementation. A device ID selects one
printer within that implementation.
A non-Bambu printer reaches the machine list through **Bind with Access
Code**, the tile in the Device tab's machine-select popup. The user enters
an address and an access code, `bind_detect()` probes the address before
any connect, and `DeviceManager::insert_local_device()` creates the
`MachineObject`. For the Moonraker family the address itself becomes the
device ID: `MoonrakerPrinterAgent::bind_detect()` seeds `dev_name` and
`dev_id` from the entered address, so an unreachable or unnamed printer
still shows up as its IP rather than blank.
Binding is the only route for that family. `MoonrakerPrinterAgent::start_discovery()`
deliberately announces nothing, because a partial discovery implementation
would populate the machine list with stale hosts. Bambu is the exception:
it has its own discovery identity and does not use the address as an ID.
`DeviceManager::selected_machine` is only a selected ID. It can name no
resolvable object. `get_selected_machine()` answers whether an object is
actually available. `set_selected_machine()` accepting an ID therefore
does not prove the printer is connected. The selected ID can remain when its
object is unavailable, so connection state must come from the object itself.
## Selecting the agent and machine
`GUI_App::switch_printer_agent()` reads the edited printer preset and
resolves its stored agent ID through `NetworkAgentFactory`.
1. An empty stored ID is a legacy sentinel. It resolves to `bbl` for a
Bambu vendor preset and to `orca` otherwise.
2. If that effective ID is registered, the factory provides the matching
printer agent implementation.
3. Clear the live printer agent only when a nonempty stored ID is unregistered
or the factory cannot construct the matching registered agent.
4. When the active printer agent changes, clear the current selection, user
selection, stale device discoveries, sidebar state, and AMS state before
installing the replacement.
5. Select the preset's address-derived machine for non-Bambu agents.
The lifetimes are easier to see than to read. Note that the agent pointer
can be unchanged while the machine still must be re-selected - that is the
trap in the same-agent path below:
```mermaid
sequenceDiagram
participant U as User
participant P as Printer preset
participant G as GUI_App::switch_printer_agent
participant F as NetworkAgentFactory
participant N as NetworkAgent
participant M as MachineObject
U->>P: Edit or switch the printer preset
P->>G: printer_agent value, possibly empty
G->>G: resolve_printer_agent_id - empty becomes bbl or orca
G->>F: get_printer_agent_info(effective ID)
alt ID not registered, and stored value was nonempty
F-->>G: no info
G->>N: clear the live printer agent
Note over N: null is inert - never fall back to another agent
else ID registered
F->>F: create once per ID, then serve from cache
F-->>G: the cached IPrinterAgent for this ID
alt Agent pointer changed
G->>N: clear selection, user selection, discoveries, sidebar, AMS
G->>N: install the replacement agent
else Same cached pointer returned
Note over G,F: Two presets can share one agent ID
end
G->>M: compare device ID, re-select if the address differs
end
```
> **Do not use the first available machine as a fallback** (rule owned by
> [Architecture](architecture.md), Runtime objects and ownership). It
> connects to a printer the user did not choose, including one owned by a
> different printer agent.
The same-agent path is important too. Two presets can use one agent type
but point at different addresses, and the factory caches one agent per ID,
so switching between them returns the same pointer and would otherwise
skip reselection entirely. Re-select the machine whenever the preset's
address changes, even when the factory returned the same active agent.
Otherwise status and filament work can continue against the previous
printer.
Note: this is a legacy coupling, not the primary workflow. It reads an
address stored on the printer preset itself (`print_host` and
`printhost_port`, named here only so the keys can be found in the code)
and derives a device ID from it with `dev_id_from_address()`. Those keys
predate printer agents and are edited through `PhysicalPrinterDialog`,
which despite its name writes the printer preset rather than a
`PhysicalPrinter` object - that object is no longer constructed. Printers
normally arrive through Bind with Access Code instead, which does not
touch the preset. Both routes end at `insert_local_device()`, so they must
agree on the device ID: `dev_id_from_address()` strips the URL scheme and
drops an empty port, while the bind path stores the address as the user
typed it.
The unknown-`coString` compatibility rule belongs to `architecture.md` under
Backward compatibility. Keep a nonempty unknown `printer_agent` ID unchanged
and display a missing state if needed; do not rewrite it during plugin unload
or choose an arbitrary replacement, so the preset can round-trip while its
plugin is temporarily unavailable.
## Starting a connection
Machine selection causes `MachineObject::connect()` to invoke the active
agent's `connect_printer()` with the selected address and effective access
code. A success return means that the connection attempt started. It does
not mean that the printer is ready or that a status stream is alive.
Moonraker-family agents must force HTTP. Moonraker and print-host
installations commonly serve plain HTTP, while the generic machine path
can request TLS by default. Passing that default through turns a valid
connection into an HTTPS request the printer will refuse. The agent therefore
must keep the connection on HTTP unless its protocol support changes
deliberately and is verified.
## Access codes: four coordinated slots
One effective access code can live in four places:
| Slot | Location | Purpose |
| --- | --- | --- |
| Device runtime | `MachineObject::access_code` | Code learned from the device. |
| User runtime | `MachineObject::user_access_code` | Code entered by the user. |
| Device config | `access_code[dev_id]` | Persisted device value. |
| User config | `user_access_code[dev_id]` | Persisted user value. |
The effective code prefers the user value when present, then the device
value. Keep user input in the user path and device replies in the device
path. Crossing those paths obscures which value should win.
`set_access_code()` deliberately does not save configuration immediately.
Device replies and polls can update it often; forcing a full config write
for each message adds unnecessary work. The normal deferred config save
persists dirty state later. Do not add an eager save just to make this one
path symmetric: device replies and polls update it often, so a config
write per message is wasted work.
> **Do not erase the user access code when a printer connects.** On the
> LAN reselection path that code can be the only credential that lets the
> machine pass the access check and receive the status or access-code
> reply that would refresh it, so erasing it at connection time can leave
> the machine permanently unable to receive updates. A failed connection
> is the place to handle a proven bad credential.
## Receiving status
An agent receives native status, translates it to the existing payload
shape, and dispatches it to the matching `MachineObject`. The object
parses the payload and records when it last received an update.
Readiness is four states, and three of them look connected:
```mermaid
stateDiagram-v2
[*] --> SelectedIdOnly
SelectedIdOnly: Selected ID only
SelectedIdOnly: selected_machine names no resolvable object
SelectedIdOnly --> FreshWindow: connect_printer returns success
FreshWindow: Fresh window
FreshWindow: reset seeded the update time - no status yet
FreshWindow --> Connecting: still fresh, still no push-status
Connecting: Connecting
Connecting: is_connecting true - the honest state
Connecting --> Ready: first push-status message parsed
Ready: Status-confirmed ready
Ready: the only state that proves a usable printer
Ready --> Stale: update time ages out
Stale: Stale
Stale: is_connected false
Stale --> Ready: a later status message arrives
note right of FreshWindow
is_connected() is true from here on.
It is a freshness test over the update
time - not proof that any status arrived.
end note
```
`is_connected()` is a freshness test over the update time. It does not
describe whether `connect_printer()` returned success or whether any status
message arrived: reset initializes the update time, creating an initial
freshness window. `is_connecting()` distinguishes that window from
status-confirmed readiness: while the object is fresh and no push-status
message has arrived, it remains connecting.
> **Do not treat freshness or a successful connect as proof of readiness.**
> Code that needs a usable printer must wait for status-confirmed
> readiness, because the fresh window exists before any status has been
> parsed.
The UI-thread mutation rule belongs to `architecture.md` under Threading rule.
Dispatch the status callback to the UI thread before changing device maps,
selection, or `MachineObject` state, because network callbacks may run in a
worker thread and mutating these structures there races with the Device tab.
## Sending commands
`MachineObject` builds the established command JSON and sends it through
the active `NetworkAgent`. The agent translates it or returns an explicit
unsupported result. It must not report success when no translation exists.
The `pushing` command exception belongs to `architecture.md` under Commands
and unsupported work. It asks for status, and a working status stream already
supplies it, so accepting it avoids false unsupported warnings from the Device
Manager's repeated keepalive.
## Maintainer constraints
- Preserve same-agent reselection by address, because an agent type can
serve more than one printer.
- Preserve the null-agent, no-fallback, and unknown-`coString` rules in
`architecture.md`; selection must remain an explicit user or preset choice,
and stale state must not belong to a replacement printer agent.
- An empty value is the legacy Bambu-or-Orca sentinel, not a missing printer
agent.
- Preserve deferred access-code saves and the no-on-connect-erase rule;
they prevent excessive config writes and credential-driven status loss.
- Keep Moonraker connections HTTP-only unless the agent's protocol support
changes deliberately and is verified.
- Do not treat freshness as proof that status arrived; wait for
status-confirmed readiness. The UI-thread mutation rule is in
`architecture.md` under Threading rule.

View File

@@ -1,110 +0,0 @@
# Filament synchronization and mapping
*Owns the three filament stages end to end: acquiring printer state,
selecting a mapping, and delivering it at print time. Defers the upload
and start mechanics to [Printing](printing.md), and per-vendor discovery
detail to [Built-in agents](agents.md).*
Filament support has three separate stages. A successful first stage does
not mean that a selected mapping will be delivered to the printer.
Stage 3 is where the two paths diverge, and only one of them reaches the
printer:
```mermaid
flowchart TD
SRC["Filament source<br/>Moonraker lane_data, or the classic MMU object"]
ACQ["1 - Acquire state<br/>Moonraker pull, not subscription"]
ST["DevFilaSystem<br/>Bambu-shaped view, synthetic 4-slot groups"]
SEL["2 - Select mapping<br/>Send dialog matches project filament to slots"]
PAR["PrintParams<br/>ams_mapping fields"]
QIDI["Qidi agent writes enable_box<br/>and value_t per tool, then starts the print"]
BASE["Base Moonraker, Happy Hare, AFC<br/>upload and start ignore the mapping fields"]
OK["Mapping reaches the printer"]
DEAD["Mapping never reaches the printer<br/>the job runs on the printer's own loaded-lane behavior"]
SRC --> ACQ --> ST --> SEL --> PAR
PAR -- Qidi --> QIDI --> OK
PAR -- every other target --> BASE --> DEAD
```
## 1. Acquire printer state
`FilamentSyncMode` declares how the UI obtains filament state:
| Mode | Meaning |
| --- | --- |
| `subscription` | A status stream keeps the state current. |
| `pull` | The UI must request state before it can use it. |
| `none` | The agent has no usable filament state. |
Moonraker uses `pull`. Its ordinary status stream does not supply the
filament data used by this UI. In particular, `lane_data` is a Moonraker
database namespace, not a printer object that the existing subscription
can follow. Changing Moonraker to `subscription` would suppress the pull
that actually populates the UI.
The agent first reads `lane_data`, which can describe AFC and newer Happy
Hare installations. If that is unavailable, it reads the classic Happy
Hare `mmu` object. Those response shapes are source-supported but not yet
verified against current Happy Hare and AFC installations.
The current parser expects lane values as strings and silently skips
numeric values. Whether current AFC or Happy Hare installations emit
numeric lane values is unverified.
The received lanes are converted into a Bambu-shaped model so existing
AMS UI can render them. The model groups numeric lane indexes into
synthetic groups of four slots and passes the result through
`ParseV1_0`. This is a UI compatibility adapter, not evidence that the
printer has a Bambu AMS.
Pull state can be stale. The Send dialog can build a mapping from the
current `DevFilaSystem` without refreshing it first, and a failed pull can
leave older state visible. Do not represent a displayed lane list as a
fresh printer read unless the call site just performed the pull.
## 2. Select a mapping
The Send dialog matches each project filament to a compatible reported
slot. It rejects a mismatched material type and then prefers compatible
slots according to the existing mapping rules. The result is carried in
the legacy linear mapping, the explicit AMS-and-slot mapping, and mapping
metadata for the job.
Treat lane numbers as printer contracts. A numeric lane index is used as a
slot index in the synthetic four-slot view, so an incorrect numbering
assumption can select the wrong physical lane.
The material identity code also retains a defect: ABS and ASA can be
shown as PLA when profile identifiers collide. This is not fixed here,
and multi-color mapping has not received hardware verification.
## 3. Deliver the mapping at print time
`PrintJob` copies the selected mappings into `PrintParams`, but base
Moonraker does not read those fields when it uploads and starts a print.
For plain Moonraker, Happy Hare, and AFC targets using that base path, a
correct-looking mapping in the UI is therefore not delivered to the
printer. The print runs using the printer's own loaded-lane behavior.
Qidi is the implemented exception. Its agent writes its own box mapping
before starting the print. That is a Qidi-specific delivery contract, not
a generic Moonraker solution.
There are deliberately no guessed Happy Hare or AFC write macros. Their
macro and variable names are defined by printer-side configuration, so a
guessed command could silently do nothing or control the wrong setup. Add
a delivery path only after verifying the exact contract against upstream
documentation or a real printer.
## Maintenance checklist
- Keep Moonraker in `pull` mode while `lane_data` remains pull-only.
- Refresh or clearly surface stale state before relying on Send-dialog
mappings.
- Do not claim base Moonraker honors mappings until it consumes them at
print time.
- Preserve Qidi as a distinct delivery implementation.
- Verify lane numbering, material identity, and multi-color behavior on
hardware before expanding the mapping contract.

View File

@@ -1,161 +0,0 @@
# Python printer-agent plugins
*Owns the plugin bridge: the implementation contract, registration and
lifetime, and the audit scope. Defers what the agent must do once live to
[Architecture](architecture.md) and
[Connection and status](connection-and-status.md).*
Python printer agents use the current capability bridge. They do not use a
separate adapter or a Moonraker-specific plugin path.
## What an agent must implement
Not overriding a member of `IPrinterAgent` has four different
consequences depending on which tier it is in. This is the whole plugin
contract:
| Tier | Members | Consequence of not overriding |
| --- | --- | --- |
| Pure virtual | `connect_printer`, `disconnect_printer`, `send_message_to_printer`, the `start_*` print operations, `start_discovery`, `bind`, `bind_detect`, `unbind`, the callback setters, `set_cloud_agent`, `get_agent_info`, and the rest of the pure surface | Compile error |
| Concrete, succeeds | `start_subscribe`, `stop_subscribe`, `add_subscribe`, `del_subscribe` | Silently returns `BAMBU_NETWORK_SUCCESS` |
| Concrete, declines | `command_ams_refresh_rfid`, `command_ams_calibrate`, `command_ams_select_tray`, `command_start_camera` | Silently returns `ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED` |
| Concrete, inert | `get_filament_sync_mode`, `fetch_filament_info` | Reports `FilamentSyncMode::none` and `false` - no filament capability at all |
The refusal tier is deliberate: those commands carry Bambu-dialect G-code
in their bodies, so the honest default is a refusal that
`MachineObject::publish_json()` turns into a dialog. The success tier is
equally deliberate - a printer whose status already streams needs no
subscription call.
> **Do not assume a missing override quietly inherits useful behavior, and
> do not assume it fails loudly either.** Only the first tier fails at
> compile time. The second silently reports success, the third silently
> declines, and the fourth silently reports no filament capability.
## The plugin contract
A plugin subclasses `printer_agent.PrinterAgentBase`, the Python binding for
`PrinterAgentPluginCapability`. The capability itself is the live native
`IPrinterAgent`; there is no intermediate protocol adapter, because
`PrinterAgentPluginCapability` inherits both `PluginCapabilityInterface`
and `IPrinterAgent` directly.
`get_type()` stays a `PluginCapabilityInterface` method and
`set_cloud_agent()` remains the native host injection point. A plugin must
implement the pure connection, communication, discovery, binding, print,
callback-registration, and filament-refresh operations. The certificate,
bind-ticket, HMS-snapshot, and user-selected-machine members are pure too;
the table above abridges the list.
The only tracked Python printer-agent implementation is the BBL plugin. There
is no Python Moonraker printer agent in the current source tree. Moonraker is
implemented by the built-in C++ class.
## Registration and lifetime
When an enabled plugin advertises a printer-connection capability, the factory
gets its `AgentInfo` and registers a factory under `AgentInfo.id`. This is the
same registry used for built-in agents.
Two similarly named structs are involved, and they are not the same thing.
`AgentInfo` is what the agent says about itself; `PrinterAgentInfo` is the
registry's entry about it:
```mermaid
classDiagram
class AgentInfo {
<<returned by the agent via get_agent_info>>
string id
string name
string version
string description
}
class PrinterAgentInfo {
<<the registry entry>>
string id
string display_name
string plugin_identifier
PrinterAgentFactory factory
}
class PrinterAgentFactory {
<<std::function>>
takes cloud_agent and log_dir
returns shared_ptr~IPrinterAgent~
}
class NetworkAgentFactory {
<<all static>>
register_printer_agent(id, display_name, factory)
create_printer_agent_by_id(id, cloud_agent, log_dir)
clear_printer_agent_cache()
register_python_printer_agent(plugin_key, capability_name)
deregister_python_printer_agent(plugin_key, capability_name)
}
AgentInfo ..> PrinterAgentInfo : id becomes the registry key
PrinterAgentInfo *-- PrinterAgentFactory
NetworkAgentFactory o-- PrinterAgentInfo : one entry per ID
PrinterAgentFactory ..> PrinterAgentPluginCapability : weak reference
```
`plugin_identifier` is empty for built-ins and
`<plugin_key>;<uuid>;<capability_name>` for plugins - that is how the
registry tells the two apart at deregistration time. Built-in IDs are the
constants `ORCA_PRINTER_AGENT_ID` and `BBL_PRINTER_AGENT_ID`.
Agent IDs are global. A plugin cannot replace a built-in agent or another
plugin with the same ID. Registry rejection is unconditional. The conflicting
capability is disabled and the user is shown the conflict only when `wxTheApp`
exists and the app is not closing. Re-registering the same plugin capability
is allowed so a reload can replace its factory with the current capability
instance.
The registered factory holds a weak reference to the capability. If the plugin
has already gone away, creation returns null instead of reviving a destroyed
Python object. Callers must treat that as no active printer agent.
On deregistration, the factory removes the registry entry and cached agent,
disconnects a cached agent, and clears the live agent if it has the same ID.
This order prevents `NetworkAgent` from retaining a Python implementation
whose module is about to unload. The current path is UI-thread oriented. Raw
pointer hazards become relevant only if deregistration moves to another thread
without adding synchronization around the GUI-held active-agent handle.
## Device-tab integration
Plugins share the native Device tab with built-in agents. There is no
printer-agent API for adding custom Device-tab panels and no plugin-owned
`MachineObject` to populate directly.
Instead, the plugin supplies the same callbacks as any `IPrinterAgent`. Its
status messages must use the Bambu-shaped payload that `MachineObject` already
parses. If a required field is absent, the shared native UI shows its default
or incomplete state. A custom protocol is acceptable inside the plugin, but
its boundary with the app must perform this translation.
## Python calls, errors, and audit scope
The C++ trampoline acquires the Python GIL, invokes each pure virtual override,
logs a Python exception, and rethrows it. A missing override is a separate
C++ pure-virtual failure, not a logged Python traceback. Python construction
also bypasses the virtual trampoline, so the bridge logs a constructor failure
at the construction boundary.
Plugin-created threads need their own exception handling. An exception raised
there does not cross the C++ trampoline; it reaches Python's thread exception
handling and is recorded through redirected Python standard error.
The audit hook is defense in depth, not a sandbox. Current printer-agent
trampoline calls use loading audit mode. In that mode, normal reads are
allowed, only some file writes are checked against allowed roots, and many
operations are outside the policy, including network access and process
creation. Work that runs outside an active trampoline scope, including a
plugin-created thread, has no attributed plugin context and is allowed by
default. Do not treat this mechanism as permission to run untrusted code.
## Source locations
- `src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp`
- `src/slic3r/plugin/pluginTypes/printerAgent/`
`PrinterAgentPluginCapabilityTrampoline.hpp`
- `src/slic3r/Utils/NetworkAgentFactory.cpp`
- `resources/orca_plugins/BBLPrinterAgentPlugin.py`

View File

@@ -1,138 +0,0 @@
# Printing through printer agents
*Owns the send path: connection choices, preflight, upload, start, and the
two recovery flows. Defers filament mapping delivery to
[Filament synchronization](filament.md), which is a separate contract even
though it is applied at print time.*
This chapter describes the printer-agent send path. It is separate from
the older print-host implementation, even when both target Moonraker.
Keep the paths separate unless their contracts and failure handling can
be deliberately reconciled.
## Connection choices
Three connection paths are in use:
| Target | Connection path | Use |
| --- | --- | --- |
| Native Bambu | Custom TLS tunnel on port 6000 | Send and optional eMMC preflight |
| Bambu Python agent | Implicit FTPS on port 990 | Upload and Bambu preflight fallback |
| Moonraker family | HTTP | Upload and start print |
The Bambu connection paths are independent. Selecting one does not prove that
the other is available. The Moonraker agent uploads with a multipart
request to its `gcodes` storage and then starts the uploaded filename;
it does not reuse the legacy `Moonraker` print-host class.
## Bambu native tunnel
The native tunnel depends on the versioned networking DLL and its
file-transfer module. `InitFTModule()` is a single-owner initialization:
it rejects a second call. Any future shared initialization must therefore
be idempotent, while `BBLNetworkPlugin` remains the single teardown owner.
It must call `UnloadFTModule()` before freeing the DLL, otherwise the
module's function pointers can point into unloaded code.
There is currently an initialization gap: selecting a printer agent does
not initialize this module. It is initialized only when the
`installed_networking` option causes the native BBL network plugin to
initialize. Calls to the tunnel must continue to fail safely until that
path has initialized the module. The Send UI catches this failure and
reports an initialization error instead of letting an exception leave a
wx event handler.
## Bambu FTPS upload
The Python Bambu agent uses implicit FTPS on port 990. Its live upload
path closes the data connection, then waits at most two seconds for the
control response with `voidresp()`. A `TimeoutError` is accepted as a
completed transfer. An `error_reply` is also accepted when its reply
begins with `200`. This is the behavior to preserve.
Do not describe the path as using TLS `unwrap()`: the live construction
does not enable it. Enabling it without a bounded wait could hang while
waiting for the peer's TLS close notification. The current timeout-based
handling has not been verified on hardware against every printer and FTP
server combination.
## Print preflight and recovery
For normal LAN prints, `PrintJob` performs a preflight before the real
send. When eMMC is eligible it tries the native tunnel, then it sends a
small `verify_job` upload through the selected agent. The latter is a real
upload, not a special protocol command. Non-Bambu agents therefore upload
the probe too.
> **Do not re-enable eMMC by default** without hardware coverage for the
> affected devices. It is opt-in because the tunnel can hang during upload
> on some printers.
The whole send, including the thread hop and the recovery fork:
```mermaid
sequenceDiagram
participant UI as Send UI (UI thread)
participant J as PrintJob (worker)
participant A as Selected IPrinterAgent
participant P as Printer
UI->>J: Start send
opt eMMC eligible - off by default
J->>P: Native Bambu tunnel attempt
Note over J,P: Can hang on some printers
end
J->>A: verify_job preflight
Note over J,A: A real upload, not a protocol probe -<br/>non-Bambu agents upload it too
A->>P: Upload probe
alt Preflight and upload succeed
J->>A: Upload the real job
A->>P: Upload, then request print start
Note over A,P: The start response may stay open while<br/>the printer prepares - a timeout is not<br/>proof of failure, so check reported print state
A-->>UI: Result from the reported print state
else Upload fails
J-->>UI: Failure callback, marshalled to the UI thread
Note over UI: Re-resolve the machine here.<br/>Never reuse a machine pointer captured<br/>on the worker - agent or machine may have changed
alt Printer still connected
UI->>UI: Explain that storage upload failed
else Printer disconnected
UI->>UI: Open the IP or access-code flow
end
end
```
An upload failure and a disconnected printer need different recovery:
| Condition | UI response |
| --- | --- |
| Printer is still connected | Explain that storage upload failed. |
| Printer is disconnected | Open the IP or access-code flow. |
> **Do not retain a machine pointer from a worker callback.** The callback
> that chooses between these two outcomes runs on the UI thread and
> re-resolves the machine there, because the selected agent or machine can
> change first. The connection check is adequate for choosing the message,
> but is not a strong enough signal to authorize a reconnect.
## Moonraker upload and start
`MoonrakerPrinterAgent` uploads through Moonraker HTTP, then requests the
print start separately. The start endpoint may keep its response open
while the printer prepares the job. A timeout after that request is not
automatically proof that the start failed: the agent checks the reported
print state before deciding the result.
The legacy print-host Moonraker path implements its own upload and start
logic. It is not the agent path and should not be changed as an implicit
side effect of agent work.
## Maintenance checklist
- Test the selected connection path, not just another path on the same
printer.
- Preserve cancellation and progress callbacks across upload and start.
- Treat `verify_job` as an actual upload when estimating storage effects.
- Keep eMMC opt-in until its hanging behavior is resolved and verified.
- Keep the connected-upload-failure dialog distinct from the disconnected
recovery flow.

View File

@@ -1,137 +0,0 @@
# Printer-agent capability matrix
This is a compact lookup for the built-in Moonraker family. It combines
implementation state with recorded evidence. It is not a promise that every
firmware configuration behaves the same way. Python plug-in behavior depends
on the plug-in, not on this matrix.
Use [testing and troubleshooting](../testing.md) before calling a live-printer
result complete.
For a quicker tour of the controls users actually see, open the
[annotated Device-tab view](device-tab-annotations.html). The annotations
explain the important routing constraints; this matrix remains the compact
cross-agent reference.
## Status definitions
- Supported - implemented, with a relevant live-printer result recorded.
- Partial - an important condition, limitation, or defect applies.
- Unsupported - no applicable implementation, or deliberate refusal.
- Not verified - implemented or source-inspected, but without a relevant live
result.
`Base` means `MoonrakerPrinterAgent`. Qidi, Creality, and Snapmaker inherit
from it unless a row identifies an override.
## Connection and status
| Capability | Base | Qidi | Creality | Snapmaker |
| --- | --- | --- | --- | --- |
| Direct LAN connection with API key | Supported | Supported | Not verified | Not verified |
| WebSocket status updates | Supported | Supported | Not verified | Not verified |
| Reconnect and fresh status | Partial | Partial | Partial | Partial |
| Discovery and cloud binding | Unsupported | Unsupported | Unsupported | Unsupported |
| Device identity with a configured port | Partial | Partial | Partial | Partial |
- Reconnect completion can leave the Device tab with stale status.
- A bare IP and `host:port` can become separate device identities.
- The Base and Qidi Supported grades come from prior hardware sessions. They
were carried into this rewrite and not rerun.
## Controls
| Capability | Base | Qidi | Creality | Snapmaker |
| --- | --- | --- | --- | --- |
| Home and arbitrary G-code | Not verified | Not verified | Not verified | Not verified |
| Bed and nozzle temperature | Not verified | Not verified | Not verified | Not verified |
| Pause, resume, and cancel | Not verified | Not verified | Not verified | Not verified |
| Configured chamber light | Partial | Partial | Partial | Partial |
| Jog and manual extrusion | Partial | Partial | Not verified | Not verified |
| Legacy part-fan speed control | Partial | Partial | Not verified | Not verified |
| Structured fan, chamber, and AI controls | Unsupported | Unsupported | Unsupported | Unsupported |
| AMS RFID, calibration, and tray control | Unsupported | Unsupported | Unsupported | Unsupported |
- Chamber light needs a recognised light object.
- Base and Qidi jog works, but can leave relative positioning active. Do not
use it as a general safe-control test until its G-code state is restored.
- Base and Qidi part-fan control works through legacy `gcode_line` while
`is_enable_np` is false. Adding `cfg`, `fun`, `aux`, and `stat` flips that
flag and routes fan and extruder controls to unsupported structured commands.
- Creality and Snapmaker inherit the source path but have no separate live
evidence for jog or fan control.
## Printing
| Capability | Base | Qidi | Creality | Snapmaker |
| --- | --- | --- | --- | --- |
| Upload G-code without starting | Not verified | Not verified | Not verified | Not verified |
| Upload and start a local print | Not verified | Not verified | Not verified | Not verified |
| Mapped multi-material print | Unsupported | Not verified | Unsupported | Unsupported |
| Cloud or SD-card start variants | Unsupported | Partial | Unsupported | Unsupported |
| Cancel during upload | Not verified | Not verified | Not verified | Not verified |
| Send with no nozzle identity | Not applicable | Not verified | Not applicable | Not applicable |
- Qidi applies mapping before it routes the real local print path.
- Some Qidi print variants can reach base success stubs after mapping.
- Qidi tolerates missing nozzle data in source, but that Send preflight is not
hardware-verified.
## Filament
| Capability | Base | Qidi | Creality | Snapmaker |
| --- | --- | --- | --- | --- |
| Sync mode | Not verified | Not verified | Not verified | Not verified |
| Read installed material and slots | Partial | Not verified | Not verified | Not verified |
| Slot, material, and colour refresh | Unsupported | Not verified | Not verified | Not verified |
| Cleanup after removed material | Not verified | Not verified | Not verified | Not verified |
| Load, unload, or write a slot | Unsupported | Partial | Unsupported | Unsupported |
| Auto Refill | Unsupported | Unsupported | Unsupported | Unsupported |
- All built-in agents use pull-mode sync.
- Base reads Happy Hare or AFC data when present. Qidi has print-time mapping;
Creality has CFS logic; Snapmaker reads printer arrays and NFC data.
- The Base Device-tab slot refresh uses a proprietary AMS command and has no
generic Moonraker translation.
- Generic write-side macros stay unsupported until their printer contract is
known and verified.
## Camera
| Capability | Base | Qidi | Creality | Snapmaker |
| --- | --- | --- | --- | --- |
| Discover a Moonraker webcam | Not verified | Not verified | Not verified | Unsupported |
| Provide a camera source | Not verified | Not verified | Not verified | Not verified |
| Live camera view | Not verified | Not verified | Not verified | Not verified |
| Snapshot-only camera start | Unsupported | Unsupported | Unsupported | Not verified |
| Camera start renewal and teardown | Unsupported | Unsupported | Unsupported | Partial |
| Print thumbnail | Not verified | Not verified | Not verified | Not verified |
- Snapmaker bypasses webcam discovery with a local snapshot-polling page.
- Its renewal and teardown path has lifetime risks without live evidence.
- Moonraker thumbnail endpoint responses and filename-cache behavior need live
coverage.
## Python plug-ins
| Capability | Python plug-in agent |
| --- | --- |
| Registration and re-registration | Not verified - lifecycle tests cover replacement |
| Duplicate agent ID | Not verified - conflict is rejected and reported |
| Disable or unload | Not verified - deregistration is tested; session teardown needs coverage |
| Capability surface | Defined by the plug-in and exposed Python API |
## Reading the matrix safely
- `Partial` is not a softer form of `Supported`. It names a condition that must
be checked before use. `Not verified` means the code was found, but no
relevant live result is recorded.
- Pair each claim with the evidence grades in the testing guide. This matters
especially for CFS, Snapmaker camera, MMU macros, Qidi Send preflight, and
thumbnail endpoints.
## Background - deliberate exclusions
The matrix excludes Bambu-specific cloud binding, RFID, calibration, and
camera-control features from the Moonraker family. They use different protocol
contracts and are deliberately refused when no safe Klipper equivalent exists.

File diff suppressed because one or more lines are too long

View File

@@ -1,280 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Printer-agent manual checklist</title>
<style>
:root { color-scheme: light dark; --bg:#111827; --panel:#1f2937; --line:#4b5563;
--text:#f9fafb; --muted:#cbd5e1; --pass:#34d399; --fail:#f87171; --block:#fbbf24; }
* { box-sizing:border-box; }
body { max-width:960px; margin:0 auto; padding:24px; font:15px/1.5 system-ui,sans-serif;
color:var(--text); background:var(--bg); }
h1 { margin:0 0 4px; } h2 { margin-top:32px; } p, li { max-width:78ch; }
.muted { color:var(--muted); } .meta { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));
gap:12px; margin:20px 0; } label { display:grid; gap:4px; }
input, textarea, button { font:inherit; } input, textarea { width:100%; padding:8px;
border:1px solid var(--line); border-radius:6px; color:inherit; background:var(--panel); }
textarea { min-height:58px; margin-top:8px; } .toolbar { display:flex; flex-wrap:wrap;
gap:8px; align-items:center; margin:16px 0; } button { border:1px solid var(--line);
border-radius:6px; padding:6px 10px; cursor:pointer; color:inherit; background:var(--panel); }
button[data-state="pass"].active { color:#062d1d; background:var(--pass); }
button[data-state="fail"].active { color:#3b0808; background:var(--fail); }
button[data-state="blocked"].active { color:#3b2600; background:var(--block); }
.progress { flex:1 1 240px; height:10px; overflow:hidden; border-radius:999px; background:var(--line); }
.progress > div { height:100%; width:0; background:var(--pass); transition:width .15s; }
.case { margin:10px 0; padding:12px; border:1px solid var(--line); border-left:4px solid var(--line);
border-radius:6px; background:var(--panel); } .case.pass { border-left-color:var(--pass); }
.case.fail { border-left-color:var(--fail); } .case.blocked { border-left-color:var(--block); }
.case-head { display:flex; justify-content:space-between; gap:12px; align-items:start; }
.case h3 { margin:0; font-size:1rem; } .actions { display:flex; gap:5px; white-space:nowrap; }
.restore-warning { padding:8px 10px; border:1px solid var(--block); border-radius:6px;
color:var(--block); background:var(--panel); }
code { overflow-wrap:anywhere; } @media (max-width:600px) { body { padding:16px; }
.meta { grid-template-columns:1fr; } .case-head { display:block; } .actions { margin-top:8px; } }
</style>
</head>
<body>
<h1>Printer-agent manual checklist</h1>
<p class="muted">Record what the printer did, not just what the UI displayed. Mark a case
blocked when the required printer, configuration, or safe test condition is unavailable.</p>
<section class="meta" aria-label="Test context">
<label>Agent and printer<input id="target" placeholder="for example: Moonraker - printer model"></label>
<label>Firmware and configuration<input id="environment" placeholder="firmware, MMU, camera, API key setup"></label>
<label>Build or package<input id="build" placeholder="version or build identifier"></label>
<label>Tester and date<input id="tester" placeholder="name and date"></label>
</section>
<div class="toolbar">
<strong id="summary">0 of 0 cases marked</strong>
<div class="progress" aria-label="Checklist progress"><div id="bar"></div></div>
<button id="export" type="button">Export Markdown</button>
<button id="reset" type="button">Reset checklist</button>
</div>
<p id="restore-warning" class="restore-warning" hidden>Saved checklist data could not be
restored. You can export the current blank checklist or use Reset to remove the saved data.</p>
<p class="muted">Use Pass only after observing the expected result. Fail needs enough evidence
to reproduce it. Include response text, log markers, or firmware behavior in the note.</p>
<section>
<h2>Connect and observe status</h2>
<div class="case" data-id="connect">
<div class="case-head"><div><h3>Connect to the selected printer</h3>
<p>Expected: the Device tab receives a fresh status update after connection. Do not use a
successful connection return alone as the result.</p></div><div class="actions"></div></div><textarea placeholder="Evidence, result, or blocker"></textarea></div>
<div class="case" data-id="status">
<div class="case-head"><div><h3>Observe live status changes</h3>
<p>Expected: temperature and target changes, fan state, print state, filename, progress,
elapsed time, and homing state reach the UI while the printer changes state.</p></div><div class="actions"></div></div><textarea placeholder="Evidence, result, or blocker"></textarea></div>
<div class="case" data-id="reconnect">
<div class="case-head"><div><h3>Disconnect and reconnect</h3>
<p>Expected: a second connection produces new status messages and does not create a duplicate
device. Record the post-reconnect status evidence.</p></div><div class="actions"></div></div><textarea placeholder="Evidence, result, or blocker"></textarea></div>
<div class="case" data-id="network-errors">
<div class="case-head"><div><h3>Handle network and response failures</h3>
<p>Expected: for discovery, status, G-code, upload, and print start, exercise controlled
HTTP 401, 404, and 500 responses, invalid JSON, and refused sockets. Each operation must
fail clearly or offer a retry, without a crash or a false success. Record the operation,
injected failure, UI result, and any retry.</p></div><div class="actions"></div></div><textarea placeholder="Operation, injected failure, UI result, retry, and evidence"></textarea></div>
</section>
<section>
<h2>Controls and printing</h2>
<div class="case" data-id="controls">
<div class="case-head"><div><h3>Exercise safe controls</h3>
<p>Expected: home, bed and nozzle temperature, and a harmless G-code command take effect on
the printer. Do not use Moonraker jog as a safe control test while it can leave relative
positioning active.</p></div><div class="actions"></div></div><textarea placeholder="Commands sent and printer-side result"></textarea></div>
<div class="case" data-id="fifo-order">
<div class="case-head"><div><h3>Verify queued command order under latency</h3>
<p>Expected: queue three harmless, uniquely marked commands while a proxy, network shaper,
or request log introduces or records latency. The printer-side log must show the markers in
the same order they were queued. Record the latency method and observed order.</p></div><div class="actions"></div></div><textarea placeholder="Queued markers, latency method, printer-side order, and result"></textarea></div>
<div class="case" data-id="send-only">
<div class="case-head"><div><h3>Send a file without starting it</h3>
<p>Expected: the file appears on the printer and no print starts.</p></div><div class="actions"></div></div><textarea placeholder="Filename and observed result"></textarea></div>
<div class="case" data-id="print">
<div class="case-head"><div><h3>Start a small print</h3>
<p>Expected: upload completes, the printer starts the selected file, and status transitions
to printing.</p></div><div class="actions"></div></div><textarea placeholder="Filename, response, and observed result"></textarea></div>
<div class="case" data-id="active-print-controls">
<div class="case-head"><div><h3>Pause, resume, and cancel an active print</h3>
<p>Expected: after the small print is actively printing, pause it and observe a paused state
on both printer and UI; resume it and observe printing again; then cancel it and observe the
printer stop and the UI leave the active or paused state.</p></div><div class="actions"></div></div><textarea placeholder="State transitions and printer-side result"></textarea></div>
<div class="case" data-id="print-failure">
<div class="case-head"><div><h3>Check upload failure handling</h3>
<p>Expected: cancellation during upload and a missing input fail clearly and do not begin a
partial or unintended print.</p></div><div class="actions"></div></div><textarea placeholder="Failure path and observed result"></textarea></div>
</section>
<section>
<h2>Filament, camera, and agent-specific checks</h2>
<div class="case" data-id="filament-read">
<div class="case-head"><div><h3>Refresh material-system data</h3>
<p>Expected: populated slots, empty slots, material, colour, and a live change are represented
correctly. Moonraker-family agents pull this data; do not expect a subscription callback.</p></div><div class="actions"></div></div><textarea placeholder="MMU or box configuration and observed result"></textarea></div>
<div class="case" data-id="filament-cleanup">
<div class="case-head"><div><h3>Clear material data when filament or the system is absent</h3>
<p>Expected: remove filament or disable the material system, refresh, and confirm the UI no
longer shows obsolete slots, material names, or colours.</p></div><div class="actions"></div></div><textarea placeholder="Change made, refresh evidence, and remaining or cleared data"></textarea></div>
<div class="case" data-id="filament-write">
<div class="case-head"><div><h3>Verify print-time mapping where supported</h3>
<p>Expected: only agents with a documented mapping implementation change printer-side mapping.
Do not attempt load, unload, or slot-setting macros unless their printer-specific contract is
known and safe.</p></div><div class="actions"></div></div><textarea placeholder="Mapping path, printer configuration, and result"></textarea></div>
<div class="case" data-id="qidi-nozzle-preflight">
<div class="case-head"><div><h3>Check Qidi Send preflight with missing nozzle identity</h3>
<p>Expected: on a Qidi agent and compatible single-nozzle slice, Send proceeds when the
Device tab has no reported nozzle diameter or type. It must not stop with
<code>PrintStatusNozzleDataInvalid</code>. Record any reported identity and any mismatch
result separately; this does not approve a known mismatch.</p></div><div class="actions"></div></div><textarea placeholder="Slice, reported nozzle data, preflight result, and printer-side result"></textarea></div>
<div class="case" data-id="camera">
<div class="case-head"><div><h3>Verify a camera feed</h3>
<p>Expected: frames advance and switching printers does not display a stale feed. For Snapmaker,
observe immediately before and after 300 seconds in one open view. The renewal result is
unknown until hardware evidence exists. Then swap agents and shut down the app to exercise
teardown around the detached callback's raw-<code>this</code> lifetime risk.</p></div><div class="actions"></div></div><textarea placeholder="Camera type, timestamps, agent swap or shutdown result, and evidence"></textarea></div>
<div class="case" data-id="thumbnail">
<div class="case-head"><div><h3>Verify the print thumbnail</h3>
<p>Expected: test a reused filename after its thumbnail changes, response payloads with both
<code>thumbnail_path</code> and <code>relative_path</code>, and a path below the G-code
root. The displayed image must match the current file in each case.</p></div><div class="actions"></div></div><textarea placeholder="Filename, endpoint key and path, displayed image, and observed result"></textarea></div>
<div class="case" data-id="plugin">
<div class="case-head"><div><h3>Reload a Python printer-agent plug-in</h3>
<p>Expected: the capability registers once, duplicate agent IDs are rejected visibly, and
disable or unload removes the agent cleanly.</p></div><div class="actions"></div></div><textarea placeholder="Plug-in identifier, actions, and observed result"></textarea></div>
</section>
<script>
const storageKey = 'orca-printer-agent-manual-checklist-v1';
const cases = [...document.querySelectorAll('.case')];
const inputs = [...document.querySelectorAll('input')];
const restoreWarning = document.querySelector('#restore-warning');
function emptyState() {
return { meta: {}, cases: {} };
}
function loadState() {
const saved = localStorage.getItem(storageKey);
if (!saved) return emptyState();
try {
const parsed = JSON.parse(saved);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' ||
(parsed.meta !== undefined && (Array.isArray(parsed.meta) || typeof parsed.meta !== 'object')) ||
(parsed.cases !== undefined && (Array.isArray(parsed.cases) || typeof parsed.cases !== 'object'))) {
throw new Error('incompatible saved checklist state');
}
return { meta: parsed.meta || {}, cases: parsed.cases || {} };
} catch (error) {
restoreWarning.hidden = false;
return emptyState();
}
}
const state = loadState();
function caseText(caseElement) {
return caseElement.querySelector('h3').textContent.trim();
}
function caseCriteria(caseElement) {
return [...caseElement.querySelectorAll('.case-head p')]
.map(paragraph => markdownParagraph(paragraph.textContent))
.filter(Boolean)
.join('\n\n');
}
function save() {
const data = { meta: {}, cases: {} };
inputs.forEach(input => { data.meta[input.id] = input.value; });
cases.forEach(item => {
data.cases[item.dataset.id] = { status: item.dataset.status || '', note: item.querySelector('textarea').value };
});
localStorage.setItem(storageKey, JSON.stringify(data));
updateProgress();
}
function renderStatus(item, status) {
item.dataset.status = status;
item.classList.toggle('pass', status === 'pass');
item.classList.toggle('fail', status === 'fail');
item.classList.toggle('blocked', status === 'blocked');
item.querySelectorAll('button[data-state]').forEach(button => {
button.classList.toggle('active', button.dataset.state === status);
});
}
function applyStatus(item, status) {
renderStatus(item, status);
save();
}
function updateProgress() {
const marked = cases.filter(item => item.dataset.status).length;
document.querySelector('#summary').textContent = `${marked} of ${cases.length} cases marked`;
document.querySelector('#bar').style.width = `${cases.length ? marked * 100 / cases.length : 0}%`;
}
function markdown() {
const meta = stateFromInputs();
const lines = ['# Printer-agent manual verification', '',
`Target: ${markdownInline(meta.target) || 'not recorded'}`,
`Firmware and configuration: ${markdownInline(meta.environment) || 'not recorded'}`,
`Build or package: ${markdownInline(meta.build) || 'not recorded'}`,
`Tester and date: ${markdownInline(meta.tester) || 'not recorded'}`, ''];
cases.forEach(item => {
const status = item.dataset.status || 'unmarked';
const note = markdownInline(item.querySelector('textarea').value);
lines.push(`## ${markdownInline(caseText(item))}`, '',
`Status: ${status}`, '',
'### Test criteria and expected result', '',
caseCriteria(item) || 'Not recorded', '',
'### Notes', '', note || 'None', '');
});
return lines.join('\n');
}
function markdownParagraph(value) {
return markdownInline(value);
}
function markdownInline(value) {
return String(value || '').replace(/\r\n?|\n/g, ' ').replace(/[\\`*_{}[\]<>#+!|]/g, '\\$&')
.replace(/\s+/g, ' ').trim();
}
function stateFromInputs() {
return Object.fromEntries(inputs.map(input => [input.id, input.value]));
}
function downloadMarkdown() {
const blob = new Blob([markdown()], { type: 'text/markdown;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'printer-agent-manual-verification.md';
link.click();
URL.revokeObjectURL(link.href);
}
cases.forEach(item => {
const actions = item.querySelector('.actions');
['pass', 'fail', 'blocked'].forEach(status => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.state = status;
button.textContent = status[0].toUpperCase() + status.slice(1);
button.addEventListener('click', () => applyStatus(item, status));
actions.appendChild(button);
});
const saved = state.cases[item.dataset.id] || {};
item.querySelector('textarea').value = typeof saved.note === 'string' ? saved.note : '';
if (['pass', 'fail', 'blocked'].includes(saved.status)) renderStatus(item, saved.status);
item.querySelector('textarea').addEventListener('input', save);
});
inputs.forEach(input => {
input.value = typeof state.meta[input.id] === 'string' ? state.meta[input.id] : '';
input.addEventListener('input', save);
});
document.querySelector('#export').addEventListener('click', downloadMarkdown);
document.querySelector('#reset').addEventListener('click', () => {
if (!confirm('Clear all saved checklist data for this browser?')) return;
localStorage.removeItem(storageKey);
location.reload();
});
updateProgress();
</script>
</body>
</html>

View File

@@ -1,249 +0,0 @@
# Testing and troubleshooting printer agents
*Owns evidence grades, the automated and manual verification passes, and
the open-defect register. Every "not hardware-verified" note elsewhere in
this guide resolves to a grade defined here.*
This page describes how to assess a printer-agent change without treating
source inspection as a hardware result. Use the
[manual checklist](reference/manual-checklist.html) for a repeatable live-printer
pass, and use the [capability matrix](reference/capability-matrix.md) to decide
which cases apply to the agent being changed.
## Evidence grades
Keep these grades separate in reviews and release notes.
- Source-inspected - the current implementation was read. It establishes
intended behavior, not printer compatibility.
- Automated - a targeted test ran. It covers its inputs and assertions, not a
printer, firmware version, or network failure that it does not model.
- Hardware-verified - the stated behavior was observed on a named class of
live printer. Record the model, firmware, configuration, and result with the
test evidence.
Do not call a capability supported by hardware solely because the code compiles or
a unit test passes. Conversely, a hardware observation should not be generalized
to every Moonraker-family printer without checking its configuration.
## Carried hardware evidence
Prior hardware sessions verified direct Moonraker-family connection, live
WebSocket status, and jog on both a Qidi/Moonraker printer and a generic
Moonraker box. The jog moved the printer but could leave relative positioning
active. This evidence establishes the Moonraker-base and Qidi grades in the
capability matrix. It was carried into this rewrite and was not rerun here.
It does not establish those behaviors for Creality or Snapmaker, and it does
not cover Qidi-specific filament discovery, box mapping, or print wrappers.
## Build and automated tests
Run the smallest relevant test target first, then broaden the run if the change
crosses shared agent, plug-in, or Device-tab code. Set
`<configured-build-dir>` to the CMake build tree that was already configured for
the compiler, generator, and build type you intend to use. Do not replace it
with the source directory or assume a `build` subdirectory exists.
```powershell
cmake --build <configured-build-dir> --config RelWithDebInfo --target slic3rutils_tests
cmake --build <configured-build-dir> --config RelWithDebInfo --target printer_agent_plugin_tests
ctest --test-dir <configured-build-dir>/tests/libslic3r --output-on-failure
```
`--config RelWithDebInfo` is needed for multi-config generators such as Visual
Studio. Omit it only when the configured generator is single-config and its
build type was selected at configure time. Parallel-build options belong to the
generator: for example, pass `--parallel 6` to CMake when the generator
supports it, or use the generator's own trailing arguments only when that
generator documents them. Do not combine a changed working directory, a
generator-specific flag, and an assumed build-tree layout in one command.
On Windows, start from an MSVC developer environment. A shell without the MSVC
include paths can fail in dependencies before it compiles Orca code, with errors
such as `C1083: Cannot open include file: 'stddef.h'`, `'time.h'`, or `'cstdint'`.
Those signatures are environment failures, not evidence against the agent change.
If a machine exhausts MSVC precompiled-header memory, use the documented lower
parallelism command:
```powershell
cmake --build <configured-build-dir> --config RelWithDebInfo --target slic3rutils_tests --parallel 6
```
Errors such as `C3859: Failed to create virtual memory for PCH` and `C1076:
internal heap limit reached` are machine-specific resource failures. If a build
appears hung and file operations are blocked, inspect for idle `cl.exe` processes
holding locks before changing source.
Relevant automated coverage includes:
- `tests/slic3rutils/test_qidi_printer_agent.cpp` validates malformed and null
Qidi slot responses without throwing.
- `tests/slic3rutils/test_printer_agent.cpp` checks the public printer-agent
surface, including filament-sync mode exposure.
- `tests/slic3rutils/test_printer_agent_plugin.cpp` exercises plug-in
registration, replacement, conflict handling, and deregistration.
Do not present a historic test count, failure count, or skipped-test count as the
current state. Run the command above and attach its own output when a current
result is needed.
## Manual hardware verification
Use a small, disposable model and a printer that can safely accept the actions.
The checklist groups the work in the order below.
1. Confirm the printer accepts its configured URL and API key, then select it in
the Device tab. Verify a fresh status update, not merely a successful connect
return code.
2. Observe temperatures, targets, fan state, print state, filename, progress,
elapsed time, and axis homing while the printer changes state.
3. Exercise safe idle controls first: home, bed and nozzle temperature, and a
harmless G-code command. Verify the printer's action as well as the UI
response.
4. Send a small file without starting it, then start a small print. Once the
print is active, pause it, confirm the printer and UI both enter a paused
state, resume it, and confirm both return to printing. Cancel only after
observing an active or paused print, then confirm that the printer stops and
the UI leaves that state. Cancel an upload and retry a missing input so that
failure handling is observed too.
5. For Moonraker command workers, send three harmless, uniquely marked commands
while a proxy, network shaper, or request log introduces or records latency.
Pass only if the printer-side log records the markers in the same order they
were queued. Record the latency method and the observed order.
6. For a material system, verify populated slots, empty slots, material, colour,
refresh after a change, and cleanup when the system is absent or filament is
removed. The latter must remove obsolete slot or material data from the UI.
Do not infer write support from read support.
7. For Qidi, use a compatible single-nozzle slice and Send it while the Device
tab has no reported nozzle diameter or type. Pass only if Send proceeds past
preflight without `PrintStatusNozzleDataInvalid`; record any reported
diameter/type and any mismatch message separately. This checks the intended
tolerance for missing identity data, not that a mismatched known nozzle is
safe.
8. Verify the camera only on hardware that advertises or implements it. Check
that frames advance, switching printers starts the newly selected camera, and
closing or changing the view does not leave misleading stale output. For
Snapmaker, also test immediately before and after 300 seconds in an
uninterrupted view. The expected renewal result is unknown until hardware
evidence exists. Swap agents and shut down the app after the camera cases to
exercise teardown around the detached callback's raw-`this` lifetime risk.
9. For Moonraker thumbnails, test a reused filename after its thumbnail changes,
responses that use `thumbnail_path` and `relative_path`, and a thumbnail in a
subdirectory below the G-code root. Record the endpoint payload and displayed
image for each case.
10. Disconnect and reconnect the printer, then confirm that new status messages
still reach the UI. A reconnect completion alone is insufficient evidence.
11. Test network and response failures for discovery, status, G-code, upload,
and print start. For each operation, exercise HTTP 401, 404, and 500,
invalid JSON, and a refused socket with a controlled proxy or test server.
Each case must fail clearly or offer a retry, without a crash or a false
success. Record the operation, injected failure, UI result, and any retry.
For Moonraker, record whether the thumbnail endpoint returns the response shape
the agent expects. That response has not yet been verified across a live
Moonraker deployment.
## Troubleshooting by symptom
### Connection appears successful but the Device tab stays stale
Treat status freshness as the connection result. Enable `ORCA_NETWORK_DEBUG` and
look for a new `parse_json: dev_id=` entry after the connection or reconnection.
The unresolved reconnect-delivery problem can complete the second connection
without delivering any new parsed messages. Capture an instrumented second
connection before changing dispatch or message-delay logic, because both remain
plausible causes.
Check identity too. One path can use a bare IP address while another uses
`host:port`; configuring a port can therefore create two machine objects. Do not
diagnose a duplicate as a printer-agent failure until the identities are
compared.
### A control reports success but the printer did not change
First establish that the command has a documented translation in the capability
matrix. Unsupported commands are deliberately rejected rather than silently
accepted. For Moonraker, queued controls are asynchronous, so wait for the
printer-side result and capture the request or log before concluding it was lost.
Moonraker jog is a special case. The current path can leave the printer in
relative positioning mode after a jog. Do not use it as a general verification
control until it is changed to save state, issue `G91` and the move, then restore
state with `SAVE_GCODE_STATE` and `RESTORE_GCODE_STATE`. Extruder-relative moves
use a separate `M83` path.
### A thumbnail is missing or belongs to an earlier print
The thumbnail lookup accepts both `thumbnail_path` and `relative_path`, but the
live endpoint response is not yet verified. The cache is keyed by filename, so
reusing a common name can retain the previous image. Test a distinct filename
before changing the lookup. Paths below the G-code root also need live coverage
for the `relative_path` fallback.
### Filament looks stale, blank, or does not follow an edit
Moonraker-family agents use pull-mode filament sync. Verify the pull request and
the resulting Device-tab update rather than expecting a subscription callback.
Read-side discovery does not establish load, unload, slot-setting, or Auto Refill
support. Happy Hare and AFC macro names are printer-side configuration; do not
guess them. A guessed macro can silently do nothing or issue the wrong action.
### A Python agent disappears or cannot be enabled
Check its agent ID first. A duplicate ID is rejected and the conflicting
capability is disabled rather than auto-promoted later, because automatic
promotion could change the active printer implementation without user intent.
Reload and teardown also need a live check: registration tests cover lifecycle
logic, but a plug-in can still be exposed to API drift or a teardown race in a
real session.
## Known defects and safeguards
- Reconnect delivery remains unresolved. Instrument the second connection before
attempting a fix; the observed failure is stale data after a completed reconnect.
- Moonraker jog can leave relative mode active. Keep the future state-save and
restore sequence together so the jog cannot affect later G-code positioning.
- Qidi's nozzle-data Send-preflight tolerance for unreported diameter and type
has not received a hardware verification.
- A configured `host:port` can coexist with a bare-IP machine identity. This can
duplicate devices and confuse selection.
- Moonraker thumbnail caching can show an old image when a filename is reused.
- Moonraker filament data can be stale, and its pull/read path does not provide
safe generic write-side MMU operations.
- Duplicate plug-in agent IDs are rejected. There is no automatic fallback to a
losing capability after the winner unloads.
- Plug-in implementations can drift from the Python printer-agent API. Treat an
import or interface error as a plug-in compatibility issue until proved otherwise.
- Snapmaker camera callbacks can outlive their view during agent replacement or
shutdown because the detached path retains a raw `this` pointer. Treat a crash
or stale callback during those transitions as a source-derived use-after-free
risk until the lifetime is made explicit.
- Snapmaker writes an IP-specific local camera HTML file below the application
cache. The source contains no cleanup path, so residual files can accumulate
for each unique printer IP. This is source-derived and was not reproduced.
## Not yet hardware-verified
- Moonraker command-worker FIFO behavior under recorded or injected network
latency.
- Moonraker thumbnail responses: reused filenames, `thumbnail_path`,
`relative_path`, and subdirectory paths.
- Snapmaker camera behavior on a live U1: frames, renewal across a long-open
view including the 300-second boundary, switching between printers, agent
replacement, and shutdown teardown.
- Whether Snapmaker's renewal cadence prevents a stale-frame interval. Do not
shorten it as a workaround without resolving the printer-side behavior first.
- Creality CFS detection and preset scoring on a real printer.
- Qidi Send preflight when firmware omits nozzle diameter and type.
- Moonraker-family write-side MMU commands. They remain blocked on verified,
printer-specific macro contracts.
## Background - source locations for maintainers
The Moonraker command worker, status stream, print path, and thumbnail
lookup live in `MoonrakerPrinterAgent`. Qidi maps its material box before routing
to the Moonraker base. Snapmaker adds the camera start request and its snapshot
page. Python agent registration and conflict handling live in
`NetworkAgentFactory`.

File diff suppressed because it is too large Load Diff

View File

@@ -575,7 +575,7 @@ function CapabilityCanRun(plugin, capability) {
}
function IsPluginChecked(plugin) {
return plugin.is_loaded;
return GetStatus(plugin) === "Activated";
}
function HasMixedCapabilityState(plugin) {
@@ -1347,8 +1347,6 @@ function StatusDescription(plugin) {
return "This plugin is still loading.";
case "Error":
return "This plugin is blocked until its error is fixed.";
case "RuntimeError":
return "This plugin is loaded but a capability reported an error.";
case "Inactive":
default:
return "This plugin is inactive. Activate it to install or load it.";

View File

@@ -424,11 +424,6 @@ body.pane-resizing {
font-weight: 600;
}
.status-cell.status-runtimeerror {
color: var(--plugin-status-warn);
font-weight: 600;
}
.status-cell.status-loading {
color: var(--plugin-status-warn);
font-weight: 600;
@@ -685,11 +680,6 @@ body.pane-resizing {
color: var(--plugin-status-danger);
}
.detail-status-chip.status-runtimeerror {
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);
}
.detail-status-chip.status-loading {
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);

View File

@@ -18,7 +18,6 @@
#include <boost/filesystem.hpp>
#include <boost/algorithm/clamp.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/join.hpp>
#include <boost/range/adaptor/transformed.hpp>
#include <boost/nowide/cstdio.hpp>
#include <boost/nowide/fstream.hpp>
@@ -3388,27 +3387,6 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ConfigOptionStrings *filament_color_type = project_config.option<ConfigOptionStrings>("filament_colour_type");
ConfigOptionInts * filament_map = project_config.option<ConfigOptionInts>("filament_map");
ConfigOptionInts * filament_volume_map = project_config.option<ConfigOptionInts>("filament_volume_map");
// why: project filament_multi_colour stores space-joined components per
// filament; decode once so every merge branch seeds from the same view,
// falling back to the main color where the project has no components.
auto decode_project_multi_colors = [this](const std::vector<std::string> &main_colors) {
std::vector<std::vector<std::string>> decoded(main_colors.size());
for (size_t i = 0; i < main_colors.size(); ++i) decoded[i] = {main_colors[i]};
const ConfigOptionStrings *project_multi_color = project_config.option<ConfigOptionStrings>("filament_multi_colour");
if (project_multi_color) {
for (size_t i = 0; i < std::min(decoded.size(), project_multi_color->values.size()); ++i) {
std::vector<std::string> colors = split_string(project_multi_color->values[i], ' ');
// why: a whitespace-only persisted entry splits into empty
// tokens, so keep the main-color fallback rather than
// serializing an empty component.
colors.erase(std::remove_if(colors.begin(), colors.end(),
[](const std::string &c) { return c.empty(); }),
colors.end());
if (!colors.empty()) decoded[i] = colors;
}
}
return decoded;
};
if (color_only) {
auto get_map_index = [&ams_infos](const std::vector<AMSMapInfo> &infos, const AMSMapInfo &temp) {
for (int i = 0; i < infos.size(); i++) {
@@ -3421,7 +3399,20 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
};
auto exist_colors = filament_color->values;
auto exist_multi_color_filment = decode_project_multi_colors(exist_colors);
std::vector<std::vector<std::string>> exist_multi_color_filment(exist_colors.size());
for (size_t i = 0; i < exist_colors.size(); i++) {
exist_multi_color_filment[i] = {exist_colors[i]};
}
ConfigOptionStrings *project_multi_color = project_config.option<ConfigOptionStrings>("filament_multi_colour");
if (project_multi_color) {
for (size_t i = 0; i < std::min(exist_multi_color_filment.size(), project_multi_color->values.size()); i++) {
std::vector<std::string> colors = split_string(project_multi_color->values[i], ' ');
if (!colors.empty()) {
exist_multi_color_filment[i] = colors;
}
}
}
bool mapped_any = false;
if (use_map && !maps.empty()) {
@@ -3497,7 +3488,11 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
auto exist_colors = filament_color->values;
auto exist_color_types = filament_color_type->values;
auto exist_filament_presets = this->filament_presets;
auto exist_multi_color_filment = decode_project_multi_colors(exist_colors);
std::vector<std::vector<std::string>> exist_multi_color_filment;
exist_multi_color_filment.resize(exist_colors.size());
for (int i = 0; i < exist_colors.size(); i++) {
exist_multi_color_filment[i] = {exist_colors[i]};
}
for (size_t i = 0; i < exist_colors.size(); i++) {
if (maps.find(i) != maps.end()) {//mapping exist
auto valid_index = get_map_index(ams_array_maps, maps[i]);
@@ -3505,10 +3500,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
exist_colors[i] = ams_filament_colors[valid_index];
exist_color_types[i] = ams_filament_color_types[valid_index];
exist_filament_presets[i] = ams_filament_presets[valid_index];
// why: a single-color agent tray reports no components, so
// fall back to the printer main color rather than keeping
// the replaced project filament's components.
exist_multi_color_filment[i] = ams_multi_color_filment[valid_index].empty() ? std::vector<std::string>{ams_filament_colors[valid_index]} : ams_multi_color_filment[valid_index];
exist_multi_color_filment[i] = ams_multi_color_filment[valid_index];
} else {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << "check error: array bound (mapping exist)";
}
@@ -3568,7 +3560,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
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.empty() ? std::vector<std::string>{need_append_colors[i].filament_color} : need_append_colors[i].mutli_filament_color);
exist_multi_color_filment.push_back(need_append_colors[i].mutli_filament_color);
}
}
filament_color->values = exist_colors;
@@ -3586,7 +3578,6 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
auto exist_colors = filament_color->values;
auto exist_color_types = filament_color_type->values;
auto exist_presets = this->filament_presets;
auto existing_multi_colors = decode_project_multi_colors(exist_colors);
size_t tray_count = ams_filament_presets.size();
size_t total = std::max(tray_count, exist_presets.size());
@@ -3596,34 +3587,25 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
std::vector<std::string> result_presets;
std::vector<std::vector<std::string>> result_multi_colors;
for (size_t i = 0; i < total; ++i) {
const bool is_loaded = i < ams_infos.size() && ams_infos[i].valid;
for (size_t i = 0; i < total; i++) {
bool is_loaded = (i < ams_infos.size() && ams_infos[i].valid);
if (is_loaded) {
// The printer replaces the project filament at this index.
// Loaded tray: use tray's filament data
result_colors.push_back(ams_filament_colors[i]);
result_color_types.push_back(ams_filament_color_types[i]);
result_presets.push_back(ams_filament_presets[i]);
if (i < ams_multi_color_filment.size() && !ams_multi_color_filment[i].empty()) {
result_multi_colors.push_back(ams_multi_color_filment[i]);
} else {
// why: old project components belong to the replaced
// filament, so use the new printer filament's main color.
result_multi_colors.push_back({ams_filament_colors[i]});
}
// why: update_multi_material_filament_presets() can grow
// filament_presets alone to the extruder count, so presets
// may outrun the colour arrays; fall through to a generic
// filament rather than read past them.
} else if (i < exist_presets.size() && i < exist_colors.size() && i < exist_color_types.size()) {
// An empty or absent printer slot retains the project filament.
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 < exist_presets.size()) {
// Empty tray or beyond tray count: keep existing filament
result_colors.push_back(exist_colors[i]);
result_color_types.push_back(exist_color_types[i]);
result_presets.push_back(exist_presets[i]);
// note: already carries the project components or its
// main-color fallback.
result_multi_colors.push_back(existing_multi_colors[i]);
result_multi_colors.push_back({exist_colors[i]});
} else {
// Neither source has a filament, so create a generic one.
// New slot beyond existing count: prefer a generic filament preset
auto it = std::find_if(filaments.begin(), filaments.end(), [](const Preset &f) {
return f.is_compatible && f.is_system
&& boost::algorithm::starts_with(f.name, "Generic ");
@@ -3669,28 +3651,23 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
void PresetBundle::update_filament_multi_color()
{
const ConfigOptionStrings *filament_color = project_config.option<ConfigOptionStrings>("filament_colour");
ConfigOptionStrings *filament_multi_colour = project_config.option<ConfigOptionStrings>("filament_multi_colour");
// note: cheap defence only. Both keys live in s_project_options and the
// constructor applies them unconditionally, so this never fires today.
if (!filament_color || !filament_multi_colour) return;
// note: this is std::vector::resize(), which safely creates empty inner
// vectors. Not ConfigOptionStrings::resize(), which throws without a
// default value.
ams_multi_color_filment.resize(filament_color->values.size());
for (size_t i = 0; i < filament_color->values.size(); ++i) {
if (ams_multi_color_filment[i].empty()) {
// why: every final main color requires one parallel
// multi-color entry.
ams_multi_color_filment[i] = {filament_color->values[i]};
std::vector<std::string> exsit_multi_colors;
for (auto &fil_item : ams_multi_color_filment){
if (fil_item.empty()) break;
if (fil_item.size() == 1)
exsit_multi_colors.push_back(fil_item[0]);
else {
std::string colors = "";
for (auto &color : fil_item){
colors += color + " ";
}
colors.erase(colors.size() - 1); // remove last space
exsit_multi_colors.push_back(colors);
}
}
std::vector<std::string> serialized;
serialized.reserve(ams_multi_color_filment.size());
for (const auto &components : ams_multi_color_filment)
serialized.push_back(boost::algorithm::join(components, " "));
// why: assignment sets both size and contents, so no resize is needed.
filament_multi_colour->values = std::move(serialized);
ConfigOptionStrings *filament_multi_colour = project_config.option<ConfigOptionStrings>("filament_multi_colour");
filament_multi_colour->resize(exsit_multi_colors.size());
filament_multi_colour->values = exsit_multi_colors;
}
std::vector<int> PresetBundle::get_used_tpu_filaments(const std::vector<int> &used_filaments)

View File

@@ -163,7 +163,7 @@ void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event)
}
}
if (m_obj) {
m_obj->set_access_code(code.ToStdString());
m_obj->set_user_access_code(code.ToStdString());
}
EndModal(wxID_OK);
}

View File

@@ -15,19 +15,6 @@
using namespace nlohmann;
namespace {
// Orca: access_code and the now-removed user_access_code field used to be persisted under
// separate AppConfig keys. Fall back to the legacy key so upgrading users don't lose a
// previously-saved code.
std::string get_access_code_with_legacy_fallback(Slic3r::AppConfig* config, const std::string& dev_id)
{
std::string code = config->get("access_code", dev_id);
if (code.empty())
code = config->get("user_access_code", dev_id);
return code;
}
}
namespace Slic3r
{
DeviceManager::DeviceManager(NetworkAgent* agent)
@@ -61,7 +48,8 @@ namespace Slic3r
obj->bind_sec_link = "secure";
obj->m_is_online = true;
obj->last_alive = Slic3r::Utils::get_current_time_utc();
obj->set_access_code(get_access_code_with_legacy_fallback(config, m.dev_id), false);
obj->set_access_code(config->get("access_code", m.dev_id), false);
obj->set_user_access_code(config->get("user_access_code", m.dev_id), false);
if (obj->has_access_right()) {
localMachineList.insert(std::make_pair(m.dev_id, obj));
} else {
@@ -351,7 +339,8 @@ namespace Slic3r
//load access code
AppConfig* config = Slic3r::GUI::wxGetApp().app_config;
if (config) {
obj->set_access_code(get_access_code_with_legacy_fallback(config, dev_id), false);
obj->set_access_code(Slic3r::GUI::wxGetApp().app_config->get("access_code", dev_id), false);
obj->set_user_access_code(Slic3r::GUI::wxGetApp().app_config->get("user_access_code", dev_id), false);
}
localMachineList.insert(std::make_pair(dev_id, obj));
@@ -393,6 +382,7 @@ namespace Slic3r
obj->m_is_online = true;
obj->last_alive = Slic3r::Utils::get_current_time_utc();
obj->set_access_code(access_code, false);
obj->set_user_access_code(access_code, false);
update_local_machine(*obj);
@@ -588,7 +578,6 @@ namespace Slic3r
}
else
{
Slic3r::GUI::wxGetApp().reset_unsigned_plugin_warning();
if (m_agent)
{
if (it->second->connection_type() != "lan" || it->second->connection_type().empty())
@@ -622,7 +611,6 @@ namespace Slic3r
}
selected_machine = dev_id;
record_user_last_machine(selected_machine);
return true;
}
@@ -887,38 +875,20 @@ namespace Slic3r
}
}
void DeviceManager::record_user_last_machine(const std::string& dev_id)
{
if (Slic3r::GUI::wxGetApp().app_config) {
Slic3r::GUI::wxGetApp().app_config->set("user_last_selected_machine", dev_id);
}
}
std::string DeviceManager::get_user_last_machine() const
{
if (Slic3r::GUI::wxGetApp().app_config) {
const auto& user_last_machine = Slic3r::GUI::wxGetApp().app_config->get("user_last_selected_machine");
if (!user_last_machine.empty()) {
return user_last_machine;
} else if (m_agent) {
return m_agent->get_user_selected_machine();
}
}
return "";
}
void DeviceManager::load_last_machine()
{
// Only reconnect the remembered cloud machine. Do not select an arbitrary
// first machine: agent swaps intentionally leave the selection empty until
// the new agent explicitly selects its configured printer.
if (userMachineList.empty())
// Get all available machines, include cloud machines and lan machines that have access right
auto all_machines = get_my_machine_list();
if (all_machines.empty())
return;
const auto& last_monitor_machine = get_user_last_machine();
if (userMachineList.find(last_monitor_machine) != userMachineList.end())
set_selected_machine(last_monitor_machine);
// Reconnect the machine the user last selected, if it's still available.
// why: no first-available fallback - auto-connecting an arbitrary machine
// fights the agent-swap reset, which intentionally leaves nothing selected.
const std::string last_monitor_machine = m_agent ? m_agent->get_user_selected_machine() : "";
const auto last_machine = all_machines.find(last_monitor_machine);
if (last_machine != all_machines.end())
this->set_selected_machine(last_machine->second->get_dev_id());
}
void DeviceManager::OnMachineBindStateChanged(MachineObject* obj, const std::string& new_state)

View File

@@ -52,9 +52,6 @@ public:
// swap path can reuse it instead of duplicating the two sidebar calls.
void OnSelectedMachineLost();
void record_user_last_machine(const std::string& dev_id);
std::string get_user_last_machine() const;
// local machine
void set_local_selected_machine(std::string dev_id) { local_selected_machine = dev_id; };
MachineObject* get_local_selected_machine() const { return get_local_machine(local_selected_machine); }
@@ -146,4 +143,4 @@ public:
protected:
virtual void on_timer(wxTimerEvent& event);
};
};
};

View File

@@ -449,7 +449,9 @@ bool MachineObject::HasRecentLanMessage()
std::string MachineObject::get_access_code() const
{
return access_code;
if (get_user_access_code().empty())
return access_code;
return get_user_access_code();
}
void MachineObject::set_access_code(std::string code, bool only_refresh)
@@ -468,6 +470,37 @@ void MachineObject::set_access_code(std::string code, bool only_refresh)
}
}
void MachineObject::erase_user_access_code()
{
this->user_access_code = "";
AppConfig* config = GUI::wxGetApp().app_config;
if (config) {
GUI::wxGetApp().app_config->erase("user_access_code", get_dev_id());
//GUI::wxGetApp().app_config->save();
}
}
void MachineObject::set_user_access_code(std::string code, bool only_refresh)
{
this->user_access_code = code;
if (only_refresh && !code.empty()) {
AppConfig* config = GUI::wxGetApp().app_config;
if (config && !code.empty()) {
GUI::wxGetApp().app_config->set_str("user_access_code", get_dev_id(), code);
DeviceManager::update_local_machine(*this);
}
}
}
std::string MachineObject::get_user_access_code() const
{
AppConfig* config = GUI::wxGetApp().app_config;
if (config) {
return GUI::wxGetApp().app_config->get("user_access_code", get_dev_id());
}
return "";
}
std::string MachineObject::get_show_printer_type() const
{
std::string printer_type = this->printer_type;
@@ -1700,11 +1733,9 @@ int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read
int MachineObject::command_ams_calibrate(int ams_id)
{
if (!m_agent) return -1;
int rtn = m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer());
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE)
show_unsupported_dlg(rtn);
return rtn;
std::string gcode_cmd = (boost::format("M620 C%1% \n") % ams_id).str();
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd;
return this->publish_gcode(gcode_cmd);
}
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max)
@@ -1742,11 +1773,9 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
int MachineObject::command_ams_refresh_rfid(std::string tray_id)
{
if (!m_agent) return -1;
int rtn = m_agent->command_ams_refresh_rfid(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer());
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE)
show_unsupported_dlg(rtn);
return rtn;
std::string gcode_cmd = (boost::format("M620 R%1% \n") % tray_id).str();
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd;
return this->publish_gcode(gcode_cmd);
}
int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id)
@@ -1759,22 +1788,12 @@ int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id)
return this->publish_json(j);
}
int MachineObject::command_start_camera()
{
if (!m_agent) return -1;
// why: this fires from the camera view's renew timer, so a refusal must stay silent -
// show_unsupported_dlg() here would pop a dialog every ~5 min on every other printer.
return m_agent->command_start_camera(get_dev_id());
}
int MachineObject::command_ams_select_tray(std::string tray_id)
{
if (!m_agent) return -1;
int rtn = m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer());
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE)
show_unsupported_dlg(rtn);
return rtn;
std::string gcode_cmd = (boost::format("M620 P%1% \n") % tray_id).str();
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd;
return this->publish_gcode(gcode_cmd);
}
int MachineObject::command_ams_control(std::string action)
@@ -2598,12 +2617,7 @@ void MachineObject::reset()
vt_slot.erase(vt_slot.begin() + 1);
}
}
// why: reset reuses MachineObject, so release its lazy subtask
// before dropping the pointer to prevent reconnect leaks.
if (subtask_) {
delete subtask_;
subtask_ = nullptr;
}
subtask_ = nullptr;
has_extra_flow_type = false;
m_partskip_ids.clear();
}
@@ -2613,20 +2627,6 @@ void MachineObject::set_print_state(std::string status)
print_status = status;
}
// why: printer agents can report progress without BBL cloud task identity.
void MachineObject::update_print_progress(const json& value)
{
if (value.is_string())
mc_print_percent = stoi(value.get<std::string>());
else if (value.is_number_integer())
mc_print_percent = value.get<int>();
else
return;
if (BBLSubTask* curr_task = get_subtask())
curr_task->task_progress = mc_print_percent;
}
int MachineObject::connect(bool use_openssl)
{
if (get_dev_ip().empty()) return -1;
@@ -2740,14 +2740,6 @@ int MachineObject::publish_json(const json& json_item, int qos, int flag)
BOOST_LOG_TRIVIAL(error) << "publish_json: " << json_item.dump() << " code: " << rtn;
}
// why: the agent is the only thing that knows what it can translate, so it reports
// not-supported in its return value and this - the single funnel every command_* builder
// passes through - is the one place that turns it into something the user sees. No list of
// unsupported commands is needed anywhere: an agent that has no case for a command says so.
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) {
show_unsupported_dlg(rtn);
}
return rtn;
}
@@ -2915,6 +2907,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
std::string access_code = j_pre["system"]["access_code"].get<std::string>();
if (!access_code.empty()) {
set_access_code(access_code);
set_user_access_code(access_code);
}
}
}
@@ -3304,7 +3297,10 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
print_type = jj["print_type"].get<std::string>();
}
if (jj.contains("mc_percent")) {
update_print_progress(jj["mc_percent"]);
if (jj["mc_percent"].is_string())
mc_print_percent = stoi(j["print"]["mc_percent"].get<std::string>());
else if (jj["mc_percent"].is_number_integer())
mc_print_percent = j["print"]["mc_percent"].get<int>();
}
if (jj.contains("mc_print_sub_stage")) {
if (jj["mc_print_sub_stage"].is_number_integer())
@@ -3474,9 +3470,6 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
this->task_id_ = jj["task_id"].get<std::string>();
}
if (jj.contains("thumbnail_url") && jj["thumbnail_url"].is_string())
m_agent_thumbnail_url = jj["thumbnail_url"].get<std::string>();
if (jj.contains("job_attr")) {
int jobAttr = jj["job_attr"].get<int>();
jobState_ = get_flag_bits(jobAttr, 4, 4);
@@ -3522,6 +3515,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
update_slice_info(jj["project_id"].get<std::string>(), jj["profile_id"].get<std::string>(), jj["subtask_id"].get<std::string>(), plate_index);
BBLSubTask* curr_task = get_subtask();
if (curr_task) {
curr_task->task_progress = mc_print_percent;
curr_task->printing_status = print_status;
curr_task->task_id = jj["subtask_id"].get<std::string>();
}
@@ -3824,7 +3818,6 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
has_ipcam = true;
} else {
has_ipcam = false;
webcam_stream_url.clear();
}
}
if (ipcam.contains("resolution")) {
@@ -3859,9 +3852,6 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
liveview_local = local_rtsp_url.empty() ? LVL_None : local_rtsp_url == "disable"
? LVL_Disable : boost::algorithm::starts_with(local_rtsp_url, "rtsps") ? LVL_Rtsps : LVL_Rtsp;
}
if (ipcam.contains("stream_url") && ipcam["stream_url"].is_string()) {
webcam_stream_url = ipcam["stream_url"].get<std::string>();
}
if (ipcam.contains("tutk_server")) {
tutk_state = ipcam["tutk_server"].get<std::string>();
}
@@ -4657,40 +4647,6 @@ void MachineObject::set_ctt_dlg( wxString text){
}
}
void MachineObject::show_unsupported_dlg(int code)
{
// why: a dead control invites repeat clicks, and the frame is modeless - without the guard
// every click stacks another one. Same shape as set_ctt_dlg above, including the reset on
// both hide and close so a dismissed dialog can reappear on the next attempt.
if (m_unsupported_dlg_shown) {
return;
}
m_unsupported_dlg_shown = true;
// why: two codes so the user learns which kind of dead end this is - the slicer having no
// translation for the command, or the printer's own config lacking the hardware to run it.
const wxString text = (code == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) ?
_L("This printer is not configured with the hardware this control needs.") :
_L("This control is not supported on this printer.");
// note: constructed directly rather than through CallAfter because every publish_json caller
// is on the UI thread - clicks come from wx handlers, and the agent marshals its own push
// callbacks back to main before parse_json runs. set_ctt_dlg relies on the same property.
auto unsupported_dlg = new GUI::SecondaryCheckDialog(nullptr, wxID_ANY, _L("Warning"),
GUI::SecondaryCheckDialog::VisibleButtons::ONLY_CONFIRM);
unsupported_dlg->update_text(text);
unsupported_dlg->Bind(wxEVT_SHOW, [this](auto& e) {
if (!e.IsShown()) {
m_unsupported_dlg_shown = false;
}
});
unsupported_dlg->Bind(wxEVT_CLOSE_WINDOW, [this](auto& e) {
e.Skip();
m_unsupported_dlg_shown = false;
});
unsupported_dlg->on_show();
}
int MachineObject::publish_gcode(std::string gcode_str)
{
json j;

View File

@@ -113,6 +113,7 @@ private:
std::string dev_name;
std::string dev_ip;
std::string access_code;
std::string user_access_code;
// type, time stamp, delay
std::vector<std::tuple<std::string, uint64_t, uint64_t>> message_delay;
@@ -227,6 +228,11 @@ public:
std::string get_access_code() const;
void set_access_code(std::string code, bool only_refresh = true);
/*user access code*/
void set_user_access_code(std::string code, bool only_refresh = true);
void erase_user_access_code();
std::string get_user_access_code() const;
//PRINTER_TYPE printer_type = PRINTER_3DPrinter_UKNOWN;
std::string printer_type; /* model_id */
std::string get_show_printer_type() const;
@@ -266,11 +272,9 @@ public:
bool m_is_online;
bool m_lan_mode_connection_state{false};
bool m_set_ctt_dlg{ false };
bool m_unsupported_dlg_shown{ false };
void set_lan_mode_connection_state(bool state) {m_lan_mode_connection_state = state;};
bool get_lan_mode_connection_state() {return m_lan_mode_connection_state;};
void set_ctt_dlg( wxString text);
void show_unsupported_dlg(int code);
int parse_msg_count = 0;
int keep_alive_count = 0;
std::chrono::system_clock::time_point last_update_time; /* last received print data from machine */
@@ -537,7 +541,6 @@ public:
bool xcam_first_layer_inspector { false };
time_t xcam_first_layer_hold_start = 0;
std::string local_rtsp_url;
std::string webcam_stream_url;
std::string tutk_state;
enum LiveviewLocal {
LVL_None,
@@ -699,8 +702,6 @@ public:
std::string subtask_id_;
std::string job_id_;
std::string last_subtask_id_;
// note: printer-agent-supplied thumbnail url, empty when the agent supplies none.
std::string m_agent_thumbnail_url;
BBLSliceInfo* slice_info {nullptr};
boost::thread* get_slice_info_thread { nullptr };
boost::thread* get_model_task_thread { nullptr };
@@ -806,7 +807,6 @@ public:
int command_ams_select_tray(std::string tray_id);
int command_ams_refresh_rfid(std::string tray_id);
int command_ams_refresh_rfid2(int ams_id, int slot_id);
int command_start_camera();
int command_ams_control(std::string action);
int command_ams_drying_stop();
int command_start_extrusion_cali(int tray_index, int nozzle_temp, int bed_temp, float max_volumetric_speed, std::string setting_id = "");
@@ -888,7 +888,6 @@ public:
static bool is_in_printing_status(std::string status);
void set_print_state(std::string status);
void update_print_progress(const json& value);
bool is_connected();
bool is_connecting();

View File

@@ -2166,12 +2166,7 @@ void GUI_App::init_networking_callbacks()
obj->is_tunnel_mqtt = tunnel;
obj->command_request_push_all(true);
obj->command_get_version();
// Do NOT erase the access code. Erasing will cause has_access_right to be false
// whenever the device slot isn't populated yet (e.g. LAN reselect after logout).
// This filters this printer out of get_my_machine_list, silently dropping every status message
// AND the get_access_code reply that would refill the code, leaving a permanently
// dead "connected but no live data" state.
// obj -> set_access_code("");
obj->erase_user_access_code();
obj->command_get_access_code();
if (m_agent)
m_agent->install_device_cert(obj->get_dev_id(), obj->is_lan_mode_printer());
@@ -2221,6 +2216,7 @@ void GUI_App::init_networking_callbacks()
wxString text;
if (msg == "5") {
obj->set_access_code("");
obj->erase_user_access_code();
text = wxString::Format(_L("Incorrect password"));
wxGetApp().show_dialog(text);
} else {
@@ -8290,7 +8286,7 @@ bool GUI_App::show_modal_ip_address_enter_dialog(bool input_sn, wxString title)
wxGetApp().app_config->save();
obj->set_dev_ip(ip_address.ToStdString());
obj->set_access_code(access_code.ToStdString());
obj->set_user_access_code(access_code.ToStdString());
}
}
});

View File

@@ -11,7 +11,6 @@ namespace Slic3r
// IMPORTANT: ordinal order is the Plugins dialog Status sort priority.
Activated,
Error,
RuntimeError,
Inactive,
Loading
};
@@ -22,28 +21,11 @@ namespace Slic3r
{
case PluginStatus::Activated: return "Activated";
case PluginStatus::Error: return "Error";
case PluginStatus::RuntimeError: return "RuntimeError";
case PluginStatus::Inactive: return "Inactive";
case PluginStatus::Loading: return "Loading";
}
return "Inactive";
}
// why: a plugin whose module is live but whose catalog carries an error is a
// RUNTIME fault (e.g. a capability rejected at register time) - it stays
// loaded/checked and is only flagged, distinct from a load-time Error where
// the module never came up. Loading wins over both so an in-flight reload
// never flashes an error.
inline PluginStatus resolve_plugin_status(bool loading, bool has_error, bool is_loaded)
{
if (loading)
return PluginStatus::Loading;
if (has_error)
return is_loaded ? PluginStatus::RuntimeError : PluginStatus::Error;
if (is_loaded)
return PluginStatus::Activated;
return PluginStatus::Inactive;
}
}
} // namespace Slic3r::GUI

View File

@@ -236,7 +236,6 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
payload_item["label"] = dialog_item.display_name;
payload_item["source"] = to_string(dialog_item.source);
payload_item["status"] = to_string(dialog_item.status);
payload_item["is_loaded"] = dialog_item.is_loaded;
payload_item["error"] = dialog_item.error_text;
payload_item["update_status"] = to_string(dialog_item.update_status);
payload_item["unauthorized"] = dialog_item.unauthorized;
@@ -381,7 +380,14 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.sharing_token = descriptor.sharing_token;
item.thumbnail_url = descriptor.thumbnail_url;
item.status = resolve_plugin_status(item.loading, item.has_error, item.is_loaded);
if (item.loading)
item.status = PluginStatus::Loading;
else if (item.has_error)
item.status = PluginStatus::Error;
else if (item.is_loaded)
item.status = PluginStatus::Activated;
else
item.status = PluginStatus::Inactive;
item.available_actions = evaluate_action_policy(item);
const bool has_enabled_script = std::any_of(item.capabilities.begin(), item.capabilities.end(),
@@ -657,9 +663,6 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled)
}
BOOST_LOG_TRIVIAL(info) << "Plugin unloaded from Plugins dialog: " << plugin_key;
// A user-disabled plugin has no meaningful error state.
if (!manager.clear_plugin_error(plugin_key))
BOOST_LOG_TRIVIAL(warning) << "Failed to clear plugin error for " << plugin_key << " (failed to find)";
// A prior activation of this plugin is moot now; drop it so no stale "Activated" arrives later.
if (m_activating_plugin_key == plugin_key)
m_activating_plugin_key.clear();

View File

@@ -1991,7 +1991,7 @@ void InputIpAddressDialog::workerThreadFunc(std::string str_ip, std::string str_
if (w.expired()) return;
if (m_obj) {
m_obj->set_access_code(str_access_code);
m_obj->set_user_access_code(str_access_code);
wxGetApp().getDeviceManager()->set_selected_machine(m_obj->get_dev_id());
}

View File

@@ -3695,32 +3695,10 @@ void SelectMachineDialog::on_send_print()
m_print_job->on_success([this]() { finish_mode(); });
m_print_job->on_check_ip_address_fail([this]() {
// Invoked from the PrintJob worker thread when the LAN pre-flight (file upload
// verification) fails. Marshal device/UI access to the main thread.
CallAfter([this]()
{
// Reset the dialog out of sending mode so the user can retry.
wxCommandEvent* evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS);
wxQueueEvent(this, evt);
DeviceManager* dev = wxGetApp().getDeviceManager();
MachineObject* obj = dev ? dev->get_selected_machine() : nullptr;
if (obj && obj->is_connected())
{
// Connected: failed on file upload
MessageDialog dlg(this,
_L("Failed to upload the file to the printer's storage. Please try again."),
_L("Send Failed"), wxOK | wxICON_ERROR);
dlg.ShowModal();
}
else
{
// Not connected: reenter ip and access code
wxGetApp().show_ip_address_enter_dialog();
}
});
});
wxCommandEvent* evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS);
wxQueueEvent(this, evt);
wxGetApp().show_ip_address_enter_dialog();
});
// update ota version
NetworkAgent* agent = wxGetApp().getAgent();
@@ -4551,13 +4529,11 @@ bool SelectMachineDialog::CheckErrorExtruderNozzleWithSlicing(MachineObject* obj
// check nozzle data valid
{
// Commented out the following as ntUndefine and 0.0f are default values
// (signifying that the value is not given) that should PASS, not fail
// if (installed_ext_nozzle.GetNozzleType() == NozzleType::ntUndefine ||
// installed_ext_nozzle.GetNozzleDiameter() <= 0.0f) {
// show_status(PrintDialogStatus::PrintStatusNozzleDataInvalid);
// return false;
// }
if (installed_ext_nozzle.GetNozzleType() == NozzleType::ntUndefine ||
installed_ext_nozzle.GetNozzleDiameter() <= 0.0f) {
show_status(PrintDialogStatus::PrintStatusNozzleDataInvalid);
return false;
}
if (obj_->is_nozzle_flow_type_supported() &&
installed_ext_nozzle.GetNozzleFlowType() == NozzleFlowType::NONE_FLOWTYPE) {
@@ -4586,10 +4562,7 @@ bool SelectMachineDialog::CheckErrorExtruderNozzleWithSlicing(MachineObject* obj
// check nozzle diameter
{
// 0.0f is default when there is no nozzle diameter is given.
// In nozzle_diameter == 0.0f case, it passes and does not require a comparison
if (installed_ext_nozzle.GetNozzleDiameter() > 0.0f &&
slicing_ext.nozzle_diameter != installed_ext_nozzle.GetNozzleDiameter()) {
if (slicing_ext.nozzle_diameter != installed_ext_nozzle.GetNozzleDiameter()) {
std::vector<wxString> msg_params;
if (ext_sys->GetTotalExtderCount() == 2) {
const wxString& mismatch_nozzle_str = _get_nozzle_name(ext_sys->GetTotalExtderCount(), slicing_ext_idx);

View File

@@ -704,6 +704,7 @@ void SelectMachinePopup::update_user_devices()
}
mobj->set_access_code("");
mobj->erase_user_access_code();
}
if (GUI::wxGetApp().plater())

View File

@@ -300,7 +300,7 @@ SendToPrinterDialog::SendToPrinterDialog(Plater *plater)
m_storage_panel->Layout();
// try to connect
m_statictext_printer_msg = new wxStaticText(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(FromDIP(400), -1), wxALIGN_CENTER_HORIZONTAL);
m_statictext_printer_msg = new wxStaticText(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxALIGN_CENTER_HORIZONTAL);
m_statictext_printer_msg->SetFont(::Label::Body_13);
m_statictext_printer_msg->SetForegroundColour(*wxBLACK);
m_statictext_printer_msg->Hide();
@@ -760,25 +760,9 @@ void SendToPrinterDialog::update_priner_status_msg(wxString msg, bool is_warning
if (str_new != str_old) {
if (m_statictext_printer_msg->GetLabel() != msg) {
m_statictext_printer_msg->SetLabel(msg);
const int wrap_width = FromDIP(400);
m_statictext_printer_msg->Wrap(wrap_width);
int line_count = 1;
const wxString wrapped_label = m_statictext_printer_msg->GetLabel();
for (size_t i = 0; i < wrapped_label.length(); ++i) {
if (wrapped_label[i] == '\n')
++line_count;
}
wxCoord text_width = 0;
wxCoord text_height = 0;
m_statictext_printer_msg->GetTextExtent(msg, &text_width, &text_height);
const int extent_line_count = text_width > 0 ?
std::max(1, (static_cast<int>(text_width) + wrap_width - 1) / wrap_width) : 1;
line_count = std::max(line_count, extent_line_count);
const int line_height = std::max(m_statictext_printer_msg->GetCharHeight(), static_cast<int>(text_height));
const int min_height = std::max(m_statictext_printer_msg->GetBestSize().GetHeight(),
line_count * line_height + FromDIP(2));
m_statictext_printer_msg->SetMinSize(wxSize(wrap_width, min_height));
m_statictext_printer_msg->SetMaxSize(wxDefaultSize);
m_statictext_printer_msg->SetMinSize(wxSize(FromDIP(400), -1));
m_statictext_printer_msg->SetMaxSize(wxSize(FromDIP(400), -1));
m_statictext_printer_msg->Wrap(FromDIP(400));
m_statictext_printer_msg->Show();
Layout();
Fit();
@@ -1504,9 +1488,6 @@ void SendToPrinterDialog::show_status(PrintDialogStatus status, std::vector<wxSt
Enable_Send_Button(false);
Enable_Refresh_Button(true);
} else if (status == PrintDialogStatus::PrintStatusPublicInitFailed) {
wxString msg_text = _L(
"Failed to initialize the printer file transfer. Please check the connection and try again.");
update_print_status_msg(msg_text, true, true);
Enable_Send_Button(false);
Enable_Refresh_Button(true);
} else if (status == PrintDialogStatus::PrintStatusPublicUploadFiled) {
@@ -1684,18 +1665,30 @@ extern void refresh_agora_url(char const *device, char const *dev_ver, char
void SendToPrinterDialog::GetConnection()
{
DeviceManager *dm = GUI::wxGetApp().getDeviceManager();
MachineObject *obj = dm ? dm->get_selected_machine() : nullptr;
if (!obj)
MachineObject *obj = dm->get_selected_machine();
if (obj == nullptr) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : obj is empty";
if (obj && !obj->get_file_remote())
m_connection_status = ConnectionStatus::NOT_START;
}
int remote_proto = obj->get_file_remote();
if (!remote_proto) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : remote_proto is not support";
if (obj && obj->is_camera_busy_off())
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : camera is busy";
m_connection_status = ConnectionStatus::NOT_START;
}
NetworkAgent* agent = wxGetApp().getAgent();
if (obj->is_camera_busy_off()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : camera is busy";
m_connection_status = ConnectionStatus::NOT_START;
}
if (m_url_timer && m_url_timer->IsRunning())
NetworkAgent *agent = wxGetApp().getAgent();
std::string agent_version = agent ? agent->get_version() : "";
std::string dev_ver = obj->get_ota_version();
std::string dev_id = obj->get_dev_id();
if (m_url_timer && m_url_timer->IsRunning())
{
m_url_timer->Stop();
}
@@ -1718,40 +1711,19 @@ void SendToPrinterDialog::GetConnection()
m_url_timer->GetId());
m_url_timer->StartOnce(8000);
if (obj && agent)
{
std::string dev_ver = obj->get_ota_version();
std::string dev_id = obj->get_dev_id();
if (agent) {
if (m_tcp_try_connect) {
std::string devIP = obj->get_dev_ip();
std::string accessCode = obj->get_access_code();
std::string url = "bambu:///local/" + devIP + "?port=6000&user=" + "bblp" + "&passwd=" + accessCode;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Connect method tcp, dev_id=" << dev_id
<< ", dev_ip=" << devIP << ", access_code_len=" << accessCode.size();
try
{
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection([this](bool is_success, int err_code, std::string error_msg)
{
CallAfter([this, is_success, err_code, error_msg]()
{
OnConnection(is_success, err_code, error_msg);
});
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Connect method tcp";
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection([this](bool is_success, int err_code, std::string error_msg) {
CallAfter([this, is_success, err_code, error_msg]() {
OnConnection(is_success, err_code, error_msg);
});
m_filetransfer_tunnel->start_connect();
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": tcp FileTransferTunnel unavailable for dev_id=" <<
dev_id
<< " dev_ip=" << devIP << ": " << e.what();
if (m_url_timer && m_url_timer->IsRunning()) m_url_timer->Stop();
m_filetransfer_tunnel.reset();
m_connection_status = ConnectionStatus::CONNECTION_FAILED;
show_status(PrintDialogStatus::PrintStatusPublicInitFailed);
}
});
m_filetransfer_tunnel->start_connect();
}
else if (m_tutk_try_connect)
{
@@ -1779,28 +1751,11 @@ void SendToPrinterDialog::GetConnection()
if (boost::algorithm::starts_with(url, "bambu:///"))
{
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Connect method tutk";
try
{
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection(
[this](bool is_success, int err_code, std::string error_msg)
{
CallAfter([this, is_success, err_code, error_msg]()
{
OnConnection(is_success, err_code, error_msg);
});
});
m_filetransfer_tunnel->start_connect();
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": tutk FileTransferTunnel unavailable: " << e.
what();
if (m_url_timer && m_url_timer->IsRunning()) m_url_timer->Stop();
m_filetransfer_tunnel.reset();
m_connection_status = ConnectionStatus::CONNECTION_FAILED;
show_status(PrintDialogStatus::PrintStatusPublicInitFailed);
}
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection([this](bool is_success, int err_code, std::string error_msg) {
CallAfter([this, is_success, err_code, error_msg]() { OnConnection(is_success, err_code, error_msg); });
});
m_filetransfer_tunnel->start_connect();
}
else
{
@@ -1899,17 +1854,8 @@ void SendToPrinterDialog::ResetTunnelAndJob()
void SendToPrinterDialog::CreateMediaAbilityJob()
{
nlohmann::json media_ability = {{"cmd_type", 7}};
try
{
m_filetransfer_mediability_job = std::make_unique<FileTransferJob>(module(), std::string(media_ability.dump()));
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": FileTransferJob unavailable: " << e.what();
show_status(PrintDialogStatus::PrintStatusPublicInitFailed);
return;
}
nlohmann::json media_ability = {{"cmd_type", 7}};
m_filetransfer_mediability_job = std::make_unique<FileTransferJob>(module(), std::string(media_ability.dump()));
m_filetransfer_mediability_job->on_result([this](int res, int resp_ec, std::string json_res, std::vector<std::byte> bin_res) {
//this pl
CallAfter([this, res, resp_ec, json_res] {
@@ -1964,20 +1910,11 @@ void SendToPrinterDialog::CreateUploadFileJob(const std::string &path, const std
{"cmd_type", 5},
};
upload_params["dest_storage"] = m_selected_storage;
upload_params["dest_name"] = name; // filenme no path
upload_params["file_path"] = path;
upload_params["dest_name"] = name; // filenme no path
upload_params["file_path"] = path;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Begin CreateUploadFileJob";
try
{
m_filetransfer_uploadfile_job = std::make_unique<FileTransferJob>(module(), std::string(upload_params.dump()));
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": FileTransferJob unavailable: " << e.what();
show_status(PrintDialogStatus::PrintStatusPublicUploadFiled);
return;
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Begin CreateUploadFileJob";
m_filetransfer_uploadfile_job = std::make_unique<FileTransferJob>(module(), std::string(upload_params.dump()));
m_filetransfer_uploadfile_job->on_result([this](int res, int resp_ec, std::string json_res, std::vector<std::byte> bin_res) { //
CallAfter([this, res, resp_ec, json_res, bin_res] {
UploadFileRessultCallback(res, resp_ec,json_res, bin_res);

View File

@@ -12,7 +12,6 @@
#include "MsgDialog.hpp"
#include "slic3r/Utils/Http.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
#include "libslic3r/Thread.hpp"
#include "DeviceErrorDialog.hpp"
@@ -2312,40 +2311,6 @@ void StatusPanel::update_camera_state(MachineObject* obj)
{
if (!obj) return;
const bool has_printer_webcam = !obj->webcam_stream_url.empty();
if (has_printer_webcam) {
if (m_printer_webcam_url != obj->webcam_stream_url) {
// why: start timing belongs to the loaded camera.
// carrying it across printers suppresses the new camera's initial start.
m_camera_start_sent = std::chrono::steady_clock::time_point{};
m_custom_camera_view->LoadURL(obj->webcam_stream_url);
m_custom_camera_view->Show();
m_media_ctrl->Hide();
m_media_play_ctrl->Hide();
m_printer_webcam_url = obj->webcam_stream_url;
}
m_camera_switch_button->Hide();
if (!m_custom_camera_view->IsShown()) {
// why: do not compare or reload the WebView URL per tick, or redirects can cause a reload loop.
m_custom_camera_view->Show();
m_media_ctrl->Hide();
m_media_play_ctrl->Hide();
}
// why: printers like the U1 capture only while asked and retire the capture task ~362 s
// after each start, so the open camera view has to renew ahead of that. 300 s matches
// Snapmaker's own client. Agents that do not need it refuse the call silently.
const auto now = std::chrono::steady_clock::now();
if (m_camera_start_sent == std::chrono::steady_clock::time_point{} ||
now - m_camera_start_sent >= std::chrono::seconds(300)) {
obj->command_start_camera();
m_camera_start_sent = now;
}
} else if (!m_printer_webcam_url.empty()) {
handle_camera_source_change();
m_printer_webcam_url.clear();
m_camera_start_sent = std::chrono::steady_clock::time_point{};
}
//sdcard
auto sdcard_state = obj->GetStorage()->get_sdcard_state();
if (m_last_sdcard != sdcard_state) {
@@ -2377,12 +2342,7 @@ void StatusPanel::update_camera_state(MachineObject* obj)
m_last_recording = obj->is_recording() ? 1 : 0;
}
if (has_printer_webcam) {
if (m_bitmap_recording_img->IsShown()) {
m_bitmap_recording_img->Hide();
m_panel_monitoring_title->Layout();
}
} else if (!m_bitmap_recording_img->IsShown()) {
if (!m_bitmap_recording_img->IsShown()) {
m_bitmap_recording_img->Show();
m_panel_monitoring_title->Layout();
}
@@ -2439,8 +2399,6 @@ void StatusPanel::update_camera_state(MachineObject* obj)
bool show_vcamera = m_media_play_ctrl->IsStreaming();
m_camera_popup->update(show_vcamera);
}
m_setting_button->Show(!has_printer_webcam);
}
StatusPanel::StatusPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, long style, const wxString &name)
@@ -2728,8 +2686,7 @@ void StatusPanel::on_subtask_partskip(wxCommandEvent &event)
void StatusPanel::on_subtask_pause_resume(wxCommandEvent &event)
{
if (obj) {
const bool was_resume = obj->can_resume();
if (was_resume) {
if (obj->can_resume()) {
BOOST_LOG_TRIVIAL(info) << "monitor: resume current print task dev_id =" << obj->get_dev_id();
obj->command_task_resume();
}
@@ -2737,13 +2694,6 @@ void StatusPanel::on_subtask_pause_resume(wxCommandEvent &event)
BOOST_LOG_TRIVIAL(info) << "monitor: pause current print task dev_id =" << obj->get_dev_id();
obj->command_task_pause();
}
if (is_moonraker_agent()) {
m_pause_resume_pending = true;
m_pause_resume_was_resume = was_resume;
m_pause_resume_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(6);
m_pause_resume_machine_id = obj->get_dev_id();
m_project_task_panel->enable_pause_resume_button(false, was_resume ? "resume_disable" : "pause_disable");
}
}
}
@@ -2755,12 +2705,6 @@ void StatusPanel::on_subtask_abort(wxCommandEvent &event)
if (obj) {
BOOST_LOG_TRIVIAL(info) << "monitor: stop current print task dev_id =" << obj->get_dev_id();
obj->command_task_abort();
if (is_moonraker_agent()) {
m_abort_pending = true;
m_abort_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(6);
m_abort_machine_id = obj->get_dev_id();
m_project_task_panel->enable_abort_button(false);
}
}
});
}
@@ -3734,25 +3678,6 @@ void StatusPanel::update_model_info()
void StatusPanel::update_subtask(MachineObject *obj)
{
if (!obj) return;
const auto now = std::chrono::steady_clock::now();
if (m_pause_resume_pending) {
if (!is_moonraker_agent() || m_pause_resume_machine_id != obj->get_dev_id() ||
obj->can_resume() != m_pause_resume_was_resume) {
m_pause_resume_pending = false;
} else if (now >= m_pause_resume_deadline) {
BOOST_LOG_TRIVIAL(warning) << "StatusPanel: Moonraker pause/resume command did not change printer state";
m_pause_resume_pending = false;
}
}
if (m_abort_pending) {
if (!is_moonraker_agent() || m_abort_machine_id != obj->get_dev_id() || obj->print_status == "FAILED" ||
obj->print_status == "FINISH" || obj->print_status == "IDLE") {
m_abort_pending = false;
} else if (now >= m_abort_deadline) {
BOOST_LOG_TRIVIAL(warning) << "StatusPanel: Moonraker abort command did not change printer state";
m_abort_pending = false;
}
}
if (m_current_print_mode != PRINGINT) {
if (calib_bitmap == nullptr) {
m_calib_mode = get_obj_calibration_mode(obj, m_calib_method, cali_stage);
@@ -3860,12 +3785,10 @@ void StatusPanel::update_subtask(MachineObject *obj)
}
update_basic_print_data(false);
} else {
if (!m_pause_resume_pending) {
if (obj->can_resume()) {
m_project_task_panel->enable_pause_resume_button(true, "resume");
} else {
m_project_task_panel->enable_pause_resume_button(true, "pause");
}
if (obj->can_resume()) {
m_project_task_panel->enable_pause_resume_button(true, "resume");
} else {
m_project_task_panel->enable_pause_resume_button(true, "pause");
}
m_project_task_panel->enable_partskip_button(obj, true);
// update printing stage
@@ -3922,9 +3845,7 @@ void StatusPanel::update_subtask(MachineObject *obj)
m_project_task_panel->market_scoring_hide();
}
} else { // model printing is not finished, hide scoring page
if (!m_abort_pending) {
m_project_task_panel->enable_abort_button(true);
}
m_project_task_panel->enable_abort_button(true);
m_project_task_panel->market_scoring_hide();
m_project_task_panel->get_request_failed_panel()->Hide();
}
@@ -3999,61 +3920,39 @@ void StatusPanel::update_cloud_subtask(MachineObject *obj)
update_calib_bitmap();
if (obj->slice_info) {
m_request_url = wxString(obj->slice_info->thumbnail_url);
load_thumbnail_from_url(m_request_url, obj);
if (!m_request_url.IsEmpty()) {
wxImage img;
std::map<wxString, wxImage>::iterator it = img_list.find(m_request_url);
if (it != img_list.end()) {
if (m_current_print_mode != PrintingTaskType::CALIBRATION ||(m_calib_mode == CalibMode::Calib_Flow_Rate && m_calib_method == CalibrationMethod::CALI_METHOD_MANUAL)) {
img = it->second;
wxImage resize_img = img.Scale(m_project_task_panel->get_bitmap_thumbnail()->GetSize().x, m_project_task_panel->get_bitmap_thumbnail()->GetSize().y);
m_project_task_panel->set_thumbnail_img(resize_img, "");
m_project_task_panel->set_brightness_value(get_brightness_value(resize_img));
}
if (this->obj) {
m_project_task_panel->set_plate_index(obj->m_plate_index);
} else {
m_project_task_panel->set_plate_index(-1);
}
task_thumbnail_state = ThumbnailState::TASK_THUMBNAIL;
BOOST_LOG_TRIVIAL(trace) << "web_request: use cache image";
} else {
web_request = wxWebSession::GetDefault().CreateRequest(this, m_request_url);
BOOST_LOG_TRIVIAL(trace) << "monitor: start request thumbnail, url = " << m_request_url;
web_request.Start();
m_start_loading_thumbnail = false;
}
}
}
}
}
bool StatusPanel::load_thumbnail_from_url(const wxString &url, MachineObject *obj)
{
if (url.IsEmpty())
return false;
wxImage img;
std::map<wxString, wxImage>::iterator it = img_list.find(url);
if (it != img_list.end()) {
if (m_current_print_mode != PrintingTaskType::CALIBRATION ||(m_calib_mode == CalibMode::Calib_Flow_Rate && m_calib_method == CalibrationMethod::CALI_METHOD_MANUAL)) {
img = it->second;
wxImage resize_img = img.Scale(m_project_task_panel->get_bitmap_thumbnail()->GetSize().x, m_project_task_panel->get_bitmap_thumbnail()->GetSize().y);
m_project_task_panel->set_thumbnail_img(resize_img, "");
m_project_task_panel->set_brightness_value(get_brightness_value(resize_img));
}
if (this->obj) {
m_project_task_panel->set_plate_index(obj->m_plate_index);
} else {
m_project_task_panel->set_plate_index(-1);
}
task_thumbnail_state = ThumbnailState::TASK_THUMBNAIL;
BOOST_LOG_TRIVIAL(trace) << "web_request: use cache image";
} else {
m_request_url = url;
web_request = wxWebSession::GetDefault().CreateRequest(this, m_request_url);
BOOST_LOG_TRIVIAL(trace) << "monitor: start request thumbnail, url = " << m_request_url;
web_request.Start();
m_start_loading_thumbnail = false;
}
return true;
}
void StatusPanel::update_sdcard_subtask(MachineObject *obj)
{
if (!obj) return;
const wxString thumbnail_url = wxString(obj->m_agent_thumbnail_url);
if (!thumbnail_url.IsEmpty()) {
// why: Moonraker has no prediction or weight data, so keep it on the sdcard path.
if (m_request_url != thumbnail_url || !m_load_sdcard_thumbnail) {
if (web_request.IsOk() && web_request.GetState() == wxWebRequest::State_Active)
web_request.Cancel();
update_calib_bitmap();
m_request_url = thumbnail_url;
load_thumbnail_from_url(thumbnail_url, obj);
m_load_sdcard_thumbnail = true;
}
return;
}
if (!m_load_sdcard_thumbnail || !m_request_url.IsEmpty()) {
if (!m_load_sdcard_thumbnail) {
update_calib_bitmap();
if (m_current_print_mode != PrintingTaskType::CALIBRATION) {
m_project_task_panel->get_bitmap_thumbnail()->SetBitmap(m_thumbnail_sdcard.bmp());
@@ -4061,14 +3960,11 @@ void StatusPanel::update_sdcard_subtask(MachineObject *obj)
}
task_thumbnail_state = ThumbnailState::SDCARD_THUMBNAIL;
m_load_sdcard_thumbnail = true;
m_request_url.clear();
}
}
void StatusPanel::reset_printing_values()
{
m_pause_resume_pending = false;
m_abort_pending = false;
m_project_task_panel->enable_partskip_button(nullptr, false);
m_project_task_panel->enable_pause_resume_button(false, "pause_disable");
m_project_task_panel->enable_abort_button(false);
@@ -4094,12 +3990,6 @@ void StatusPanel::reset_printing_values()
this->Layout();
}
bool StatusPanel::is_moonraker_agent() const
{
auto* agent = wxGetApp().getAgent();
return agent && std::dynamic_pointer_cast<Slic3r::MoonrakerPrinterAgent>(agent->get_printer_agent()) != nullptr;
}
void StatusPanel::on_axis_ctrl_xy(wxCommandEvent &event)
{
if (!obj) return;
@@ -5281,10 +5171,6 @@ bool StatusPanel::is_stage_list_info_changed(MachineObject *obj)
void StatusPanel::set_default()
{
BOOST_LOG_TRIVIAL(trace) << "status_panel: set_default";
if (!m_printer_webcam_url.empty()) {
handle_camera_source_change();
m_printer_webcam_url.clear();
}
obj = nullptr;
last_subtask = nullptr;
last_tray_exist_bits = -1;

View File

@@ -14,7 +14,6 @@
#include <wx/sizer.h>
#include <wx/gbsizer.h>
#include <wx/webrequest.h>
#include <chrono>
#include "wxMediaCtrl2.h"
#include "MediaPlayCtrl.h"
#include "AMSSetting.hpp"
@@ -631,7 +630,6 @@ class StatusPanel : public StatusBasePanel
{
private:
friend class MonitorPanel;
bool load_thumbnail_from_url(const wxString &url, MachineObject *obj);
protected:
std::shared_ptr<SliceInfoPopup> m_slice_info_popup;
@@ -665,9 +663,6 @@ protected:
int m_last_timelapse = -1;
int m_last_extrusion = -1;
int m_last_vcamera = -1;
std::string m_printer_webcam_url;
// note: zero = not started; see update_camera_state() for the renew interval.
std::chrono::steady_clock::time_point m_camera_start_sent{};
int m_model_mall_request_count = 0;
bool m_is_load_with_temp = false;
json m_rating_result;
@@ -691,13 +686,6 @@ protected:
CalibrationMethod m_calib_method;
int cali_stage;
PrintingTaskType m_current_print_mode = PrintingTaskType::NOT_CLEAR;
bool m_pause_resume_pending = false;
bool m_pause_resume_was_resume = false;
std::chrono::steady_clock::time_point m_pause_resume_deadline;
std::string m_pause_resume_machine_id;
bool m_abort_pending = false;
std::chrono::steady_clock::time_point m_abort_deadline;
std::string m_abort_machine_id;
void init_scaled_buttons();
void create_tasklist_info();
@@ -800,7 +788,6 @@ protected:
void update_calib_bitmap();
void reset_printing_values();
bool is_moonraker_agent() const;
void on_webrequest_state(wxWebRequestEvent &evt);
bool is_task_changed(MachineObject* obj);

View File

@@ -2,9 +2,7 @@
#include "BBLNetworkPlugin.hpp"
#include "NetworkAgentFactory.hpp"
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <nlohmann/json.hpp>
namespace Slic3r {
@@ -22,65 +20,6 @@ void BBLPrinterAgent::set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud)
// Communication
// ============================================================================
std::string BBLPrinterAgent::ams_refresh_rfid_gcode(const std::string& tray_id)
{
return (boost::format("M620 R%1% \n") % tray_id).str();
}
std::string BBLPrinterAgent::ams_calibrate_gcode(int ams_id)
{
return (boost::format("M620 C%1% \n") % ams_id).str();
}
std::string BBLPrinterAgent::ams_select_tray_gcode(const std::string& tray_id)
{
return (boost::format("M620 P%1% \n") % tray_id).str();
}
int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
const std::string gcode = ams_refresh_rfid_gcode(tray_id);
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode;
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
return publish(dev_id, j, lan_mode);
}
int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode)
{
const std::string gcode = ams_calibrate_gcode(ams_id);
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode;
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
return publish(dev_id, j, lan_mode);
}
int BBLPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
const std::string gcode = ams_select_tray_gcode(tray_id);
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode;
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
return publish(dev_id, j, lan_mode);
}
int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode)
{
const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0);
if (rtn == 0) {
BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
} else {
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
}
return rtn;
}
int BBLPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag)
{
auto& plugin = BBLNetworkPlugin::instance();

View File

@@ -5,7 +5,6 @@
#include "ICloudServiceAgent.hpp"
#include <string>
#include <memory>
#include <nlohmann/json.hpp>
namespace Slic3r {
@@ -29,12 +28,6 @@ public:
// Communication
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override;
static std::string ams_refresh_rfid_gcode(const std::string& tray_id);
static std::string ams_calibrate_gcode(int ams_id);
static std::string ams_select_tray_gcode(const std::string& tray_id);
int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override;
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override;
int disconnect_printer() override;
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override;
@@ -92,9 +85,6 @@ public:
FilamentSyncMode get_filament_sync_mode() const override;
private:
// why: the lan/cloud DECISION stays machine-side; keep this mechanical branch in sync with publish_json.
int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode);
std::shared_ptr<ICloudServiceAgent> m_cloud_agent;
};

View File

@@ -2,13 +2,6 @@
#define __I_PRINTER_AGENT_HPP__
#include "bambu_networking.hpp"
// why: these extend the BAMBU_NETWORK_* return space rather than opening a new one - the value
// flows through the same int domain callers already compare against BAMBU_NETWORK_SUCCESS.
// They live here and not in bambu_networking.hpp because that file is a vendor header replaced
// wholesale by header-sync commits (see c09252ce11), which would silently clobber them.
// -70xx is free: the vendor occupies -1..-25 and -10xx through -60xx.
#define ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED -7010 // no translation exists for this command
#define ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE -7020 // a translation exists; this printer lacks the capability
#include <string>
#include <memory>
@@ -84,21 +77,6 @@ public:
*/
virtual int send_message(std::string dev_id, std::string json_str, int qos, int flag) = 0;
// why: gcode is firmware dialect, not a waist concept - commands whose body is Bambu-dialect
// gcode live on the agent that speaks it; the default is an honest refusal that MachineObject's
// publish funnel turns into a dialog.
virtual int command_ams_refresh_rfid(std::string, std::string, int, bool)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
virtual int command_ams_calibrate(std::string, int, int, bool)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
virtual int command_ams_select_tray(std::string, std::string, int, bool)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
// why: some printers emit camera frames only while explicitly asked, and retire the
// capture task on their own - the camera view starts it and renews it. Printers with an
// always-on stream need nothing here, hence the honest refusal by default.
virtual int command_start_camera(std::string)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
/**
* Establish a direct LAN connection to a printer.
*/

File diff suppressed because it is too large Load Diff

View File

@@ -9,16 +9,11 @@
#include <set>
#include <string>
#include <thread>
#include <condition_variable>
#include <deque>
#include <functional>
#include <nlohmann/json.hpp>
namespace Slic3r {
bool moonraker_is_light_name(const std::string& name);
class MoonrakerPrinterAgent : public IPrinterAgent
{
public:
@@ -117,7 +112,7 @@ protected:
// Helpers
bool is_numeric(const std::string& value);
std::string normalize_base_url(std::string host, const std::string& port);
std::string sanitize_filename(const std::string& filename) const;
std::string sanitize_filename(const std::string& filename);
std::string join_url(const std::string& base_url, const std::string& path) const;
// Trim whitespace and convert to uppercase
@@ -126,22 +121,6 @@ protected:
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
static std::string map_filament_type_to_generic_id(const std::string& filament_type);
// Send a G-code script via Moonraker (/printer/gcode/script)
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
bool send_gcode(const std::string& dev_id, const std::string& gcode,
const std::string& base_url, const std::string& api_key) const;
bool post_print_action(const std::string& action) const;
bool post_print_action(const std::string& action,
const std::string& base_url, const std::string& api_key) const;
// Send one JSON-RPC call over a short-lived Moonraker websocket. Returns true when the
// request was written; it never waits for a reply.
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
// why: a printer with no /server/webcams/list entry can still name its stream directly;
// returning empty (the default) keeps the normal Moonraker discovery path.
virtual std::string webcam_stream_override(const std::string& base_url) const { return {}; }
private:
int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id);
@@ -149,7 +128,7 @@ private:
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& objects, std::string& error) const;
bool query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const;
bool fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation);
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
void announce_printhost_device();
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
@@ -158,8 +137,7 @@ private:
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
void handle_ws_message(std::string dev_id, std::string payload, std::string base_url, std::string api_key);
void refresh_thumbnail_url(std::string base_url, std::string api_key);
void handle_ws_message(const std::string& dev_id, const std::string& payload);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -173,10 +151,9 @@ private:
const std::string& base_url, const std::string& api_key,
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
// Start a print of a previously uploaded G-code file (path relative to the
// Moonraker gcodes root).
bool start_print_file(const std::string& base_url, const std::string& api_key,
const std::string& filename, std::string& error_msg) const;
// JSON-RPC helper
bool send_jsonrpc_command(const std::string& base_url, const std::string& api_key,
const nlohmann::json& request, std::string& response) const;
// Connection thread management
void perform_connection_async(const std::string& dev_id,
@@ -212,15 +189,9 @@ private:
mutable std::recursive_mutex payload_mutex;
nlohmann::json status_cache;
// note: guarded by payload_mutex; filled by refresh_thumbnail_url(), empty url = looked up, none found
std::string thumbnail_filename;
std::string thumbnail_url;
std::string webcam_stream_url;
unsigned thumbnail_lookup_attempts = 0;
std::atomic<int> next_jsonrpc_id{1};
std::set<std::string> available_objects; // Track for feature detection
bool assumed_light_on = false;
std::atomic<bool> ws_stop{false};
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
@@ -236,15 +207,7 @@ private:
// Connection thread management
std::atomic<uint64_t> connect_generation{0};
std::thread connect_thread;
mutable std::recursive_mutex connect_mutex;
void enqueue_command(std::function<void()> fn);
void run_command_worker();
std::thread cmd_thread;
std::deque<std::function<void()>> cmd_queue;
std::mutex cmd_mutex;
std::condition_variable cmd_cv;
bool cmd_stop = false;
std::recursive_mutex connect_mutex;
};
} // namespace Slic3r

View File

@@ -767,34 +767,6 @@ int NetworkAgent::send_message(std::string dev_id, std::string json_str, int qos
return -1;
}
int NetworkAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
if (m_printer_agent)
return m_printer_agent->command_ams_refresh_rfid(dev_id, tray_id, sequence_id, lan_mode);
return -1;
}
int NetworkAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode)
{
if (m_printer_agent)
return m_printer_agent->command_ams_calibrate(dev_id, ams_id, sequence_id, lan_mode);
return -1;
}
int NetworkAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
if (m_printer_agent)
return m_printer_agent->command_ams_select_tray(dev_id, tray_id, sequence_id, lan_mode);
return -1;
}
int NetworkAgent::command_start_camera(std::string dev_id)
{
if (m_printer_agent)
return m_printer_agent->command_start_camera(dev_id);
return -1;
}
int NetworkAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl)
{
if (m_printer_agent)

View File

@@ -142,10 +142,6 @@ public:
int set_on_local_message_fn(OnMessageFn fn);
int set_server_callback(OnServerErrFn fn);
int send_message(std::string dev_id, std::string json_str, int qos, int flag);
int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode);
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode);
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode);
int command_start_camera(std::string dev_id);
int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl);
int disconnect_printer();
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag);

View File

@@ -16,10 +16,8 @@
#include <mutex>
#include <utility>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/I18N.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <wx/msgdlg.h>
namespace Slic3r {
namespace {
@@ -316,21 +314,6 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
std::shared_ptr<IPrinterAgent> cached_agent;
auto reject_conflicting_capability = [plugin_key, capability_name](const std::string& error_message) {
if (!wxTheApp || GUI::wxGetApp().is_closing())
return;
GUI::wxGetApp().CallAfter([plugin_key, capability_name, error_message]() {
if (GUI::wxGetApp().is_closing())
return;
PluginManager& manager = PluginManager::instance();
manager.set_plugin_error(plugin_key, error_message);
// note: the unload callback triggered by disabling will call deregister,
// which will be a no-op since the printer agent is never registered
manager.set_capability_enabled({PluginCapabilityType::PrinterConnection, capability_name, plugin_key}, false);
wxMessageBox(wxString::FromUTF8(error_message.c_str()), _L("Plugins"), wxOK | wxICON_WARNING, GUI::wxGetApp().GetTopWindow());
});
};
{
std::lock_guard<std::mutex> lock(s_registry_mutex);
@@ -349,10 +332,9 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
auto& python_agent_ids = get_python_printer_agent_ids();
for (const auto& pair : python_agent_ids) {
if (pair.first != capability_key && pair.second == info.id) {
const std::string error_message = "Printer-agent '" + info.name + "' could not be enabled: agent ID '" + info.id +
"' is already registered by capability '" + pair.first.second + "' from plugin '" + pair.first.first + "'.";
BOOST_LOG_TRIVIAL(warning) << error_message;
reject_conflicting_capability(error_message);
BOOST_LOG_TRIVIAL(warning) << "Printer-agent plugin '" << capability_name << "' uses duplicate agent ID '" << info.id
<< "' already registered by capability '" << pair.first.second << "' from plugin '"
<< pair.first.first << "'";
return;
}
}
@@ -372,22 +354,12 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
auto& agents = get_printer_agents();
auto agent_it = agents.find(info.id);
// why: reject only when the ID is owned by SOMEONE ELSE - a built-in has an empty
// plugin_identifier, another plugin/capability has a different plugin_full_ref. When it
// IS the same plugin_full_ref, this capability is just re-registering itself, so fall
// through and refresh.
if (agent_it != agents.end() && agent_it->second.plugin_identifier != plugin_full_ref) {
const std::string error_message = "Printer-agent '" + info.name + "' could not be enabled: agent ID '" + info.id +
"' is already registered by '" + agent_it->second.display_name + "'.";
BOOST_LOG_TRIVIAL(warning) << error_message;
reject_conflicting_capability(error_message);
BOOST_LOG_TRIVIAL(warning) << "Printer-agent plugin '" << capability_name << "' uses agent ID '" << info.id
<< "' already registered by '" << agent_it->second.display_name << "'";
return;
}
// why: insert_or_assign, not emplace - reaching here means the ID is new, or the same
// capability is re-registering (same plugin_full_ref). In the re-register case we WANT to
// overwrite so the factory closure points at the current live capability instance; emplace
// would silently keep the stale entry.
agents.insert_or_assign(info.id, PrinterAgentInfo(info.id, info.name, plugin_full_ref, std::move(factory)));
python_agent_ids[capability_key] = info.id;

View File

@@ -74,81 +74,6 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id)
return true;
}
bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
{
// enable_box mirrors task_use_ams: engage the multi-color box only when this
// job actually routes filament through it. (See qidi-ams-findings.md §2/§8.3 —
// if firmware treats enable_box as "a box exists" rather than "use it this job",
// switch this gate to HasAms()/box_count instead.)
const int enable = params.task_use_ams ? 1 : 0;
if (!send_gcode(device_info.dev_id, "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable))) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set enable_box";
return false;
}
// When the box isn't used this job, leave the existing value_t<tool> slot
// assignments untouched (enable_box=0 is enough to disengage it).
if (!enable)
return true;
if (params.ams_mapping.empty()) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty";
return true;
}
// ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the
// physical box slot (-1 = unmapped). Mirror it onto the printer's value_t<tool>
// variables: SAVE_VARIABLE VARIABLE=value_t<tool> VALUE='slot<n>'.
auto mapping = nlohmann::json::parse(params.ams_mapping, nullptr, /*allow_exceptions*/ false);
if (mapping.is_discarded() || !mapping.is_array()) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: invalid ams_mapping: " << params.ams_mapping;
return false;
}
for (size_t tool = 0; tool < mapping.size(); ++tool) {
if (!mapping[tool].is_number_integer())
continue;
const int slot = mapping[tool].get<int>();
if (slot < 0)
continue; // unmapped filament — skip
const std::string gcode = "SAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) +
" VALUE=\"'slot" + std::to_string(slot) + "'\"";
if (!send_gcode(device_info.dev_id, gcode)) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set value_t" << tool;
return false;
}
}
return true;
}
int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_local_print(std::move(params), update_fn, cancel_fn);
}
int QidiPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_print(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED;
return MoonrakerPrinterAgent::start_local_print_with_record(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_sdcard_print(std::move(params), update_fn, cancel_fn);
}
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
const std::string& api_key,
const QidiFilamentDict& dict,
@@ -193,10 +118,20 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return false;
}
nlohmann::json status;
nlohmann::json variables;
if (!parse_slot_response(response_body, status, variables, error))
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].contains("variables")) {
error = "Unexpected JSON structure";
return false;
}
auto& variables = json["result"]["status"]["save_variables"]["variables"];
auto& status = json["result"]["status"];
box_count = variables.value("box_count", 1);
if (box_count < 0) {
@@ -271,31 +206,6 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return true;
}
bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
std::string& error)
{
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.is_object() || !json.contains("result") || !json["result"].is_object() || !json["result"].contains("status") ||
!json["result"]["status"].is_object() || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].is_object() || !json["result"]["status"]["save_variables"].contains("variables") ||
!json["result"]["status"]["save_variables"]["variables"].is_object()) {
// why: Qidi firmware may send null here, but json::value() throws for it.
error = "Unexpected JSON structure: save_variables.variables must be an object";
return false;
}
status = json["result"]["status"];
variables = status["save_variables"]["variables"];
return true;
}
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url,
const std::string& api_key,
QidiFilamentDict& dict,

View File

@@ -2,7 +2,6 @@
#define __QIDI_PRINTER_AGENT_HPP__
#include "MoonrakerPrinterAgent.hpp"
#include "nlohmann/json_fwd.hpp"
#include <map>
#include <string>
@@ -22,21 +21,7 @@ public:
// Override filament sync (Qidi-specific implementation)
bool fetch_filament_info(std::string dev_id) override;
static bool parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
std::string& error);
// Print operations — emit QiDi multi-color box config, then delegate to base.
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
private:
// Push enable_box + value_t<tool> SAVE_VARIABLEs before a print starts.
// Returns false if any command fails (caller should abort the print).
bool apply_box_mapping(const PrintParams& params) const;
struct QidiFilamentDict
{
std::map<int, std::string> colors;

View File

@@ -1,14 +1,10 @@
#include "SnapmakerPrinterAgent.hpp"
#include "Http.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp"
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#include <thread>
namespace Slic3r {
@@ -71,69 +67,6 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
int SnapmakerPrinterAgent::command_start_camera(std::string dev_id)
{
(void) dev_id;
// why: the printer executes this over the websocket but answers only over MQTT, and the
// call itself blocks on socket I/O - it fires from the camera view's renew timer on the UI
// thread, so run it detached rather than block the caller on a reply that never comes.
// note: interval is dead time in SECONDS on top of a ~0.455 s capture, so 0 is the 2.15 fps
// ceiling (1 measures 0.63 fps), and it cannot be changed while a capture task is running.
std::thread([this] {
send_ws_rpc("camera.start_monitor",
{{"domain", "lan"}, {"interval", 0}, {"expect_pw", false}});
}).detach();
return BAMBU_NETWORK_SUCCESS;
}
std::string SnapmakerPrinterAgent::webcam_stream_override(const std::string& base_url) const
{
const std::string snapshot_url = join_url(base_url, "/server/files/camera/monitor.jpg");
// why: one wrapper file per printer - two U1s would otherwise overwrite each other's URL.
const boost::filesystem::path page = boost::filesystem::path(data_dir()) / "cache" /
("snapmaker_camera_" + sanitize_filename(device_info.dev_ip) + ".html");
// why: the printer writes a still JPEG at ~2 fps, so the page polls it with a cache buster
// instead of consuming a stream. Chaining the next request off onload (never a bare
// setInterval) keeps requests from piling up when the printer is slow to answer.
const std::string html =
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Camera</title><style>"
"html,body{margin:0;height:100%;background:#000;overflow:hidden}"
"img{width:100%;height:100%;object-fit:contain;display:block}</style></head>"
"<body><img id=\"frame\" alt=\"\"><script>\n"
"var src=\"" + snapshot_url + "\";\n"
"var img=document.getElementById(\"frame\");\n"
"function next(){img.src=src+\"?_nocache=\"+Date.now()+\"_\"+Math.floor(Math.random()*10000);}\n"
"img.onload=function(){setTimeout(next,250);};\n"
"img.onerror=function(){setTimeout(next,1000);};\n"
"next();\n"
"</script></body></html>\n";
std::string write_error;
try {
boost::filesystem::create_directories(page.parent_path());
boost::nowide::ofstream out(page.string().c_str(), std::ios::binary | std::ios::trunc);
out << html;
out.close();
// note: an ofstream reports a failed write in its state, not by throwing.
if (!out) {
write_error = "write failed";
}
} catch (const std::exception& e) {
write_error = e.what();
}
if (!write_error.empty()) {
// why: no wrapper means no camera - a raw monitor.jpg URL would render one frozen frame
// and read as a broken feed, so fall back to showing nothing and say why in the log.
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent: could not write camera page " << page.string()
<< ": " << write_error;
return {};
}
return "file://" + page.generic_string();
}
AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
{
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};

View File

@@ -16,10 +16,6 @@ public:
AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id) override;
int command_start_camera(std::string dev_id) override;
protected:
std::string webcam_stream_override(const std::string& base_url) const override;
private:
// Combine filament_type + filament_sub_type into a unified type string

View File

@@ -2,16 +2,12 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_dev_mapping.cpp
test_device_progress.cpp
test_network_versions.cpp
test_action_source.cpp
test_plugin_host_api.cpp
test_plugin_capability_config.cpp
test_plugin_config.cpp
test_plugin_capabilities_in_use.cpp
test_plugin_status.cpp
test_printer_agent.cpp
test_qidi_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_slicing_pipeline_bindings.cpp
@@ -59,22 +55,3 @@ elseif (APPLE)
endif()
orcaslicer_discover_tests(${_TEST_NAME}_tests)
# why: the loader runs on a detached worker thread, so its Python interpreter
# ownership model cannot share the embedded interpreter in the main test binary.
add_executable(printer_agent_plugin_tests test_printer_agent_plugin.cpp)
if (MSVC)
target_link_libraries(printer_agent_plugin_tests Setupapi.lib)
endif ()
target_link_libraries(printer_agent_plugin_tests test_common libslic3r_gui libslic3r pybind11::embed Catch2::Catch2)
set_property(TARGET printer_agent_plugin_tests PROPERTY FOLDER "tests")
# why: the existing target stages the complete bundled Python home under
# python/, which is the layout PythonInterpreter discovers beside the test exe.
add_dependencies(printer_agent_plugin_tests ${_TEST_NAME}_tests)
orcaslicer_copy_test_dlls()
orcaslicer_discover_tests(printer_agent_plugin_tests)

View File

@@ -1,141 +0,0 @@
// why: match the GUI include order to avoid rpcndr.h byte/std::byte
// ambiguity in the Windows COM headers.
// why: wx/timer.h must precede DeviceManager.hpp because
// DeviceErrorDialog.hpp uses wxTimerEvent.
#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 <stdexcept>
#include <wx/timer.h>
#include "slic3r/GUI/DeviceManager.hpp"
#include <nlohmann/json.hpp>
using json = nlohmann::json;
using namespace Slic3r;
TEST_CASE("Integer progress reaches the shared subtask", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(37));
REQUIRE(machine.mc_print_percent == 37);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
CHECK(subtask->task_progress == 37);
}
TEST_CASE("String progress reaches the shared subtask", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json("41"));
REQUIRE(machine.mc_print_percent == 41);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
CHECK(subtask->task_progress == 41);
}
TEST_CASE("Floating-point progress preserves the previous shared value", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(29));
REQUIRE(machine.mc_print_percent == 29);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
REQUIRE(subtask->task_progress == 29);
machine.update_print_progress(json(29.5));
BBLSubTask* current_subtask = machine.get_subtask();
REQUIRE(current_subtask != nullptr);
REQUIRE(current_subtask == subtask);
CHECK(machine.mc_print_percent == 29);
CHECK(current_subtask->task_progress == 29);
}
TEST_CASE("Unsupported progress values leave a fresh machine unchanged", "[DeviceManager][Progress]")
{
SECTION("boolean") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json(true));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("null") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json(nullptr));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("object") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json::object());
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("array") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json::array());
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
}
TEST_CASE("Malformed string progress leaves a fresh machine unchanged", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
CHECK_THROWS_AS(machine.update_print_progress(json("not-a-percent")), std::invalid_argument);
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
TEST_CASE("Zero progress replaces active shared progress", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(63));
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
REQUIRE(machine.mc_print_percent == 63);
REQUIRE(subtask->task_progress == 63);
machine.set_print_state("FAILED");
machine.update_print_progress(json(0));
BBLSubTask* current_subtask = machine.get_subtask();
REQUIRE(current_subtask != nullptr);
REQUIRE(current_subtask == subtask);
REQUIRE(machine.mc_print_percent == 0);
CHECK(current_subtask->task_progress == 0);
}

View File

@@ -1,23 +0,0 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/PluginStatus.hpp>
using Slic3r::GUI::PluginStatus;
using Slic3r::GUI::resolve_plugin_status;
TEST_CASE("resolve_plugin_status precedence", "[plugin][status]") {
// the new branch: loaded module + error -> runtime fault, not a load failure.
REQUIRE(resolve_plugin_status(/*loading*/ false, /*has_error*/ true, /*is_loaded*/ true) == PluginStatus::RuntimeError);
// error without a live module is a load-time Error.
REQUIRE(resolve_plugin_status(false, true, false) == PluginStatus::Error);
// loading wins over a pending error so a reload never flashes red.
REQUIRE(resolve_plugin_status(true, true, true) == PluginStatus::Loading);
// healthy loaded plugin.
REQUIRE(resolve_plugin_status(false, false, true) == PluginStatus::Activated);
// nothing loaded, no error.
REQUIRE(resolve_plugin_status(false, false, false) == PluginStatus::Inactive);
}

View File

@@ -1,237 +0,0 @@
#include <catch2/catch_all.hpp>
#include <slic3r/Utils/BBLPrinterAgent.hpp>
#include <slic3r/Utils/MoonrakerPrinterAgent.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <memory>
#include <string>
using namespace Slic3r;
namespace py = pybind11;
// why: these builders preserve the Bambu firmware dialect byte-for-byte, including its trailing space.
TEST_CASE("unit: BBL AMS gcode builders preserve command bytes", "[unit][bbl]")
{
CHECK(BBLPrinterAgent::ams_refresh_rfid_gcode("123") == "M620 R123 \n");
CHECK(BBLPrinterAgent::ams_calibrate_gcode(123) == "M620 C123 \n");
CHECK(BBLPrinterAgent::ams_select_tray_gcode("123") == "M620 P123 \n");
}
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.command_ams_refresh_rfid("dev", "123", 1, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]")
{
CHECK(moonraker_is_light_name("caselight"));
CHECK(moonraker_is_light_name("LED_STRIP"));
CHECK_FALSE(moonraker_is_light_name("beeper"));
CHECK(moonraker_is_light_name("FLASHLIGHT_SWITCH"));
CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH"));
}
// ===========================================================================
// UNIT - handle_request's not-supported default.
// The agent is the only thing that knows what it can translate, so an untranslated
// command has to say so instead of returning success and letting the UI believe the
// control worked. Guards the inverse too: the pushing namespace is genuinely
// satisfied by the websocket status stream, and it re-fires from the keepalive timer
// roughly once a second, so it must stay a success or it would raise a dialog on a
// timer. Only branches that touch neither the network nor wx are exercised.
// ===========================================================================
TEST_CASE("unit: Moonraker reports untranslated commands as not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.send_message("dev", R"({"print":{"command":"ams_change_filament"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"system":{"command":"set_door_stat"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"xcam":{"command":"xcam_control_set"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"pushall"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"start"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
// why: malformed input is a different failure than an untranslated command, and the
// default must not swallow it into a misleading not-supported verdict.
CHECK(agent.send_message("dev", "{not json", 0, 0) == BAMBU_NETWORK_ERR_INVALID_RESULT);
}
// ===========================================================================
// UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
// or previously registered agent.
// ===========================================================================
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]")
{
// why: the registry is process-global state shared by the test binary, and
// Catch2 may run cases in any order. Use an id that cannot collide with
// built-ins or other cases. Avoid SECTIONs because each section re-runs the
// body and would register the same id twice.
const std::string id = "orca-test::registry-probe-7f3a";
auto stub_factory = [](std::shared_ptr<ICloudServiceAgent>, const std::string&)
-> std::shared_ptr<IPrinterAgent> { return nullptr; };
REQUIRE_FALSE(NetworkAgentFactory::is_printer_agent_registered(id));
REQUIRE(NetworkAgentFactory::register_printer_agent(id, "Registry Probe", stub_factory));
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(id));
// Re-registering the same id is rejected and does not replace the entry.
REQUIRE_FALSE(NetworkAgentFactory::register_printer_agent(id, "Impostor", stub_factory));
// The first registration's display name survives the rejected duplicate.
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(id);
REQUIRE(info != nullptr);
CHECK(info->display_name == "Registry Probe");
// It appears exactly once in the UI-population list.
auto agents = NetworkAgentFactory::get_registered_printer_agents();
int count = 0;
for (const auto& a : agents)
if (a.id == id)
++count;
CHECK(count == 1);
}
// ===========================================================================
// INTEGRATION - the orca.printer_agent Python binding surface.
// Boots the embedded interpreter and asserts the C++ to Python contract that
// every printer-agent plugin subclasses. If a binding is renamed or removed,
// plugins fail at runtime even though C++ still compiles.
// ===========================================================================
namespace {
void ensure_python_initialized()
{
// why: the `orca` module is embedded in this binary, so a bare interpreter
// can import it without a bundled Python home. The app interpreter expects
// that deployed layout, which is not present beside this test binary.
if (!Py_IsInitialized()) {
static py::scoped_interpreter interpreter;
(void) interpreter;
}
}
py::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration (incl. printer_agent) exists.
(void) Slic3r::PythonPluginBridge::instance();
return py::module_::import("orca");
}
} // namespace
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]")
{
py::module_ orca = import_orca_module();
REQUIRE(py::hasattr(orca, "printer_agent"));
py::object pa = orca.attr("printer_agent");
// The base class every printer-agent plugin subclasses.
REQUIRE(py::hasattr(pa, "PrinterAgentBase"));
py::object base = pa.attr("PrinterAgentBase");
for (const char* method : { "get_agent_info", "connect_printer", "disconnect_printer",
"send_message", "start_discovery", "bind_detect",
"start_print", "get_filament_sync_mode" }) {
CAPTURE(method);
CHECK(py::hasattr(base, method));
}
// AgentInfo value type - the registry identity the host reads (id is the key).
REQUIRE(py::hasattr(pa, "AgentInfo"));
py::object info = pa.attr("AgentInfo")("moonraker", "Moonraker", "1.0", "test agent");
CHECK(info.attr("id").cast<std::string>() == "moonraker");
CHECK(info.attr("name").cast<std::string>() == "Moonraker");
// FilamentSyncMode enum the host queries to pick pull vs subscription.
REQUIRE(py::hasattr(pa, "FilamentSyncMode"));
py::object mode = pa.attr("FilamentSyncMode");
CHECK(py::hasattr(mode, "Pull"));
CHECK(py::hasattr(mode, "Subscription"));
CHECK(py::hasattr(mode, "None_"));
// Plugin-type enum exposed at module root (host reads it without the GIL).
CHECK(py::hasattr(orca, "PluginType"));
}
// ===========================================================================
// INTEGRATION - plugin-registration API and discovery-context guards.
// These are the symbols every plugin package uses: the @orca.plugin decorator,
// orca.base, orca.register_capability, and the capability base modules. Checking
// them in the lightweight embedded-interpreter test catches binding breakage
// before the plugin-loader test needs to run.
// ===========================================================================
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
{
py::module_ orca = import_orca_module();
// Module-level surface every plugin package relies on.
// note: no "gcode" module here - this branch has no G-code capability module;
// PostProcessing exists only as a PluginType value.
for (const char* name : { "plugin", "register_capability", "base", "PythonPluginBase",
"PluginType", "PluginResult", "script", "printer_agent", "host" }) {
CAPTURE(name);
CHECK(py::hasattr(orca, name));
}
// Plugin package base and capability base contract.
CHECK(py::hasattr(orca.attr("base"), "register_capabilities"));
py::object cap_base = orca.attr("PythonPluginBase");
for (const char* method : { "get_name", "get_type", "on_load", "on_unload" }) {
CAPTURE(method);
CHECK(py::hasattr(cap_base, method));
}
// The script capability module exposes its own base class.
CHECK(py::hasattr(orca.attr("script"), "ScriptPluginCapabilityBase"));
// PluginType enum carries the values that route a capability, including PrinterConnection.
// note: no PostProcessing value on this branch's binding.
py::object types = orca.attr("PluginType");
for (const char* value : { "PrinterConnection", "Script" }) {
CAPTURE(value);
CHECK(py::hasattr(types, value));
}
// note: this is testing behavior, not normal plugin loading.
// These APIs should only work while Orca is actively loading a plugin.
try {
// Calls Python's orca.register_capability(0) from C++.
// 0 is intentionally bogus. The important part is that there is no active
// plugin load context, so the function should reject the call immediately.
orca.attr("register_capability")(py::int_(0));
// If the call above does NOT throw, the test fails here.
FAIL("register_capability outside discovery context must raise");
} catch (const py::error_already_set& error) {
// pybind11 wraps Python exceptions as py::error_already_set.
// This checks the Python exception type is ValueError.
CHECK(error.matches(PyExc_ValueError));
}
try {
// This is the function behind @orca.plugin.
// Same logic as above.
orca.attr("plugin")(py::int_(0));
FAIL("@orca.plugin outside discovery context must raise");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}

View File

@@ -1,414 +0,0 @@
#include <catch2/catch_all.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <libslic3r/Utils.hpp> // for set_data_dir
#include <boost/filesystem.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_session.hpp>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <string>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
// why: embedding the fake plugin keeps this test self-contained. The PEP 723
// block declares a printer-connection plugin, and the decorated plugin package
// registers one PrinterAgentBase capability whose AgentInfo.id is the registry
// key asserted below.
constexpr const char* kFakePluginSource = R"PY(# /// script
# requires-python = ">=3.12"
# dependencies = []
#
# [tool.orcaslicer.plugin]
# name = "Lifecycle Test Agent"
# description = "Minimal printer-agent plugin for the lifecycle test."
# author = "tests"
# version = "1.0.0"
# type = "printer-connection"
# ///
import orca
class LifecycleTestAgentCapability(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "Lifecycle Test Agent"
def get_agent_info(self):
return orca.printer_agent.AgentInfo(
id="lifecycle-test-agent",
name="Lifecycle Test Agent",
version="1.0.0",
description="Lifecycle test printer agent",
)
@orca.plugin
class LifecycleTestPlugin(orca.base):
def register_capabilities(self):
orca.register_capability(LifecycleTestAgentCapability)
)PY";
// why: in production GUI_App::init_plugin_gui_wiring subscribes the agent-registry
// callbacks; the test binary has no GUI, so install the same UNLOAD-side wiring
// once so the tests exercise the production deregister-on-unload path.
// note: the load-side (register) wiring is deliberately NOT installed - the two
// concurrent load_plugin calls in the duplicate-id test would race for the id;
// the tests register manually, in a deterministic order, instead.
void install_agent_registry_wiring()
{
static bool installed = false;
if (installed)
return;
installed = true;
PluginManager& mgr = PluginManager::instance();
mgr.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin);
mgr.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
if (capability.type == PluginCapabilityType::PrinterConnection)
NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name);
});
}
} // namespace
// ===========================================================================
// PRINTER-AGENT PLUGIN LIFECYCLE: load, register, unload, deregister.
//
// This test uses its own executable because load_plugin runs on a detached worker
// thread that needs the GIL released on the main thread (the PythonInterpreter
// model). slic3rutils_tests' other Python tests hold the GIL on the main thread
// via a bare scoped_interpreter; the two models can't share one process.
//
// When bundled Python is unavailable in the test environment, the test is
// skipped so source-only or partially staged builds can still run the rest of
// the suite.
// ===========================================================================
TEST_CASE("plugin lifecycle: printer-agent load registers and unload deregisters", "[plugin][lifecycle][Python]")
{
const std::string plugin_key = "LifecycleTestAgent"; // entry-file stem
const std::string agent_id = "lifecycle-test-agent"; // AgentInfo.id from the plugin
// Stage a throwaway data directory. Plugins are discovered under
// <data_dir>/orca_plugins, so this controls which plugin is loaded.
const fs::path data_dir = fs::temp_directory_path() / "orca-plugin-lifecycle-test";
const fs::path plugin_dir = data_dir / "orca_plugins" / plugin_key;
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec); // clear any stale run
}
fs::create_directories(plugin_dir);
{
std::ofstream out((plugin_dir / (plugin_key + ".py")).string(), std::ios::binary);
out << kFakePluginSource;
}
// why: best-effort cleanup even if an assertion throws.
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
// Initialize the plugin system on this thread. If the bundled Python home
// is not reachable, skip gracefully.
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
// Discover synchronously so the catalog holds the descriptor before loading.
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
INFO("expected plugin_key (entry-file stem): " << plugin_key);
PluginDescriptor descriptor;
REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor));
// Load on the worker thread and block until it finishes.
std::string error;
mgr.load_plugin(plugin_key, /*skip_deps=*/true);
const bool loaded = mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error);
INFO("plugin load error: " << error);
REQUIRE(loaded);
REQUIRE(mgr.is_plugin_loaded(plugin_key));
// Resolve the one PrinterConnection capability and register it as an agent.
auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(caps.size() == 1);
REQUIRE(caps[0] != nullptr);
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
// The AgentInfo.id returned by the plugin is now in the registry.
CHECK(NetworkAgentFactory::is_printer_agent_registered(agent_id));
// Unloading the plugin deregisters its Python-backed agent.
REQUIRE(mgr.unload_plugin(plugin_key));
// The registry no longer contains the agent id after unload.
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(agent_id));
}
namespace {
// why: duplicate-id coverage needs two distinct plugins with different package
// keys and classes while both return the same AgentInfo.id.
std::string make_agent_plugin_source(const std::string& suffix, const std::string& display_name, const std::string& agent_id)
{
return std::string{}
+ "# /// script\n"
+ "# requires-python = \">=3.12\"\n"
+ "# dependencies = []\n"
+ "#\n"
+ "# [tool.orcaslicer.plugin]\n"
+ "# name = \"" + display_name + "\"\n"
+ "# description = \"Duplicate-id fake printer-agent plugin.\"\n"
+ "# author = \"tests\"\n"
+ "# version = \"1.0.0\"\n"
+ "# type = \"printer-connection\"\n"
+ "# ///\n"
+ "import orca\n"
+ "\n\n"
+ "class Cap" + suffix + "(orca.printer_agent.PrinterAgentBase):\n"
+ " def get_name(self):\n"
+ " return \"" + display_name + "\"\n"
+ "\n"
+ " def get_agent_info(self):\n"
+ " return orca.printer_agent.AgentInfo(\n"
+ " id=\"" + agent_id + "\", name=\"" + display_name + "\",\n"
+ " version=\"1.0.0\", description=\"duplicate id test\")\n"
+ "\n\n"
+ "@orca.plugin\n"
+ "class Plugin" + suffix + "(orca.base):\n"
+ " def register_capabilities(self):\n"
+ " orca.register_capability(Cap" + suffix + ")\n";
}
void stage_plugin(const fs::path& data_dir, const std::string& plugin_key, const std::string& source)
{
const fs::path dir = data_dir / "orca_plugins" / plugin_key;
fs::create_directories(dir);
std::ofstream out((dir / (plugin_key + ".py")).string(), std::ios::binary);
out << source;
}
} // namespace
// ===========================================================================
// DUPLICATE PRINTER-AGENT IDS
// When two loaded plugins return the same AgentInfo.id, the first registration
// keeps ownership. Unloading it removes the id, and the rejected plugin is not
// promoted automatically. Manual re-registration is required.
// ===========================================================================
TEST_CASE("duplicate agent id is rejected and the winner is not clobbered", "[plugin][lifecycle][Python]")
{
const std::string key_a = "DuplicateIdAgentA"; // winner, registered first
const std::string key_b = "DuplicateIdAgentB"; // duplicate, rejected
const std::string dup_id = "duplicate-id-agent"; // both plugins return this AgentInfo.id
const fs::path data_dir = fs::temp_directory_path() / "orca-duplicate-agent-test";
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec);
}
stage_plugin(data_dir, key_a, make_agent_plugin_source("A", "Duplicate Id Agent A", dup_id));
stage_plugin(data_dir, key_b, make_agent_plugin_source("B", "Duplicate Id Agent B", dup_id));
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor_a;
PluginDescriptor descriptor_b;
REQUIRE(mgr.try_get_valid_plugin_descriptor(key_a, descriptor_a));
REQUIRE(mgr.try_get_valid_plugin_descriptor(key_b, descriptor_b));
std::string error;
mgr.load_plugin(key_a, /*skip_deps=*/true);
mgr.load_plugin(key_b, /*skip_deps=*/true);
const bool loaded_a = mgr.wait_for_plugin_load(key_a, std::chrono::seconds(60), error);
INFO("plugin A load error: " << error);
REQUIRE(loaded_a);
const bool loaded_b = mgr.wait_for_plugin_load(key_b, std::chrono::seconds(60), error);
INFO("plugin B load error: " << error);
REQUIRE(loaded_b);
auto caps_a = mgr.get_plugin_capabilities(key_a, PluginCapabilityType::PrinterConnection);
auto caps_b = mgr.get_plugin_capabilities(key_b, PluginCapabilityType::PrinterConnection);
REQUIRE(caps_a.size() == 1);
REQUIRE(caps_b.size() == 1);
// Register A first as the owner, then B with the duplicate id.
NetworkAgentFactory::register_python_printer_agent(key_a, caps_a[0]->name());
NetworkAgentFactory::register_python_printer_agent(key_b, caps_b[0]->name());
// The shared id is registered, and still owned by A; B did not replace it.
CHECK(NetworkAgentFactory::is_printer_agent_registered(dup_id));
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(dup_id);
REQUIRE(info != nullptr);
CHECK(info->plugin_identifier.find(key_a) != std::string::npos); // owned by A
CHECK(info->plugin_identifier.find(key_b) == std::string::npos); // B never took ownership
// Unload the owner. The id is not promoted to the still loaded duplicate.
REQUIRE(mgr.unload_plugin(key_a));
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(dup_id));
mgr.unload_plugin(key_b); // the duplicate was loaded but never registered
}
// ===========================================================================
// NATIVE (BUILT-IN) AGENT ID COLLISION
// A plugin may not hijack a built-in agent id (e.g. "bbl"). The built-in keeps
// ownership and the plugin's agent is rejected.
// ===========================================================================
TEST_CASE("printer-agent plugin cannot claim a built-in agent id", "[plugin][lifecycle][Python]")
{
// Register the native built-ins so "bbl"/"orca" occupy the registry.
NetworkAgentFactory::register_all_agents();
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(BBL_PRINTER_AGENT_ID));
const std::string plugin_key = "BuiltinClashAgent";
const fs::path data_dir = fs::temp_directory_path() / "orca-builtin-clash-test";
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec);
}
stage_plugin(data_dir, plugin_key, make_agent_plugin_source("Clash", "Builtin Clash", BBL_PRINTER_AGENT_ID));
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor));
std::string error;
mgr.load_plugin(plugin_key, /*skip_deps=*/true);
REQUIRE(mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error));
auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(caps.size() == 1);
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
// "bbl" is still the native built-in, not the plugin.
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(BBL_PRINTER_AGENT_ID);
REQUIRE(info != nullptr);
CHECK_FALSE(info->is_plugin());
CHECK(info->plugin_identifier.find(plugin_key) == std::string::npos);
mgr.unload_plugin(plugin_key);
}
// ===========================================================================
// RE-REGISTERING THE SAME CAPABILITY IS A REFRESH, NOT A DUPLICATE
// The guard rejects only a DIFFERENT owner. The same capability registering its
// own id again must stay registered (insert_or_assign refreshes it in place).
// ===========================================================================
TEST_CASE("re-registering the same capability keeps its agent registered", "[plugin][lifecycle][Python]")
{
const std::string plugin_key = "ReRegisterAgent";
const std::string agent_id = "re-register-agent";
const fs::path data_dir = fs::temp_directory_path() / "orca-re-register-test";
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec);
}
stage_plugin(data_dir, plugin_key, make_agent_plugin_source("Re", "Re Register", agent_id));
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor));
std::string error;
mgr.load_plugin(plugin_key, /*skip_deps=*/true);
REQUIRE(mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error));
auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(caps.size() == 1);
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(agent_id));
const PrinterAgentInfo* first = NetworkAgentFactory::get_printer_agent_info(agent_id);
REQUIRE(first != nullptr);
const std::string owner = first->plugin_identifier;
// The same capability registering again is a refresh: still registered, same owner, not rejected.
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
CHECK(NetworkAgentFactory::is_printer_agent_registered(agent_id));
const PrinterAgentInfo* second = NetworkAgentFactory::get_printer_agent_info(agent_id);
REQUIRE(second != nullptr);
CHECK(second->plugin_identifier == owner);
mgr.unload_plugin(plugin_key);
}
// why: this binary embeds CPython through the PythonInterpreter singleton, which
// lives for the full process. Normal static destruction can tear Python down
// while C++ objects still hold Python handles, producing a Windows heap
// corruption after the assertions have finished. The app has an ordered shutdown
// path; this test harness does not, so it returns the Catch2 result through
// _Exit after flushing output.
int main(int argc, char* argv[])
{
const int result = Catch::Session().run(argc, argv);
std::cout.flush();
std::cerr.flush();
std::fflush(nullptr);
std::_Exit(result);
}

View File

@@ -1,131 +0,0 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include <slic3r/Utils/QidiPrinterAgent.hpp>
#include <string>
using namespace Slic3r;
TEST_CASE("Qidi slot response rejects null variables without throwing", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": null
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("variables"));
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("object"));
}
TEST_CASE("Qidi slot response rejects missing and non-object fields without throwing", "[QidiPrinterAgent]")
{
std::string response;
SECTION("missing result")
{
response = R"({})";
}
SECTION("non-object result")
{
response = R"({"result":null})";
}
SECTION("missing status")
{
response = R"({"result":{}})";
}
SECTION("non-object status")
{
response = R"({"result":{"status":null}})";
}
SECTION("missing save_variables")
{
response = R"({"result":{"status":{}}})";
}
SECTION("non-object save_variables")
{
response = R"({"result":{"status":{"save_variables":null}}})";
}
SECTION("missing variables")
{
response = R"({"result":{"status":{"save_variables":{}}}})";
}
SECTION("scalar")
{
response = R"({"result":{"status":{"save_variables":{"variables":42}}}})";
}
SECTION("array")
{
response = R"({"result":{"status":{"save_variables":{"variables":[]}}}})";
}
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
}
TEST_CASE("Qidi slot response exposes valid status and variables", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": {
"box_count": 2,
"color_slot0": 3
}
},
"box_stepper slot0": {
"runout_button": 0
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = false;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
REQUIRE(parsed);
CHECK(status.is_object());
CHECK(variables.is_object());
CHECK(variables.at("box_count") == 2);
CHECK(status.contains("box_stepper slot0"));
}
TEST_CASE("Qidi slot response rejects invalid JSON", "[QidiPrinterAgent]")
{
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response("{not json", status, variables, error));
CHECK_FALSE(parsed);
CHECK(error == "Invalid JSON response");
}