diff --git a/docs/printer-agent/README.md b/docs/printer-agent/README.md deleted file mode 100644 index b36cfb2b4e..0000000000 --- a/docs/printer-agent/README.md +++ /dev/null @@ -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. diff --git a/docs/printer-agent/agents.md b/docs/printer-agent/agents.md deleted file mode 100644 index f1197dcde5..0000000000 --- a/docs/printer-agent/agents.md +++ /dev/null @@ -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` 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` diff --git a/docs/printer-agent/architecture.md b/docs/printer-agent/architecture.md deleted file mode 100644 index 776806ddbc..0000000000 --- a/docs/printer-agent/architecture.md +++ /dev/null @@ -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
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. diff --git a/docs/printer-agent/camera.md b/docs/printer-agent/camera.md deleted file mode 100644 index 7a4e57cd0a..0000000000 --- a/docs/printer-agent/camera.md +++ /dev/null @@ -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
LAN or cloud, not from IPrinterAgent"] - MU["Moonraker webcam discovery
/server/webcams/list stream URL"] - SU["Snapmaker camera page
local HTML that polls monitor.jpg"] - - BV["wxMediaCtrl2
native Bambu media pipeline"] - WV["Device-panel wxWebView
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. diff --git a/docs/printer-agent/connection-and-status.md b/docs/printer-agent/connection-and-status.md deleted file mode 100644 index 125cc1e02f..0000000000 --- a/docs/printer-agent/connection-and-status.md +++ /dev/null @@ -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. diff --git a/docs/printer-agent/filament.md b/docs/printer-agent/filament.md deleted file mode 100644 index 5fbfe78905..0000000000 --- a/docs/printer-agent/filament.md +++ /dev/null @@ -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
Moonraker lane_data, or the classic MMU object"] - ACQ["1 - Acquire state
Moonraker pull, not subscription"] - ST["DevFilaSystem
Bambu-shaped view, synthetic 4-slot groups"] - SEL["2 - Select mapping
Send dialog matches project filament to slots"] - PAR["PrintParams
ams_mapping fields"] - QIDI["Qidi agent writes enable_box
and value_t per tool, then starts the print"] - BASE["Base Moonraker, Happy Hare, AFC
upload and start ignore the mapping fields"] - OK["Mapping reaches the printer"] - DEAD["Mapping never reaches the printer
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. diff --git a/docs/printer-agent/plugin-agents.md b/docs/printer-agent/plugin-agents.md deleted file mode 100644 index 68f03a00dd..0000000000 --- a/docs/printer-agent/plugin-agents.md +++ /dev/null @@ -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 { - <> - string id - string name - string version - string description - } - class PrinterAgentInfo { - <> - string id - string display_name - string plugin_identifier - PrinterAgentFactory factory - } - class PrinterAgentFactory { - <> - takes cloud_agent and log_dir - returns shared_ptr~IPrinterAgent~ - } - class NetworkAgentFactory { - <> - 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 -`;;` 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` diff --git a/docs/printer-agent/printing.md b/docs/printer-agent/printing.md deleted file mode 100644 index f8efe9e5be..0000000000 --- a/docs/printer-agent/printing.md +++ /dev/null @@ -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 -
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
the printer prepares - a timeout is not
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.
Never reuse a machine pointer captured
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. diff --git a/docs/printer-agent/reference/capability-matrix.md b/docs/printer-agent/reference/capability-matrix.md deleted file mode 100644 index 296730dd98..0000000000 --- a/docs/printer-agent/reference/capability-matrix.md +++ /dev/null @@ -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. diff --git a/docs/printer-agent/reference/device-tab-annotations.html b/docs/printer-agent/reference/device-tab-annotations.html deleted file mode 100644 index e023d378b6..0000000000 --- a/docs/printer-agent/reference/device-tab-annotations.html +++ /dev/null @@ -1,971 +0,0 @@ - - - - - -Moonraker Device-tab annotations - - - - - -
- -
- Moonraker printer agents · annotated reference -

Selected Device-tab and Plater controls on Moonraker

-

The markers connect visible controls to their implementation and limitations. This page is a - visual companion to the capability matrix, not a substitute for - live-printer verification.

-
- -
-

Read the verdicts as operating conditions

-

Supported has relevant live-printer evidence. Partial has an important - limitation. Unsupported has no safe built-in implementation. Not verified - describes a source-inspected path without equivalent live evidence. A visible control can still look available - when its underlying command is unsupported.

-
- -
-

Why a control can appear to succeed

-

Much of the Device tab was designed around Bambu protocol commands. Moonraker accepts generic G-code and a - smaller set of print actions, but it has no general equivalent for proprietary AMS, camera, or structured - control commands. Treat a closed dialog or a changed button state as UI feedback, not proof that the printer - received a safe command.

-
- -
-

Keep protocol boundaries explicit

-

Do not make proprietary Bambu commands appear available by widening capability checks. In particular, the - legacy Moonraker paths for the part fan and extrusion rely on is_enable_np staying - false. Adding cfg, fun, aux, or stat changes - routing to unsupported structured commands. The visible result can be a silent loss of a previously working - control.

-
- -
- Supported or Local only - ! Partial - Unsupported - ? Not verified - Hover or focus a marker to highlight its note, and vice versa. The eye at each - screenshot's top right hides the markers so you can see the bare UI. -
- - -
-
- Panel 01 · Device tab -

AMS / filament

-

Moonraker can pull installed material and slots from a compatible MMU source. It does not provide - a generic, verified contract for changing slots, loading material, or delivering a selected mapping to a - print.

-
-
-
-
- OrcaSlicer Device tab AMS panel with four spools, an external spool slot, and Auto Refill, Unload and Load buttons - - - - - - - - -
-
-
    -
  1. - 1 - Spool data (A1-A4, Ext) Partial -

    The built-in agent uses pull-mode sync. It can populate the view from Happy Hare or AFC data, - but refresh depends on the Plater sidebar sync action. Base Moonraker does not deliver a selected material - mapping when a multi-material print starts.

    -

    See get_filament_sync_mode() and fetch_filament_info() in - MoonrakerPrinterAgent.

    -
  2. -
  3. - 2 - Slot refresh arrow (A1-A4) Unsupported -

    The control relies on a Bambu-specific AMS opcode. Generic Moonraker has no verified - replacement. Do not widen the structured AMS path merely to expose it: that also changes filament-state - handling and can turn an unavailable action into a blocking error.

    -
  4. -
  5. - 3 - Slot pencil, filament setting Unsupported -

    The dialog is available, but generic Moonraker has no built-in write-side material or colour - contract. Printer-specific macros must be known and verified before adding one.

    -
  6. -
  7. - 4 - External spool pencil Unsupported -

    This is the external-spool variant of the same unsupported write-side material path.

    -
  8. -
  9. - 5 - Auto Refill Unsupported -

    The dialog can accurately report that the feature is unavailable, but generic Moonraker has - no built-in auto-refill contract.

    -
  10. -
  11. - 6 - AMS settings icon Unsupported -

    The settings use Bambu-specific print and AMS options. The built-in Moonraker family does not - map them to generic printer controls.

    -
  12. -
  13. - 7 - Unload Unsupported -

    A generic unload macro is unsafe to assume. The required printer-side contract is not part of - base Moonraker.

    -
  14. -
  15. - 8 - Load Unsupported -

    This uses the same unsupported generic write-side material path. Qidi has agent-specific - behavior, but that does not make it a Base Moonraker capability.

    -
  16. -
-
-
- - -
-
- Panel 02 · Device tab -

Extruder feed and retract

-

Manual extrusion uses a legacy raw-G-code path while the structured capability flag remains - disabled. It is a useful compatibility path, not a general proof that manual motion is safe.

-
-
-
-
- Extruder control widget with a retract button above and a feed button below the extruder graphic - - -
-
-
    -
  1. - 1 - Retract (up) Partial -

    The legacy path sends raw G-code through Moonraker and can move the extruder. Klipper still - applies its own extrusion-temperature and safety limits.

    -
  2. -
  3. - 2 - Feed (down) Partial -

    The same legacy path handles feed. It remains conditional on is_enable_np being - false. Adding cfg, fun, aux, or stat changes - routing to unsupported structured extrusion commands.

    -
  4. -
-
-
- - -
-
- Panel 03 · Device tab -

Fan and chamber light

-

The legacy part-fan route is available only while structured capabilities stay disabled. A chamber - light also depends on a recognised light object in the printer configuration.

-
-
-
-
- Fan button above a 100 percent fan speed readout and a Lamp toggle - - - -
-
-
    -
  1. - 1 - Fan button, speed popup Partial -

    The Base and Qidi legacy path sends M106 through gcode_line, so part-fan - speed control works while is_enable_np is false. It is not a structured fan API: - adding capability fields routes the control to unsupported structured commands.

    -
  2. -
  3. - 2 - Fan speed readout (100%) Not verified -

    The value is sourced from Moonraker fan status, with fan.power as a fallback. - The displayed value still needs equivalent live-printer evidence across supported configurations.

    -
  4. -
  5. - 3 - Lamp Partial -

    A configured chamber light can be controlled when the agent recognises a suitable light object. - It is not a generic guarantee for every output_pin or LED configuration.

    -
  6. -
-
-
- - -
-
- Panel 04 · Device tab -

Camera

-

Moonraker live view can work when webcam discovery yields a usable stream. Recording, timelapse, - resolution, and Bambu-specific camera controls do not have a generic Moonraker implementation.

-
-
-
-
- Camera panel showing a black video area, a Local Mqtt signal readout, a Lan toggle, three icons and a green play button - - - - - - - -
-
-
    -
  1. - 1 - Live view Not verified -

    The path is source-inspected and configuration-dependent: webcam discovery must provide a usable - stream with a URL the local media pipeline accepts. Equivalent live-printer evidence is not recorded.

    -
  2. -
  3. - 2 - Play Unsupported -

    The Bambu local-camera transport is not a Moonraker webcam control.

    -
  4. -
  5. - 3 - "Local Mqtt -92/-91" Unsupported -

    This diagnostic readout is not sourced from Moonraker device status.

    -
  6. -
  7. - 4 - Lan toggle Unsupported -

    This tunnel subscription control has no Moonraker override.

    -
  8. -
  9. - 5 - Recording Unsupported -

    Moonraker webcam discovery does not define a recording-control contract.

    -
  10. -
  11. - 6 - Timelapse Unsupported -

    Timelapse control is printer-specific and is not provided by the Base Moonraker agent.

    -
  12. -
  13. - 7 - Camera settings, resolution Unsupported -

    The Base Moonraker agent does not expose a generic stream-resolution setting.

    -
  14. -
-
-
- - -
-
- Panel 05 · Device tab -

Printing progress

-

The print display is built from Moonraker status and file metadata. The paths shown here are - source-inspected unless the capability matrix records relevant live-printer evidence.

-
-
-
-
- Printing progress panel with a placeholder thumbnail, N/A task name and state, a progress bar, Layer N/A, a 13:00 time and pause and stop buttons - - - - - - - - -
-
-
    -
  1. - 1 - Task thumbnail Not verified -

    The agent requests a thumbnail for the uploaded G-code and exposes its URL through - slice_info->thumbnail_url. The request must stay off the WebSocket update path and be cached: - a blocking lookup while that path holds shared state can delay status updates and the UI.

    -

    /server/files/thumbnails → - slice_info->thumbnail_urlStatusPanel thumbnail handling. - The response shape is unverified - if the image 404s, check the path prefix first.

    -
  2. -
  3. - 2 - Task name Not verified -

    Real value: subtask_name is filled from print_stats.filename. It reads - N/A here only because the printer is idle; it populates once a print starts.

    -

    StatusPanel reads subtask_name.

    -
  4. -
  5. - 3 - Print state Not verified -

    gcode_state is mapped from print_stats.state, with a parallel - mc_print_stage for printing / paused / complete / error.

    -

    map_moonraker_state() in build_print_payload_locked()

    -
  6. -
  7. - 4 - Progress bar Not verified -

    mc_percent comes from virtual_sdcard.progress, clamped to 0-100.

    -

    StatusPanel reads task_progress.

    -
  8. -
  9. - 5 - Layer Not verified -

    Layer data comes from print_stats.info.current_layer and - .total_layer. It is conditional on G-code that calls SET_PRINT_STATS_INFO; where - a printer profile omits those calls, the field stays blank.

    -

    StatusPanel reads curr_layer and - total_layers from the status payload.

    -
  10. -
  11. - 6 - Remaining time Not verified -

    Remaining time is estimated from virtual_sdcard.progress. It is not a - slicer-accurate ETA, and pause or heating behavior can make the estimate differ from the printer's actual - completion time.

    -

    mc_remaining_time is derived in - build_print_payload_locked().

    -
  12. -
  13. - 7 - Pause Not verified -

    POST /printer/print/pause hits Klipper's - pause_resume webhook as a direct interrupt, instead of queuing a raw PAUSE behind - the G-code queue where it could stall behind a long move or a macro.

    -

    StatusPanelDeviceManager → - post_print_action("pause") in MoonrakerPrinterAgent.

    -
  14. -
  15. - 8 - Stop Not verified -

    POST /printer/print/cancel is used. Note that stop is also the - discriminator emitted by HMS fault-stop, so the agent cannot distinguish a user cancel from a fault - abort.

    -

    StatusPanelDeviceManager → - post_print_action("cancel").

    -
  16. -
-
-
- - -
-
- Panel 06 · Plater sidebar -

Filament list

-

Most controls in this sidebar edit the local project. The AMS sync button is the relevant - Moonraker integration point and uses pull mode.

-
-
-
-
- Plater sidebar filament section with a Flushing volumes button, four icon buttons and seven filament rows - - - - - - -
-
-
    -
  1. - 1 - Flushing volumes Local only -

    Opens the flushing-volume dialog. Never touches the printer agent, so the agent cannot break - it.

    -

    Plateropen_flushing_dialog().

    -
  2. -
  3. - 2 - Remove last filament Local only -

    Local project edit only.

    -

    Platerdelete_filament().

    -
  4. -
  5. - 3 - Add one filament Local only -

    Local project edit only.

    -

    Plateradd_filament().

    -
  6. -
  7. - 4 - Sync filament list from AMS Partial -

    The built-in Moonraker family uses pull-mode sync to read installed material and slots. It can - populate the local list when Happy Hare or AFC data is available, but generic Moonraker does not deliver a - selected mapping to a print. Qidi mapping is agent-specific.

    -

    Platersync_ams_list() → - load_ams_list()build_filament_ams_list() → - MoonrakerPrinterAgent::fetch_filament_info(). The button is visible when the sync mode is not - FilamentSyncMode::none.

    -
  8. -
  9. - 5 - Set filaments to use Local only -

    Opens ConfigWizard. Local only.

    -

    Plater opens ConfigWizard.

    -
  10. -
  11. - 6 - Filament rows and "..." menus Local only -

    Pure preset selection - colour swatch, preset combo, and the Edit / Merge / Delete menu. No agent - call on any of them.

    -

    Plater::init_filament_combo().

    -
  12. -
-
-
- -
-

How to use this page. Each marker connects a visible control to a current capability verdict. - Check the capability matrix for the agent-family scope and evidence grade, - then use the testing guide before relying on a configuration-dependent result.

-

Safety boundary. Manual extrusion and legacy part-fan control depend on - is_enable_np remaining false. Adding cfg, fun, - aux, or stat redirects these controls to unsupported structured commands. Jog is - Partial because it can leave relative positioning active; do not use it as a general safe-control test until - the G-code state is restored.

-

Deliberate exclusions. Bambu-specific AMS write operations, RFID, calibration, and camera - controls remain Unsupported for the Base Moonraker agent because no generic, verified Klipper contract exists. - Printer-specific support belongs in the relevant agent, not in a guessed Base command.

-
- -
- - - - diff --git a/docs/printer-agent/reference/manual-checklist.html b/docs/printer-agent/reference/manual-checklist.html deleted file mode 100644 index f6fcd95731..0000000000 --- a/docs/printer-agent/reference/manual-checklist.html +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - Printer-agent manual checklist - - - -

Printer-agent manual checklist

-

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.

- -
- - - - -
- -
- 0 of 0 cases marked -
- - -
- - - -

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.

- -
-

Connect and observe status

-
-

Connect to the selected printer

-

Expected: the Device tab receives a fresh status update after connection. Do not use a - successful connection return alone as the result.

-
-

Observe live status changes

-

Expected: temperature and target changes, fan state, print state, filename, progress, - elapsed time, and homing state reach the UI while the printer changes state.

-
-

Disconnect and reconnect

-

Expected: a second connection produces new status messages and does not create a duplicate - device. Record the post-reconnect status evidence.

-
-

Handle network and response failures

-

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.

-
- -
-

Controls and printing

-
-

Exercise safe controls

-

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.

-
-

Verify queued command order under latency

-

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.

-
-

Send a file without starting it

-

Expected: the file appears on the printer and no print starts.

-
-

Start a small print

-

Expected: upload completes, the printer starts the selected file, and status transitions - to printing.

-
-

Pause, resume, and cancel an active print

-

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.

-
-

Check upload failure handling

-

Expected: cancellation during upload and a missing input fail clearly and do not begin a - partial or unintended print.

-
- -
-

Filament, camera, and agent-specific checks

-
-

Refresh material-system data

-

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.

-
-

Clear material data when filament or the system is absent

-

Expected: remove filament or disable the material system, refresh, and confirm the UI no - longer shows obsolete slots, material names, or colours.

-
-

Verify print-time mapping where supported

-

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.

-
-

Check Qidi Send preflight with missing nozzle identity

-

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 - PrintStatusNozzleDataInvalid. Record any reported identity and any mismatch - result separately; this does not approve a known mismatch.

-
-

Verify a camera feed

-

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-this lifetime risk.

-
-

Verify the print thumbnail

-

Expected: test a reused filename after its thumbnail changes, response payloads with both - thumbnail_path and relative_path, and a path below the G-code - root. The displayed image must match the current file in each case.

-
-

Reload a Python printer-agent plug-in

-

Expected: the capability registers once, duplicate agent IDs are rejected visibly, and - disable or unload removes the agent cleanly.

-
- - - - diff --git a/docs/printer-agent/testing.md b/docs/printer-agent/testing.md deleted file mode 100644 index 057f92811b..0000000000 --- a/docs/printer-agent/testing.md +++ /dev/null @@ -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 -`` 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 --config RelWithDebInfo --target slic3rutils_tests -cmake --build --config RelWithDebInfo --target printer_agent_plugin_tests -ctest --test-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 --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`. diff --git a/resources/orca_plugins/BBLPrinterAgentPlugin.py b/resources/orca_plugins/BBLPrinterAgentPlugin.py deleted file mode 100644 index a7edd8747b..0000000000 --- a/resources/orca_plugins/BBLPrinterAgentPlugin.py +++ /dev/null @@ -1,1243 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = ["paho-mqtt"] -# -# [tool.orcaslicer.plugin] -# name = "BBL Printer Agent" -# description = "Barebones Bambu Lab printer-agent plugin." -# author = "OrcaSlicer" -# version = "0.0.1" -# type = "printer-connection" -# /// -import json -import ftplib -import queue -import select -import socket -import ssl -import sys -import threading -import uuid -from pathlib import Path - -import orca - -from typing import Any, Callable - -try: - import paho.mqtt.client as mqtt -except ModuleNotFoundError: - mqtt = None - - -def _log(msg): - print(f"[BBLPrinterAgentPlugin] {msg}", file=sys.stderr, flush=True) - - -MQTT_CONNECT_TIMEOUT_SECONDS = 10 -MQTT_KEEPALIVE_SECONDS = 60 -MQTT_PORT = 1883 -MQTT_SSL_PORT = 8883 -FTP_SSL_PORT = 990 -SSDP_GROUP = "239.255.255.250" -SSDP_PORTS = (1990, 2021) -SSDP_RECV_SIZE = 8192 -BAMBU_DEFAULT_USERNAME = "bblp" -MQTT_CONNACK_MESSAGES = { - 0: "accepted", - 1: "unacceptable protocol version", - 2: "identifier rejected", - 3: "server unavailable", - 4: "bad username or password", - 5: "not authorized", -} -CONNECT_STATUS_OK = 0 -CONNECT_STATUS_FAILED = 1 -CONNECT_STATUS_LOST = 2 -BAMBU_NETWORK_ERR_INVALID_HANDLE = -1 -BAMBU_NETWORK_ERR_SEND_MSG_FAILED = -4 -BAMBU_NETWORK_ERR_BIND_FAILED = -5 -BAMBU_NETWORK_ERR_UNBIND_FAILED = -6 -BAMBU_NETWORK_ERR_FILE_NOT_EXIST = -14 -BAMBU_NETWORK_ERR_CANCELED = -18 -BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED = -2130 -BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED = -4020 -BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED = -4030 -BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED = -5010 -PRINTING_STAGE_CREATE = 0 -PRINTING_STAGE_UPLOAD = 1 -PRINTING_STAGE_SENDING = 3 -PRINTING_STAGE_FINISHED = 6 -PRINTING_STAGE_ERROR = 7 -INITIAL_PUSH_PAYLOAD = { - "pushing": {"command": "pushall"}, - "info": {"command": "get_version"}, - "upgrade": {"command": "get_history"}, -} - - -class _ImplicitFTP_TLS(ftplib.FTP_TLS): - def __init__(self, *args, unwrap: bool = False, **kwargs): - super().__init__(*args, **kwargs) - self._sock = None - self.unwrap = unwrap - - def ntransfercmd(self, cmd, rest=None): - conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) - if self._prot_p: - conn = self.context.wrap_socket(conn, server_hostname=self.host, session=self.sock.session) - return conn, size - - @property - def sock(self): - return self._sock - - @sock.setter - def sock(self, value): - if value is not None and not isinstance(value, ssl.SSLSocket): - value = self.context.wrap_socket(value) - self._sock = value - - def storbinary(self, cmd, fp, blocksize=8192, callback=None, rest=None): - self.voidcmd("TYPE I") - conn = self.transfercmd(cmd, rest) - try: - while True: - buf = fp.read(blocksize) - if not buf: - break - conn.sendall(buf) - if callback: - callback(buf) - if isinstance(conn, ssl.SSLSocket) and self.unwrap: - conn.unwrap() - finally: - conn.close() - - # Bambu's FTPS server may leave the control channel without the usual - # final 226 response after the data socket has been closed. At this - # point transfercmd/sendall has already succeeded, so accept the known - # non-standard completion forms instead of reporting a false upload - # failure to the UI. - old_timeout = self.sock.gettimeout() if self.sock else None - try: - if self.sock: - self.sock.settimeout(2) - return self.voidresp() - except TimeoutError: - _log(f"FTP upload completed for {cmd}; timed out waiting for final response") - return "226 Transfer complete" - except ftplib.error_reply as exc: - if str(exc).startswith("200"): - _log(f"FTP upload completed for {cmd}; accepted non-standard response: {exc}") - return str(exc) - raise - finally: - if self.sock and old_timeout is not None: - self.sock.settimeout(old_timeout) - - -class BBLPrinterAgentPlugin(orca.printer_agent.PrinterAgentBase): - def on_load(self): - self.on_server_err_fn: Callable[..., None] | None = None - self.on_printer_connected_fn: Callable[..., None] | None = None - self.on_subscribe_failure_fn: Callable[..., None] | None = None - self.on_message_fn: Callable[..., None] | None = None - self.on_user_message_fn: Callable[..., None] | None = None - self.on_local_connect_fn: Callable[..., None] | None = None - self.on_local_message_fn: Callable[..., None] | None = None - self.on_ssdp_msg_fn: Callable[..., None] | None = None - self.queue_on_main_fn: Callable[..., None] | None = None - self._selected_machine = "" - self._mqtt_lock = threading.RLock() - self._mqtt_client = None - self._mqtt_connected = threading.Event() - self._mqtt_dev_id = "" - self._mqtt_host = "" - self._mqtt_username = BAMBU_DEFAULT_USERNAME - self._mqtt_password = "" - self._mqtt_command_topic = "" - self._mqtt_manual_disconnect = False - self._printer_data: dict[str, Any] = {} - self._last_error = "" - self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE - self._sequence_id = 10000000 - self._mqtt_command_queue = queue.Queue() - self._mqtt_command_worker_stop = threading.Event() - self._mqtt_command_worker = threading.Thread(target=self._mqtt_command_loop, daemon=True) - self._mqtt_command_worker.start() - self._ssdp_stop = threading.Event() - self._ssdp_thread = None - self._ssdp_sockets = [] - self._ssdp_seen = {} - - def get_name(self): - return "BBL Printer Agent" - - def set_server_callback(self, on_server_err_fn): - self.on_server_err_fn = on_server_err_fn - return 0 - - def set_on_printer_connected_fn(self, on_printer_connected_fn): - self.on_printer_connected_fn = on_printer_connected_fn - return 0 - - def set_on_subscribe_failure_fn(self, on_subscribe_failure_fn): - self.on_subscribe_failure_fn = on_subscribe_failure_fn - return 0 - - def set_on_message_fn(self, on_message_fn): - self.on_message_fn = on_message_fn - return 0 - - def set_on_user_message_fn(self, on_user_message_fn): - self.on_user_message_fn = on_user_message_fn - return 0 - - def set_on_local_connect_fn(self, on_connect_fn): - self.on_local_connect_fn = on_connect_fn - return 0 - - def set_on_local_message_fn(self, on_message_fn): - self.on_local_message_fn = on_message_fn - return 0 - - def set_on_ssdp_msg_fn(self, on_ssdp_msg_fn): - self.on_ssdp_msg_fn = on_ssdp_msg_fn - return 0 - - def set_queue_on_main_fn(self, queue_on_main_fn): - self.queue_on_main_fn = queue_on_main_fn - return 0 - - def connect_printer(self, dev_id, dev_ip, username, password, use_ssl) -> int: - self._ensure_state() - dev_id = str(dev_id or "").strip() - dev_ip = str(dev_ip or "").strip() - username = str(username or BAMBU_DEFAULT_USERNAME).strip() - password = str(password or "").strip() - _log( - "connect_printer requested " - f"dev_id={dev_id or ''} host={dev_ip or ''} " - f"username={username or BAMBU_DEFAULT_USERNAME} ssl={bool(use_ssl)} " - f"password_length={len(password)}" - ) - - if not dev_ip: - return self._connection_failed(dev_id, "Missing printer IP address") - if mqtt is None: - return self._connection_failed(dev_id, "paho-mqtt is required for MQTT printer connections") - if not dev_id: - return self._connection_failed(dev_id, "Missing printer serial/device id") - if not password: - return self._connection_failed(dev_id, "Missing printer access code/password") - - port = MQTT_SSL_PORT if use_ssl else MQTT_PORT - connected = threading.Event() - connect_result = {"rc": None} - client = self._create_mqtt_client(dev_id) - command_topic = f"device/{dev_id}/request" - report_topic = f"device/{dev_id}/report" - - def on_connect(client, userdata, flags, rc, *extra): - connect_result["rc"] = self._mqtt_result_code(rc) - try: - _log( - f"MQTT on_connect dev_id={dev_id} rc={connect_result['rc']} " - f"message={MQTT_CONNACK_MESSAGES.get(connect_result['rc'], 'unknown')}" - ) - if connect_result["rc"] == 0: - self._mqtt_connected.set() - client.subscribe(report_topic) - client.publish(command_topic, json.dumps(INITIAL_PUSH_PAYLOAD)) - _log(f"MQTT subscribed dev_id={dev_id} topic={report_topic}; requested initial push") - finally: - connected.set() - - def on_disconnect(client, userdata, *args): - reason = self._disconnect_reason(args) - with self._mqtt_lock: - is_current_client = client is self._mqtt_client - if is_current_client: - self._mqtt_connected.clear() - current_dev_id = self._mqtt_dev_id - manual_disconnect = self._mqtt_manual_disconnect - else: - current_dev_id = "" - manual_disconnect = True - _log(f"MQTT on_disconnect dev_id={current_dev_id or dev_id} reason={reason} manual={manual_disconnect}") - if self.on_local_connect_fn and current_dev_id and not manual_disconnect: - self.on_local_connect_fn(CONNECT_STATUS_LOST, current_dev_id, f"MQTT disconnected: {reason}") - - def on_message(client, userdata, message): - with self._mqtt_lock: - if client is not self._mqtt_client: - return - payload = message.payload.decode("utf-8", errors="replace") - self._handle_message_payload(payload) - if self.on_local_message_fn: - self.on_local_message_fn(dev_id, payload) - - def on_log(client, userdata, level, buf): - text = str(buf) - if any(token in text.lower() for token in ("connect", "ssl", "tls", "fail", "error", "refused")): - _log(f"MQTT paho log dev_id={dev_id} level={level}: {text}") - - client.on_connect = on_connect - client.on_disconnect = on_disconnect - client.on_message = on_message - client.on_log = on_log - - username = username or BAMBU_DEFAULT_USERNAME - client.username_pw_set(username, password) - if use_ssl: - client.tls_set(tls_version=ssl.PROTOCOL_TLS, cert_reqs=ssl.CERT_NONE) - client.tls_insecure_set(True) - - with self._mqtt_lock: - old_client = self._detach_mqtt_locked() - if old_client is not None: - _log("connect_printer replacing existing MQTT client") - self._stop_mqtt_client(old_client) - - with self._mqtt_lock: - self._mqtt_client = client - self._mqtt_dev_id = dev_id - self._mqtt_host = dev_ip - self._mqtt_username = username - self._mqtt_password = password - self._mqtt_command_topic = command_topic - self._mqtt_manual_disconnect = False - self._printer_data = {} - self._last_error = "" - self._selected_machine = dev_id - - try: - _log(f"MQTT connecting dev_id={dev_id} host={dev_ip} port={port}") - client.connect_async(dev_ip, port, keepalive=MQTT_KEEPALIVE_SECONDS) - client.loop_start() - _log(f"MQTT loop started dev_id={dev_id}; waiting up to {MQTT_CONNECT_TIMEOUT_SECONDS}s for CONNACK") - except Exception as exc: - client_to_stop = None - with self._mqtt_lock: - if self._mqtt_client is client: - client_to_stop = self._detach_mqtt_locked() - self._stop_mqtt_client(client_to_stop) - return self._connection_failed(dev_id, str(exc)) - - if not connected.wait(MQTT_CONNECT_TIMEOUT_SECONDS): - client_to_stop = None - with self._mqtt_lock: - if self._mqtt_client is client: - client_to_stop = self._detach_mqtt_locked() - self._stop_mqtt_client(client_to_stop) - return self._connection_failed(dev_id, f"Timed out connecting to MQTT broker at {dev_ip}:{port}") - - with self._mqtt_lock: - is_current_client = client is self._mqtt_client - if not is_current_client: - return self._connection_failed(dev_id, "MQTT client was replaced before connection completed") - - rc = connect_result.get("rc") - if rc == 0: - if self.on_local_connect_fn: - self.on_local_connect_fn(CONNECT_STATUS_OK, dev_id, "0") - if self.on_printer_connected_fn: - self.on_printer_connected_fn(report_topic) - _log(f"connect_printer succeeded dev_id={dev_id} host={dev_ip} port={port}") - return 0 - - reason = MQTT_CONNACK_MESSAGES.get(rc, "unknown") - message = ( - f"MQTT broker rejected connection with code {rc} ({reason}); " - f"check LAN access code, username={username}, ssl={bool(use_ssl)}" - ) - client_to_stop = None - with self._mqtt_lock: - if self._mqtt_client is client: - client_to_stop = self._detach_mqtt_locked() - self._stop_mqtt_client(client_to_stop) - return self._connection_failed(dev_id, message, callback_message=str(rc)) - - def disconnect_printer(self) -> int: - self._ensure_state() - with self._mqtt_lock: - client = self._detach_mqtt_locked() - self._stop_mqtt_client(client) - return 0 - - def start_discovery(self, start=True, sending=False) -> bool: - self._ensure_state() - if start: - if self._ssdp_thread and self._ssdp_thread.is_alive(): - _log("SSDP discovery already running") - return True - self._ssdp_stop.clear() - self._ssdp_seen = {} - _log(f"Starting SSDP discovery sending={bool(sending)} ports={SSDP_PORTS}") - self._ssdp_thread = threading.Thread( - target=self._ssdp_loop, - args=(bool(sending),), - daemon=True, - ) - self._ssdp_thread.start() - return True - - if not self._ssdp_sockets and not (self._ssdp_thread and self._ssdp_thread.is_alive()): - return True - - _log("Stopping SSDP discovery") - self._ssdp_stop.set() - for sock in list(self._ssdp_sockets): - try: - sock.close() - except OSError: - pass - self._ssdp_sockets = [] - return True - - def install_device_cert(self, dev_id, lan_only=True): - return - - def get_agent_info(self): - return orca.printer_agent.AgentInfo( - "pybbl", - "Bambu Lab", - "0.0.1", - "Barebones Bambu Lab printer-agent plugin", - ) - - def get_user_selected_machine(self): - self._ensure_state() - return self._selected_machine - - def set_user_selected_machine(self, dev_id): - self._ensure_state() - self._selected_machine = str(dev_id or "") - return 0 - - def send_message(self, dev_id, json_str, qos=0, flag=0): - return self.send_message_to_printer(dev_id, json_str, qos, flag) - - def send_message_to_printer(self, dev_id, json_str, qos=0, flag=0): - self._ensure_state() - del dev_id, flag - if self._publish_payload(json_str, qos=max(0, min(2, self._int_or_default(qos, 0)))): - return 0 - self._last_code = BAMBU_NETWORK_ERR_SEND_MSG_FAILED - return self._last_code - - def check_cert(self): - return 0 - - def ping_bind(self, ping_code): - self._last_error = "Printer binding is not supported by this plugin" - self._last_code = BAMBU_NETWORK_ERR_BIND_FAILED - return self._last_code - - def bind_detect(self, dev_ip, sec_link, detect): - detect.result_msg = "Printer binding is not supported by this plugin" - detect.command = "bind_detect" - detect.dev_id = "" - detect.model_id = "" - detect.dev_name = "" - detect.version = "" - detect.bind_state = "unsupported" - detect.connect_type = "lan" - self._last_error = detect.result_msg - self._last_code = BAMBU_NETWORK_ERR_BIND_FAILED - return self._last_code - - def bind(self, dev_ip, dev_id, sec_link, timezone, improved, update_fn): - del dev_ip, dev_id, sec_link, timezone, improved - self._last_error = "Printer binding is not supported by this plugin" - self._last_code = BAMBU_NETWORK_ERR_BIND_FAILED - self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error) - return self._last_code - - def unbind(self, dev_id): - del dev_id - self._last_error = "Printer unbinding is not supported by this plugin" - self._last_code = BAMBU_NETWORK_ERR_UNBIND_FAILED - return self._last_code - - def request_bind_ticket(self): - return (BAMBU_NETWORK_ERR_BIND_FAILED, "") - - def start_print(self, params=None, update_fn=None, cancel_fn=None, wait_fn=None): - del wait_fn - return self._start_local_print(params, BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED, update_fn, cancel_fn) - - def start_local_print(self, params=None, update_fn=None, cancel_fn=None): - return self._start_local_print(params, BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED, update_fn, cancel_fn) - - def start_local_print_with_record(self, params=None, update_fn=None, cancel_fn=None, wait_fn=None): - del wait_fn - return self._start_local_print(params, BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED, update_fn, cancel_fn) - - def start_sdcard_print(self, params=None, update_fn=None, cancel_fn=None): - self._ensure_state() - del update_fn - if self._cancel_requested(cancel_fn): - self._last_error = "Cancelled" - self._last_code = BAMBU_NETWORK_ERR_CANCELED - return self._last_code - if params is None: - self._last_error = "Missing print params" - self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE - return self._last_code - try: - payload = self._build_sdcard_project_file_payload(params) - except Exception as exc: - self._last_error = str(exc) - self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE - return self._last_code - if self._queue_payload(payload): - return 0 - self._last_code = BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED - return self._last_code - - def start_send_gcode_to_sdcard(self, params=None, update_fn=None, cancel_fn=None, wait_fn=None): - self._ensure_state() - del wait_fn - if params is None: - self._last_error = "Missing print params" - self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE - return self._last_code - if self._upload_print_file(params, update_fn, cancel_fn): - return 0 - return self._last_code or BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED - - def get_filament_sync_mode(self): - return orca.printer_agent.FilamentSyncMode.Subscription - - def fetch_filament_info(self, dev_id): - del dev_id - return self._publish_payload({"pushing": {"command": "pushall"}}, require_connected=False) - - def send_gcode(self, gcode, sequence_id=None): - self._ensure_state() - gcode = str(gcode or "").strip() - if not gcode: - self._last_error = "Missing G-code command" - self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE - return self._last_code - payload = { - "print": { - "command": "gcode_line", - "param": gcode, - "sequence_id": str(sequence_id or self._next_sequence_id()), - } - } - if self._queue_payload(payload): - return 0 - self._last_code = BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED - return self._last_code - - def send_command(self, request_json): - return self._send_command_impl(request_json, None, None) - - def send_command_with_progress(self, request_json, update_fn, cancel_fn): - return self._send_command_impl(request_json, update_fn, cancel_fn) - - def _send_command_impl(self, request_json, update_fn=None, cancel_fn=None): - try: - request = json.loads(request_json) - except json.JSONDecodeError: - return json.dumps({ - "status": "error", - "message": "Invalid printer-agent request", - }) - - command = request.get("command") - payload = request.get("payload") or {} - if not isinstance(payload, dict): - return json.dumps({ - "status": "error", - "message": "Invalid printer-agent payload", - }) - - if command == "connect_printer": - dev_id = request.get("dev_id") or payload.get("dev_id") or "" - result = self.connect_printer( - dev_id, - payload.get("dev_ip") or "", - payload.get("username") or "", - payload.get("password") or "", - self._as_bool(payload.get("use_ssl")), - ) - if result == 0: - return json.dumps({"status": "ok"}) - return json.dumps({"status": "error", "message": self._last_error or "MQTT connection failed"}) - - if command == "disconnect_printer": - self.disconnect_printer() - return json.dumps({"status": "ok"}) - - if command == "get_filament_info": - self._queue_payload({"pushing": {"command": "pushall"}}, require_connected=False) - return json.dumps(self._build_filament_info()) - - if command in ("send_message", "send_message_to_printer"): - outbound = payload.get("message") if "message" in payload else payload - qos = self._int_or_default(payload.get("qos"), 0) - qos = max(0, min(2, qos)) - return self._status_response(self._queue_payload(outbound, qos=qos)) - - if command == "start_local_print_with_record": - return self._status_response( - self._start_local_print(payload, BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED, update_fn, cancel_fn) - ) - - if command == "start_local_print": - return self._status_response( - self._start_local_print(payload, BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED, update_fn, cancel_fn) - ) - - if command == "start_send_gcode_to_sdcard": - result = 0 if self._upload_print_file(payload, update_fn, cancel_fn) else self._last_code - if result is None: - result = BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED - return self._status_response(result) - - if command == "start_sdcard_print": - return self._status_response(self.start_sdcard_print(payload)) - - if command == "send_gcode": - gcode = payload.get("gcode") or payload.get("param") or payload.get("message") - return self._status_response(self.send_gcode(gcode, payload.get("sequence_id"))) - - if self._looks_like_bambu_payload(request): - return self._status_response(self._queue_payload(request, qos=0)) - - return json.dumps({ - "status": "error", - "message": f"Unsupported printer-agent command: {command or ''}", - }) - - def on_unload(self): - self.start_discovery(False, False) - self._mqtt_command_worker_stop.set() - self.disconnect_printer() - if self._mqtt_command_worker and self._mqtt_command_worker.is_alive(): - self._mqtt_command_worker.join(timeout=1.0) - self.on_server_err_fn: Callable[..., None] | None = None - self.on_printer_connected_fn: Callable[..., None] | None = None - self.on_subscribe_failure_fn: Callable[..., None] | None = None - self.on_message_fn: Callable[..., None] | None = None - self.on_user_message_fn: Callable[..., None] | None = None - self.on_local_connect_fn: Callable[..., None] | None = None - self.on_local_message_fn: Callable[..., None] | None = None - self.on_ssdp_msg_fn: Callable[..., None] | None = None - self.queue_on_main_fn: Callable[..., None] | None = None - - def _ensure_state(self): - if hasattr(self, "_mqtt_lock"): - return - self.on_load() - - def _create_mqtt_client(self, dev_id): - client_id = f"OrcaSlicer-{uuid.uuid4().hex[:12]}" - _log(f"MQTT creating client dev_id={dev_id or ''} client_id={client_id}") - kwargs = { - "client_id": client_id, - "clean_session": True, - "protocol": mqtt.MQTTv311, - } - callback_api_version = getattr(mqtt, "CallbackAPIVersion", None) - if callback_api_version is not None: - kwargs["callback_api_version"] = callback_api_version.VERSION1 - return mqtt.Client(**kwargs) - - def _detach_mqtt_locked(self): - client = self._mqtt_client - self._mqtt_manual_disconnect = True - self._mqtt_client = None - self._mqtt_dev_id = "" - self._mqtt_host = "" - self._mqtt_command_topic = "" - self._mqtt_connected.clear() - return client - - def _stop_mqtt_client(self, client): - if client is None: - return - try: - client.disconnect() - except Exception: - pass - try: - client.loop_stop() - except Exception: - pass - - def _stop_mqtt_client_async(self, client): - if client is None: - return - threading.Thread(target=self._stop_mqtt_client, args=(client,), daemon=True).start() - - def _ssdp_loop(self, sending=False): - sockets = [] - try: - for port in SSDP_PORTS: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if hasattr(socket, "SO_REUSEPORT"): - try: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) - except OSError: - pass - sock.bind(("", port)) - membership = socket.inet_aton(SSDP_GROUP) + socket.inet_aton("0.0.0.0") - sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership) - sockets.append(sock) - _log(f"SSDP listening on {SSDP_GROUP}:{port}") - except OSError as exc: - self._last_error = f"SSDP listen failed on port {port}: {exc}" - _log(self._last_error) - - self._ssdp_sockets = sockets - if not sockets: - _log("SSDP discovery has no listening sockets") - return - - if sending: - _log("Sending SSDP M-SEARCH") - for port in SSDP_PORTS: - request = ( - "M-SEARCH * HTTP/1.1\r\n" - f"HOST: {SSDP_GROUP}:{port}\r\n" - 'MAN: "ssdp:discover"\r\n' - "MX: 1\r\n" - "ST: ssdp:all\r\n" - "\r\n" - ).encode("utf-8") - for sock in sockets: - try: - sock.sendto(request, (SSDP_GROUP, port)) - except OSError as exc: - _log(f"SSDP M-SEARCH send failed port={port}: {exc}") - - while not self._ssdp_stop.is_set(): - try: - readable, _, _ = select.select(sockets, [], [], 1.0) - except OSError as exc: - _log(f"SSDP select failed: {exc}") - break - - for sock in readable: - try: - packet, addr = sock.recvfrom(SSDP_RECV_SIZE) - except OSError as exc: - _log(f"SSDP recv failed: {exc}") - continue - - text = packet.decode("utf-8", errors="replace") - headers = {} - for line in text.replace("\r\n", "\n").split("\n"): - if ":" not in line: - continue - key, value = line.split(":", 1) - key = "".join(ch for ch in key.lower() if ch.isalnum()) - value = value.strip() - headers[key] = value - for suffix in ("bambucom", "bambulabcom"): - if key.endswith(suffix): - headers.setdefault(key[:-len(suffix)], value) - - def header(*names, default=""): - for name in names: - key = "".join(ch for ch in name.lower() if ch.isalnum()) - value = headers.get(key) - if value: - return value - return default - - dev_id = header("dev_id", "devid", "serial", "serial_number", "usn") - if dev_id.startswith("uuid:"): - dev_id = dev_id[5:] - if "::" in dev_id: - dev_id = dev_id.split("::", 1)[0] - if not dev_id: - continue - - dev_name = header("dev_name", "devname", "friendly_name", "name", default=dev_id) - device = { - "dev_name": dev_name, - "dev_id": dev_id, - "dev_ip": addr[0], - "dev_type": header("dev_type", "dev_model", "devmodel", "model", "printer_type", default=""), - "dev_signal": header("dev_signal", "devsignal", "signal", default="0"), - "connect_type": header("connect_type", "connection_type", "dev_connect", "devconnect", default="lan"), - "bind_state": header("bind_state", "dev_bind", "devbind", "bind", default="free"), - "sec_link": header("sec_link", "dev_sec_link", "devseclink", "secure_link", default=""), - "ssdp_version": header("ssdp_version", "dev_version", "devversion", "version", default=""), - "connection_name": header("connection_name", default=dev_name), - } - seen_key = device["dev_id"] - seen_value = ( - device["dev_ip"], - device["dev_name"], - device["dev_type"], - device["connect_type"], - device["bind_state"], - device["sec_link"], - device["ssdp_version"], - ) - if self._ssdp_seen.get(seen_key) != seen_value: - self._ssdp_seen[seen_key] = seen_value - _log(f"SSDP discovered printer {json.dumps(device, sort_keys=True)}") - if self.on_ssdp_msg_fn: - self.on_ssdp_msg_fn(json.dumps(device)) - finally: - for sock in sockets: - try: - sock.close() - except OSError: - pass - self._ssdp_sockets = [] - _log("SSDP discovery loop exited") - - def _connection_failed(self, dev_id, message, callback_message=None) -> int: - self._last_error = message - _log(f"connect_printer failed dev_id={dev_id or ''}: {message}") - if self.on_local_connect_fn: - self.on_local_connect_fn(CONNECT_STATUS_FAILED, dev_id, callback_message or message) - return -1 - - def _queue_payload(self, payload, qos=1, require_connected=True) -> bool: - try: - self._mqtt_command_queue.put_nowait((payload, qos, require_connected)) - return True - except Exception as exc: - self._last_error = str(exc) - return False - - def _mqtt_command_loop(self): - while not self._mqtt_command_worker_stop.is_set(): - try: - payload, qos, require_connected = self._mqtt_command_queue.get(timeout=0.25) - except queue.Empty: - continue - - try: - if not self._publish_payload(payload, qos=qos, require_connected=require_connected): - _log(f"MQTT queued command publish failed: {self._last_error}") - finally: - self._mqtt_command_queue.task_done() - - def _publish_payload(self, payload, qos=1, require_connected=True) -> bool: - if isinstance(payload, str): - try: - payload = json.loads(payload) - except json.JSONDecodeError: - pass - - with self._mqtt_lock: - client = self._mqtt_client - topic = self._mqtt_command_topic - connected = self._mqtt_connected.is_set() - - if client is None or not topic: - self._last_error = "MQTT client is not connected" - return False - if require_connected and not connected: - self._last_error = "MQTT client is not connected" - return False - - try: - message = payload if isinstance(payload, str) else json.dumps(payload) - info = client.publish(topic, message, qos=qos) - if getattr(info, "rc", 0) != 0: - self._last_error = f"MQTT publish failed with code {info.rc}" - return False - return True - except Exception as exc: - self._last_error = str(exc) - return False - - def _handle_message_payload(self, payload: str): - try: - doc = json.loads(payload) - except json.JSONDecodeError: - return - if not isinstance(doc, dict): - return - with self._mqtt_lock: - self._merge_printer_data(doc) - - def _merge_printer_data(self, doc: dict[str, Any]): - for key, value in doc.items(): - if isinstance(value, dict) and isinstance(self._printer_data.get(key), dict): - self._printer_data[key].update(value) - else: - self._printer_data[key] = value - - def _build_filament_info(self) -> dict[str, Any]: - with self._mqtt_lock: - data = json.loads(json.dumps(self._printer_data)) - - print_data = data.get("print", {}) - if not isinstance(print_data, dict): - return {"units": [], "external": []} - - return { - "units": self._build_ams_units(print_data.get("ams", {})), - "external": self._build_external_filaments(print_data.get("vt_tray")), - } - - def _build_ams_units(self, ams_data) -> list[dict[str, Any]]: - if not isinstance(ams_data, dict): - return [] - units = [] - for unit_index, unit in enumerate(ams_data.get("ams", []) or []): - if not isinstance(unit, dict): - continue - unit_id = str(unit.get("id", unit_index)) - slots = [] - for slot_index, tray in enumerate(unit.get("tray", []) or []): - if not isinstance(tray, dict): - continue - slots.append(self._convert_filament_tray(tray, slot_index)) - units.append({ - "id": unit_id, - "type": self._ams_type_name(unit), - "extruder": self._int_or_default(unit.get("ext_id"), 0), - "temperature": self._float_or_default(unit.get("temp"), 0.0), - "humidity_percent": self._int_or_default(unit.get("humidity_raw"), -1), - "dry_time_min": self._int_or_default(unit.get("dry_time"), 0), - "slots": slots, - }) - return units - - def _build_external_filaments(self, vt_tray) -> list[dict[str, Any]]: - if not isinstance(vt_tray, dict): - return [] - return [self._convert_filament_tray(vt_tray, 0, extruder=0)] - - def _convert_filament_tray(self, tray, index, extruder=None) -> dict[str, Any]: - color = self._normalize_color(tray.get("tray_color") or tray.get("color") or "00000000") - material = str(tray.get("tray_type") or tray.get("type") or "") - preset_id = str(tray.get("tray_info_idx") or tray.get("preset_id") or "") - converted = { - "index": self._int_or_default(tray.get("id"), index), - "loaded": bool(material or preset_id or tray.get("n")), - "material": material, - "preset_id": preset_id, - "color": color, - "nozzle_temp_min": self._int_or_default(tray.get("nozzle_temp_min"), 0), - "nozzle_temp_max": self._int_or_default(tray.get("nozzle_temp_max"), 0), - "remain_percent": self._int_or_default(tray.get("remain"), -1), - "k": self._float_or_default(tray.get("k"), 0.0), - } - if extruder is not None: - converted["extruder"] = extruder - diameter = self._float_or_none(tray.get("tray_diameter") or tray.get("diameter")) - if diameter is not None: - converted["diameter_mm"] = diameter - weight = self._int_or_none(tray.get("tray_weight") or tray.get("weight")) - if weight is not None: - converted["weight_g"] = weight - return converted - - def _start_local_print(self, params, upload_error_code, update_fn=None, cancel_fn=None): - self._ensure_state() - if params is None: - self._last_error = "Missing print params" - self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE - return self._last_code - if self._cancel_requested(cancel_fn): - self._last_error = "Cancelled" - self._last_code = BAMBU_NETWORK_ERR_CANCELED - return self._last_code - - self._update_progress(update_fn, PRINTING_STAGE_CREATE, 0, "Preparing...") - if not self._upload_print_file(params, update_fn, cancel_fn, upload_error_code): - self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error) - return self._last_code - if self._cancel_requested(cancel_fn): - self._last_error = "Cancelled" - self._last_code = BAMBU_NETWORK_ERR_CANCELED - return self._last_code - - try: - payload = self._build_project_file_payload(params) - except Exception as exc: - self._last_error = str(exc) - self._last_code = BAMBU_NETWORK_ERR_INVALID_HANDLE - self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error) - return self._last_code - - self._update_progress(update_fn, PRINTING_STAGE_SENDING, 0, "Starting print...") - if not self._queue_payload(payload): - self._last_code = BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED - self._update_progress(update_fn, PRINTING_STAGE_ERROR, self._last_code, self._last_error) - return self._last_code - - self._update_progress(update_fn, PRINTING_STAGE_FINISHED, 100, "1") - return 0 - - def _build_project_file_payload(self, params) -> dict[str, Any]: - filename = self._remote_print_name(params) - if not filename: - raise ValueError("Missing print filename") - - plate = self._param(params, "plate_index", 1) - try: - plate = int(plate) - except (TypeError, ValueError): - plate = 1 - - ams_mapping = self._json_or_default(self._param(params, "ams_mapping"), [0]) - if not isinstance(ams_mapping, list): - ams_mapping = [ams_mapping] - - return { - "print": { - "command": "project_file", - "param": self._param(params, "plate_location") or f"Metadata/plate_{plate}.gcode", - "file": filename, - "bed_leveling": self._as_bool(self._param(params, "task_bed_leveling", True)), - "bed_type": self._param(params, "task_bed_type", "textured_plate"), - "flow_cali": self._as_bool(self._param(params, "task_flow_cali", True)), - "vibration_cali": self._as_bool(self._param(params, "task_vibration_cali", True)), - "url": self._param(params, "url") or f"ftp:///{filename}", - "layer_inspect": self._as_bool(self._param(params, "task_layer_inspect", False)), - "sequence_id": str(self._param(params, "sequence_id") or self._next_sequence_id()), - "timelapse": self._as_bool(self._param(params, "task_record_timelapse", False)), - "use_ams": self._as_bool(self._param(params, "task_use_ams", True)), - "ams_mapping": ams_mapping, - "skip_objects": self._json_or_default(self._param(params, "skip_objects"), None), - } - } - - def _build_sdcard_project_file_payload(self, params) -> dict[str, Any]: - remote = ( - self._param(params, "dst_file") - or self._param(params, "ftp_file") - or self._param(params, "file") - or self._param(params, "filename") - ) - if not remote: - raise ValueError("Missing printer storage file") - remote = str(remote) - if remote.startswith("file://"): - file_url = remote - elif remote.startswith("/"): - file_url = f"file://{remote}" - else: - file_url = f"file:///{remote}" - payload_params = dict(params) if isinstance(params, dict) else {} - payload_params["ftp_file"] = Path(remote).name - payload_params["url"] = self._param(params, "url") or file_url - return self._build_project_file_payload(payload_params) - - def _upload_print_file(self, params, update_fn=None, cancel_fn=None, error_code=BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED) -> bool: - source = ( - self._param(params, "local_path") - or self._param(params, "source") - or self._param(params, "filename") - or self._param(params, "dst_file") - ) - if not source: - self._last_error = "Missing local file path" - self._last_code = BAMBU_NETWORK_ERR_FILE_NOT_EXIST - return False - path = Path(source) - if not path.is_file(): - self._last_error = f"Local file does not exist: {source}" - self._last_code = BAMBU_NETWORK_ERR_FILE_NOT_EXIST - return False - - remote = self._remote_print_name(params, path) - host = self._param(params, "dev_ip") or self._mqtt_host - username = self._param(params, "username") or self._mqtt_username or BAMBU_DEFAULT_USERNAME - password = self._param(params, "password") or self._mqtt_password - use_ssl = self._as_bool(self._param(params, "use_ssl_for_ftp", True)) - port = FTP_SSL_PORT if use_ssl else 21 - if not host or not password: - self._last_error = "Missing FTP host or password" - self._last_code = error_code - return False - - try: - self._update_progress(update_fn, PRINTING_STAGE_UPLOAD, 0, "Uploading...") - ftp = _ImplicitFTP_TLS() if use_ssl else ftplib.FTP() - _log( - "FTP upload starting " - f"host={host} port={port} ssl={use_ssl} source={path} remote={remote} " - f"size={path.stat().st_size}" - ) - ftp.connect(host=host, port=port, timeout=30) - ftp.login(username, password) - if use_ssl: - ftp.prot_p() - total = path.stat().st_size - uploaded = 0 - - def on_upload(block): - nonlocal uploaded - if self._cancel_requested(cancel_fn): - raise InterruptedError("Cancelled") - uploaded += len(block) - if total > 0: - self._update_progress(update_fn, PRINTING_STAGE_UPLOAD, min(100, int(uploaded * 100 / total)), "Uploading...") - - with path.open("rb") as fh: - ftp.storbinary(f"STOR {remote}", fh, blocksize=32768, callback=on_upload) - ftp.close() - self._update_progress(update_fn, PRINTING_STAGE_UPLOAD, 100, "Uploading...") - _log(f"FTP upload succeeded remote={remote} bytes={uploaded}") - return True - except InterruptedError as exc: - self._last_error = str(exc) - self._last_code = BAMBU_NETWORK_ERR_CANCELED - _log(f"FTP upload cancelled remote={remote}: {exc}") - return False - except Exception as exc: - self._last_error = str(exc) - self._last_code = error_code - _log(f"FTP upload failed remote={remote} code={error_code}: {type(exc).__name__}: {exc}") - return False - - def _remote_print_name(self, params, source_path: Path | None = None) -> str: - remote = self._param(params, "ftp_file") or self._param(params, "file") - if remote: - return Path(str(remote)).name - candidate = self._param(params, "filename") or self._param(params, "dst_file") or self._param(params, "source") or self._param(params, "local_path") - if candidate: - return Path(str(candidate)).name - project_name = self._param(params, "project_name") - if project_name: - return Path(str(project_name)).name - return source_path.name if source_path else "" - - def _status_response(self, result): - if isinstance(result, bool): - result = 0 if result else (self._last_code or BAMBU_NETWORK_ERR_INVALID_HANDLE) - if result == 0: - return json.dumps({"status": "ok"}) - status = "cancelled" if result == BAMBU_NETWORK_ERR_CANCELED else "error" - return json.dumps({ - "status": status, - "code": result, - "message": self._last_error or "operation failed", - }) - - @staticmethod - def _looks_like_bambu_payload(request) -> bool: - return isinstance(request, dict) and any(key in request for key in ("print", "system", "pushing", "info", "upgrade", "xcam", "camera")) - - @staticmethod - def _disconnect_reason(args) -> int: - if not args: - return 0 - if len(args) >= 2: - return BBLPrinterAgentPlugin._mqtt_result_code(args[1]) - return BBLPrinterAgentPlugin._mqtt_result_code(args[0]) - - @staticmethod - def _ams_type_name(unit) -> str: - ams_type = str(unit.get("ams_type", unit.get("type", "ams"))).lower() - if ams_type in {"2", "ams_lite", "ams-lite"}: - return "ams_lite" - if ams_type in {"3", "n3f"}: - return "n3f" - if ams_type in {"4", "n3s"}: - return "n3s" - return "ams" - - @staticmethod - def _normalize_color(value) -> str: - color = str(value or "").strip().lstrip("#") - if not color: - return "00000000" - if len(color) == 6: - color += "FF" - return color.upper() - - @staticmethod - def _param(params, key, default=None): - if isinstance(params, dict): - return params.get(key, default) - return getattr(params, key, default) - - def _next_sequence_id(self): - with self._mqtt_lock: - self._sequence_id += 1 - return self._sequence_id - - @staticmethod - def _update_progress(update_fn, stage, code, info): - if not update_fn: - return - try: - update_fn(stage, code, info) - except Exception as exc: - _log(f"progress callback failed: {exc}") - - @staticmethod - def _cancel_requested(cancel_fn) -> bool: - if not cancel_fn: - return False - try: - return bool(cancel_fn()) - except Exception as exc: - _log(f"cancel callback failed: {exc}") - return False - - @staticmethod - def _json_or_default(value, default): - if value in (None, ""): - return default - if isinstance(value, str): - try: - return json.loads(value) - except json.JSONDecodeError: - return default - return value - - @staticmethod - def _int_or_default(value, default): - result = BBLPrinterAgentPlugin._int_or_none(value) - return default if result is None else result - - @staticmethod - def _int_or_none(value): - try: - return int(value) - except (TypeError, ValueError): - return None - - @staticmethod - def _float_or_default(value, default): - result = BBLPrinterAgentPlugin._float_or_none(value) - return default if result is None else result - - @staticmethod - def _float_or_none(value): - try: - return float(value) - except (TypeError, ValueError): - return None - - @staticmethod - def _mqtt_result_code(rc) -> int: - try: - return int(rc) - except (TypeError, ValueError): - value = getattr(rc, "value", None) - return int(value) if value is not None else -1 - - @staticmethod - def _as_bool(value) -> bool: - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return bool(value) - - -@orca.plugin -class BBLPrinterAgentPackage(orca.base): - def register_capabilities(self): - orca.register_capability(BBLPrinterAgentPlugin)