diff --git a/docs/plugins/examples/host_ui_panel.py b/docs/plugins/examples/host_ui_panel.py deleted file mode 100644 index ae1f0d2651..0000000000 --- a/docs/plugins/examples/host_ui_panel.py +++ /dev/null @@ -1,475 +0,0 @@ -# /// script -# requires-python = ">=3.12" -# dependencies = ["numpy"] -# -# [tool.orcaslicer.plugin] -# name = "Host Inspector Panel" -# description = "A non-modal interactive panel that browses the whole orca.host read-only API." -# author = "OrcaSlicer" -# version = "1.0.0" -# /// -"""Host Inspector — a worked sample for the interactive `orca.host.ui` API that -also walks the entire `orca.host` read-only surface. - -Run it from the Plugins dialog. It opens a NON-MODAL window (so OrcaSlicer stays -usable) with three expandable rows — Plater, Presets, Model. Each row shows a one -line summary; click its triangle to reveal the full detail for that section. The -page talks to the plugin through the injected `window.orca` bridge: - - page --orca.postMessage({command:'refresh'})--> plugin.on_message() - page --orca.postMessage({command:'detail',section})--> plugin.on_message() - plugin --win.post({command:'summary'|'detail', ...})--> page (orca.onMessage) - -Detail is fetched lazily — the heavy Model walk (mesh geometry + numpy arrays) -runs only when you expand the Model row. Click "Refresh" after changing the plate -and the summaries update; any section left open re-fetches its detail to stay live. - -numpy is declared as a dependency so the zero-copy mesh arrays and 4x4 matrices -are available; the code still degrades gracefully if it is missing. -""" - -import orca - -try: - import numpy as np -except Exception: - np = None - - -# Soft caps so a heavy scene cannot produce an unusably long detail block. When a -# cap trims output we say so explicitly rather than truncating silently. -MAX_OBJECTS = 25 -MAX_VOLUMES = 10 -MAX_INSTANCES = 10 - - -# --------------------------------------------------------------------------- # -# small formatting helpers -# --------------------------------------------------------------------------- # -def fmt_vec(v): - """Format an (x, y, z) tuple / 3-element sequence with mm precision.""" - return "(" + ", ".join(f"{float(c):.3f}" for c in v) + ")" - - -def fmt_bbox(bb): - """Format a host.BoundingBox as min / max / size (mm).""" - if not bb.defined: - return "" - return f"min{fmt_vec(bb.min)} max{fmt_vec(bb.max)} size{fmt_vec(bb.size)}" - - -def vol_type_name(volume): - """Readable name of a ModelVolumeType enum value.""" - t = volume.type() - return getattr(t, "name", str(t)) - - -class Report: - """Accumulates indented key/value lines into one block of text.""" - - def __init__(self): - self._lines = [] - - def line(self, text=""): - self._lines.append(text) - - def kv(self, indent, key, value, width=15): - pad = " " * indent - self._lines.append(f"{pad}{(key + ':'):<{width}} {value}") - - def text(self): - return "\n".join(self._lines) - - -# --------------------------------------------------------------------------- # -# section builders — each guards itself so one failure doesn't sink the report -# --------------------------------------------------------------------------- # -def report_plater(r, plater): - r.line("[Plater]") - try: - r.kv(2, "project dirty", plater.is_project_dirty()) - r.kv(2, "presets dirty", plater.is_presets_dirty()) - r.kv(2, "in snapshot", plater.inside_snapshot_capture()) - except Exception as exc: - r.kv(2, "error", exc) - r.line() - - -def report_presets(r, bundle): - r.line("[Presets]") - try: - pp = bundle.current_print_preset() - r.kv(2, "process", f"{pp.name} (system={pp.is_system} dirty={pp.is_dirty})") - - printer = bundle.current_printer_preset() - r.kv(2, "printer", f"{printer.name} (system={printer.is_system})") - - r.kv(2, "filaments", bundle.current_filament_preset_names()) - - # PresetCollection access: sizes + currently selected names. - r.kv(2, "collections", - f"prints={bundle.prints.size()} " - f"printers={bundle.printers.size()} " - f"filaments={bundle.filaments.size()}") - - # full_config_value(): a few representative keys, only if present. - keys = ("layer_height", "nozzle_diameter", "filament_type", - "printer_model", "sparse_infill_density") - samples = [] - for key in keys: - value = bundle.full_config_value(key) - if value is not None: - samples.append(f"{key}={value}") - if samples: - r.kv(2, "config sample", " | ".join(samples)) - r.kv(2, "config keys", f"{len(bundle.full_config_keys())} total") - except Exception as exc: - r.kv(2, "error", exc) - r.line() - - -def report_mesh(r, indent, mesh, instance, volume): - """Detail a host.TriangleMesh: counts, samples, and (numpy) arrays.""" - r.kv(indent, "vertices", mesh.vertex_count()) - r.kv(indent, "triangles", mesh.triangle_count()) - r.kv(indent, "empty", mesh.is_empty()) - r.kv(indent, "manifold", mesh.is_manifold()) - r.kv(indent, "volume", f"{mesh.volume():.3f} mm^3") - r.kv(indent, "bbox", fmt_bbox(mesh.bounding_box())) - if not mesh.is_empty(): - # numpy-free element access (always available, bounds-checked). - r.kv(indent, "vertex[0]", fmt_vec(mesh.vertex(0))) - r.kv(indent, "triangle[0]", tuple(mesh.triangle(0))) - - if np is None: - r.kv(indent, "numpy", 'not installed (add dependencies=["numpy"])') - return - - try: - V = np.asarray(mesh.vertices()) # (N, 3) float32, read-only, zero-copy - T = np.asarray(mesh.triangles()) # (M, 3) int32, read-only, zero-copy - N = np.asarray(mesh.face_normals()) # (M, 3) float32, computed copy - r.kv(indent, "np vertices", - f"shape={V.shape} dtype={V.dtype} " - f"writeable={V.flags.writeable} zero_copy={V.base is not None}") - r.kv(indent, "np triangles", f"shape={T.shape} dtype={T.dtype}") - r.kv(indent, "np normals", f"shape={N.shape} dtype={N.dtype}") - - # World-space bounding box for this volume under `instance`, using the - # row-vector convention world = [V 1] @ (instance @ volume).T - if instance is not None and V.size: - M = instance.matrix() @ volume.matrix() # 4x4 float64 - homog = np.c_[V.astype(np.float64), np.ones(len(V))] - world = (homog @ M.T)[:, :3] - r.kv(indent, "world bbox", - f"min{fmt_vec(world.min(0))} max{fmt_vec(world.max(0))}") - except Exception as exc: - r.kv(indent, "numpy", f"") - - -def report_volume(r, index, volume, instance): - r.line(f" Volume[{index}] '{volume.name}' type={vol_type_name(volume)}") - try: - r.kv(6, "roles", - f"part={volume.is_model_part()} modifier={volume.is_modifier()} " - f"negative={volume.is_negative_volume()} " - f"support_enf={volume.is_support_enforcer()} " - f"support_blk={volume.is_support_blocker()}") - r.kv(6, "extruder_id", volume.extruder_id()) - r.kv(6, "offset", fmt_vec(volume.offset())) - r.kv(6, "rotation", fmt_vec(volume.rotation())) - r.kv(6, "scale", fmt_vec(volume.scaling_factor())) - r.kv(6, "mirror", fmt_vec(volume.mirror())) - r.kv(6, "facets", volume.facets_count()) - r.kv(6, "manifold", volume.is_manifold()) - r.kv(6, "mesh errors", volume.mesh_errors_count()) - r.kv(6, "painted", - f"support={volume.is_fdm_support_painted()} " - f"seam={volume.is_seam_painted()} " - f"mm={volume.is_mm_painted()} " - f"fuzzy={volume.is_fuzzy_skin_painted()}") - r.kv(6, "config keys", len(volume.config_keys())) - r.line(" mesh:") - report_mesh(r, 8, volume.mesh(), instance, volume) - except Exception as exc: - r.kv(6, "error", exc) - - -def report_instance(r, index, instance): - r.line(f" Instance[{index}]") - try: - r.kv(6, "printable", instance.printable) - r.kv(6, "is_printable", instance.is_printable()) - r.kv(6, "offset", fmt_vec(instance.offset())) - r.kv(6, "rotation", fmt_vec(instance.rotation())) - r.kv(6, "scale", fmt_vec(instance.scaling_factor())) - r.kv(6, "mirror", fmt_vec(instance.mirror())) - r.kv(6, "left_handed", instance.is_left_handed()) - r.kv(6, "world bbox", fmt_bbox(instance.bounding_box())) - except Exception as exc: - r.kv(6, "error", exc) - - -def report_object(r, index, obj): - r.line(f" Object[{index}] '{obj.name}'") - try: - r.kv(4, "input_file", obj.input_file or "") - r.kv(4, "module_name", obj.module_name or "") - r.kv(4, "printable", obj.printable) - r.kv(4, "volumes", obj.volume_count()) - r.kv(4, "instances", obj.instance_count()) - r.kv(4, "facets", obj.facets_count()) - r.kv(4, "parts", obj.parts_count()) - r.kv(4, "materials", obj.materials_count()) - r.kv(4, "mesh errors", obj.mesh_errors_count()) - r.kv(4, "flags", - f"multiparts={obj.is_multiparts()} cut={obj.is_cut()} " - f"custom_layering={obj.has_custom_layering()}") - r.kv(4, "painted", - f"support={obj.is_fdm_support_painted()} " - f"seam={obj.is_seam_painted()} " - f"mm={obj.is_mm_painted()} " - f"fuzzy={obj.is_fuzzy_skin_painted()}") - r.kv(4, "z range", f"[{obj.min_z():.3f}, {obj.max_z():.3f}]") - r.kv(4, "bbox", fmt_bbox(obj.bounding_box())) - r.kv(4, "raw bbox", fmt_bbox(obj.raw_mesh_bounding_box())) - - # The first instance is used as the frame for world-space mesh maths. - instance0 = obj.instance(0) if obj.instance_count() else None - - shown = min(obj.volume_count(), MAX_VOLUMES) - for vi in range(shown): - report_volume(r, vi, obj.volume(vi), instance0) - if obj.volume_count() > shown: - r.line(f" ... and {obj.volume_count() - shown} more volume(s)") - - shown = min(obj.instance_count(), MAX_INSTANCES) - for ii in range(shown): - report_instance(r, ii, obj.instance(ii)) - if obj.instance_count() > shown: - r.line(f" ... and {obj.instance_count() - shown} more instance(s)") - except Exception as exc: - r.kv(4, "error", exc) - r.line() - - -def report_model(r, model): - r.line(f"[Model] id={model.id()}") - try: - r.kv(2, "objects", model.object_count()) - r.kv(2, "materials", model.material_count()) - r.kv(2, "current plate", model.current_plate_index()) - r.kv(2, "max_z", f"{model.max_z():.3f} mm") - r.kv(2, "painted", - f"support={model.is_fdm_support_painted()} " - f"seam={model.is_seam_painted()} " - f"mm={model.is_mm_painted()} " - f"fuzzy={model.is_fuzzy_skin_painted()}") - r.kv(2, "designer", repr(model.designer())) - r.kv(2, "design_id", repr(model.design_id())) - r.kv(2, "bbox exact", fmt_bbox(model.bounding_box())) - r.kv(2, "bbox approx", fmt_bbox(model.bounding_box_approx())) - except Exception as exc: - r.kv(2, "error", exc) - r.line() - - count = model.object_count() - if count == 0: - r.line(" (no objects on the plate)") - r.line() - return - - shown = min(count, MAX_OBJECTS) - for oi in range(shown): - report_object(r, oi, model.object(oi)) - if count > shown: - r.line(f" ... and {count - shown} more object(s)") - r.line() - - -# --------------------------------------------------------------------------- # -# one-line summaries (collapsed row text) — pure formatters -# --------------------------------------------------------------------------- # -def summary_plater(plater): - return f"project dirty: {'yes' if plater.is_project_dirty() else 'no'}" - - -def summary_presets(bundle): - return f"printer: {bundle.current_printer_preset().name}" - - -def summary_model(model): - return f"{model.object_count()} object(s) · max_z {model.max_z():.1f} mm" - - -# --------------------------------------------------------------------------- # -# the page — three expandable section rows, themed, self-contained -# --------------------------------------------------------------------------- # -# -# No hardcoded colors: the host injects a theme matching OrcaSlicer's current -# light/dark mode and exposes it as CSS variables (--orca-bg, --orca-fg, -# --orca-muted, --orca-accent, --orca-border). The page only adds layout and -# reuses those variables, so it follows the active theme automatically. -PAGE = r""" - - - - - - -
-

Host Inspector

- - -
- -
-
- Plater - -
- -
-
-
- Presets - -
- -
-
-
- Model - -
- -
- - - - -""" - - -# --------------------------------------------------------------------------- # -# the plugin -# --------------------------------------------------------------------------- # -class HostInspectorPanel(orca.script.ScriptPluginCapabilityBase): - def get_name(self): - return "Host Inspector" - - def execute(self): - # Non-modal: returns immediately. The window is host-owned and lives on - # after execute() returns; on_message keeps firing when the page posts. - self.win = orca.host.ui.create_window( - title="Host Inspector", - html=PAGE, - width=760, - height=560, - on_message=self.on_message, - on_close=self.on_close, - ) - return orca.ExecutionResult.success("Host Inspector opened.") - - # Called on the UI thread when the page posts a message. - def on_message(self, msg): - msg = msg or {} - command = msg.get("command") - if command == "refresh": - self.win.post({"command": "summary", "data": self.summaries()}) - elif command == "detail": - section = msg.get("section", "") - self.win.post({"command": "detail", "section": section, - "text": self.detail(section)}) - - def on_close(self): - print("Host Inspector closed") - - @staticmethod - def summaries(): - """One-line summary per section; each guarded independently so one - failure (or a not-ready host) shows only that row's error.""" - def safe(fn): - try: - return fn() - except Exception as exc: - return f"" - return { - "plater": safe(lambda: summary_plater(orca.host.plater())), - "presets": safe(lambda: summary_presets(orca.host.preset_bundle())), - "model": safe(lambda: summary_model(orca.host.model())), - } - - @staticmethod - def detail(section): - """Full monospace report for one section, built on demand.""" - try: - r = Report() - if section == "plater": - report_plater(r, orca.host.plater()) - elif section == "presets": - report_presets(r, orca.host.preset_bundle()) - elif section == "model": - report_model(r, orca.host.model()) - else: - return f"" - return r.text() - except Exception as exc: - return f"" - - -@orca.plugin -class HostInspectorPlugin(orca.base): - def register_capabilities(self): - orca.register_capability(HostInspectorPanel) diff --git a/docs/plugins/examples/multi_capability_skeleton.py b/docs/plugins/examples/multi_capability_skeleton.py deleted file mode 100644 index ff47b500ce..0000000000 --- a/docs/plugins/examples/multi_capability_skeleton.py +++ /dev/null @@ -1,169 +0,0 @@ -# /// script -# requires-python = ">=3.12" -# dependencies = [] -# -# [tool.orcaslicer.plugin] -# name = "Capability Skeleton" -# description = "Starter template: one plugin package that registers several different capabilities." -# author = "Your Name" -# version = "1.0.0" -# /// -"""Multi-capability plugin skeleton. - -Copy this file into its own folder under ``data_dir()/orca_plugins//`` -and adapt it. It shows the full shape of a plugin that offers more than one -capability: - - * ExampleScript - a ``script`` capability (runs from the Plugins dialog) - * ExamplePostProcess - a ``post-processing`` capability (edits exported G-code) - * ExamplePrinterAgent - a ``printer-connection`` capability (a network printer agent) - -A plugin is a *package* (the ``@orca.plugin`` class at the bottom) that registers one -or more *capabilities*. Each capability is an independent class with its own name and -type. To make your own plugin: delete the capabilities you do not need, fill in the -ones you keep, and register only those in ``register_capabilities``. - -See ``plugin_development.md`` for the full reference, and ``host_ui_panel.py`` for a -richer worked example built on the ``orca.host`` read-only API. -""" - -import orca - - -# --------------------------------------------------------------------------- # -# Capability 1 - a script capability -# Runs on the main/UI thread via the Plugins dialog "Run" action. Keep -# execute() fast: a slow call freezes the UI. Offload heavy work to your own -# threading.Thread (which must not touch the model) and surface results through -# an orca.host.ui window (see host_ui_panel.py). -# --------------------------------------------------------------------------- # -class ExampleScript(orca.script.ScriptPluginCapabilityBase): - def get_name(self): - # Display name; unique within this plugin; must not contain ';'. - return "Example Script" - - def on_load(self): - # Optional. Runs once when the capability is loaded. Default: no-op. - pass - - def on_unload(self): - # Optional. Runs once when the capability is unloaded. Default: no-op. - pass - - def execute(self): - # TODO: your logic here. - return orca.ExecutionResult.success("Example Script ran") - - -# --------------------------------------------------------------------------- # -# Capability 2 - a post-processing (G-code) capability -# Runs on a background slicing thread during G-code export. Receives a context -# pointing at the temporary G-code file, which you may rewrite in place. -# --------------------------------------------------------------------------- # -class ExamplePostProcess(orca.gcode.GCodePluginCapabilityBase): - def get_name(self): - return "Example Post-process" - - def execute(self, ctx): - # ctx.gcode_path - absolute path to the temp G-code being post-processed - # ctx.output_name - the output file name - # ctx.host - target host when exporting to a network printer - # ctx.orca_version - OrcaSlicer version string - # Writing into the folder of ctx.gcode_path is permitted by the audit hook; - # writing elsewhere outside data_dir() is blocked. - try: - with open(ctx.gcode_path, "a", encoding="utf-8") as f: - f.write(f"\n; processed by Example Post-process for {ctx.output_name}\n") - except Exception as exc: - return orca.ExecutionResult.failure( - orca.PluginResult.RecoverableError, - f"post-process failed: {exc}") - return orca.ExecutionResult.success("G-code annotated") - - -# --------------------------------------------------------------------------- # -# Capability 3 - a printer-connection (agent) capability -# Registers a network printer agent on load. Unlike the other capabilities, an -# agent is driven through the native printer-agent surface: the host calls -# individual operations (connect_printer, start_discovery, start_print, ...) -# directly on your object. orca.printer_agent.PrinterAgentBase declares ~30 -# pure-virtual operations and EVERY one must be overridden - an operation you -# leave out raises RuntimeError the moment the host calls it. This skeleton -# implements just enough to load and be discovered; see -# resources/orca_plugins/BBLPrinterAgentPlugin.py for a complete working agent -# and the full method list. Delete this whole class if you are not writing one. -# --------------------------------------------------------------------------- # -class ExamplePrinterAgent(orca.printer_agent.PrinterAgentBase): - def get_name(self): - return "Example Printer Agent" - - def get_agent_info(self): - return orca.printer_agent.AgentInfo( - id="example-agent", - name="Example Printer Agent", - version="1.0.0", - description="Skeleton printer agent.", - ) - - # --- connection ------------------------------------------------------- # - def connect_printer(self, dev_id, dev_ip, username, password, use_ssl) -> int: - # TODO: open your transport (MQTT/HTTP/serial/...). Return 0 on success. - return 0 - - def disconnect_printer(self) -> int: - # TODO: tear the transport down. Return 0 on success. - return 0 - - # --- discovery -------------------------------------------------------- # - def start_discovery(self, start=True, sending=False) -> bool: - # TODO: start/stop scanning the network for printers. Return True on success. - return True - - # --- messaging -------------------------------------------------------- # - def send_message(self, dev_id, json_str, qos=0, flag=0) -> int: - # TODO: publish a control message to the device. Return 0 on success. - return 0 - - def get_user_selected_machine(self) -> str: - return "" - - def set_user_selected_machine(self, dev_id) -> int: - return 0 - - # --- printing --------------------------------------------------------- # - def start_print(self, params=None, update_fn=None, cancel_fn=None, wait_fn=None) -> int: - # params is an orca.printer_agent.PrintParams. The host also passes callbacks: - # update_fn(stage, percent, message) - report progress to the host UI - # cancel_fn() -> bool - poll it; abort if it returns True - # wait_fn(...) - host-provided wait hook - # Return 0 on success. - return 0 - - # --- filament sync ---------------------------------------------------- # - def get_filament_sync_mode(self): - return orca.printer_agent.FilamentSyncMode.None_ - - def fetch_filament_info(self, dev_id) -> bool: - return False - - # NOTE: PrinterAgentBase has more pure-virtual operations a real agent must - # implement, e.g. send_message_to_printer, bind_detect, bind/unbind, ping_bind, - # check_cert/install_device_cert, request_bind_ticket, start_local_print, - # start_local_print_with_record, start_sdcard_print, start_send_gcode_to_sdcard, - # and the host-callback setters (set_server_callback, set_on_message_fn, - # set_on_printer_connected_fn, set_queue_on_main_fn, ...). See - # BBLPrinterAgentPlugin.py for the full set and expected signatures. - - -# --------------------------------------------------------------------------- # -# The package - exactly one @orca.plugin class per file. register_capabilities() -# declares which capabilities this plugin exposes. A capability you do not pass to -# register_capability() is invisible to OrcaSlicer, even if its class is defined -# above. -# --------------------------------------------------------------------------- # -@orca.plugin -class CapabilitySkeleton(orca.base): - def register_capabilities(self): - orca.register_capability(ExampleScript) - orca.register_capability(ExamplePostProcess) - orca.register_capability(ExamplePrinterAgent) diff --git a/docs/plugins/plugin_audit_hook.md b/docs/plugins/plugin_audit_hook.md deleted file mode 100644 index 826f7654d4..0000000000 --- a/docs/plugins/plugin_audit_hook.md +++ /dev/null @@ -1,387 +0,0 @@ -# Plugin Audit Hook - -OrcaSlicer's plugin system runs Python, which is extremely capable — it can read and -write files, spawn processes, open sockets, and load native code. To keep plugins from -reaching outside what they legitimately need, we install a **CPython audit hook** that -inspects sensitive runtime operations performed by plugin code and blocks the ones that -fall outside an allow‑list. - -> **Scope of this version.** This is intentionally a *narrow, low‑risk first version* — -> groundwork, not a complete sandbox. Today it enforces one thing: **file writes are -> restricted to an allow‑list of directories** while a plugin is executing. Reads are -> left permissive so Python can still import modules. Process/network/native‑code events -> are *not* yet enforced. See [Limitations](#limitations) before relying on it as a -> security boundary. - ---- - -## What is a plugin audit hook? - -CPython exposes an auditing API (PEP 578). Any interpreter‑wide hook registered with -`PySys_AddAuditHook` is called *before* the runtime performs a sensitive operation — for -example opening a file (`open`), spawning a subprocess (`subprocess.Popen`), or connecting -a socket (`socket.connect`). The hook receives the event name and its arguments and may -abort the operation by setting a Python exception and returning a non‑zero value. - -We register exactly **one** such hook, once, from `PythonInterpreter::initialize()` via -`PluginAuditManager::instance().install_hook()`. Everything else — *which* plugin is -running, *what* mode it runs under, and *which* directories it may touch — is tracked by -`PluginAuditManager`. - -The hook itself is global to the interpreter, but it only enforces anything when a plugin -**audit context** is active (see below). Non‑plugin Python code, and plugin loading before -the context is set, pass through untouched. - ---- - -## How it works - -There are three moving parts. Keep them distinct — conflating them is the usual source of -confusion. - -### 1. Audit identity — *who* is running (set once, per instance) - -Every plugin instance carries a C++‑only identity string, never exposed to Python: - -```cpp -// PythonPluginInterface.hpp -class PluginCapabilityInterface { -public: - void set_audit_plugin_key(std::string key); - const std::string& audit_plugin_key() const; -private: - std::string m_audit_plugin_key; // == PluginDescriptor::plugin_key -}; -``` - -This is the canonical runtime ID, `PluginDescriptor::plugin_key`. It is stamped onto the -instance by the loader **after** the plugin is captured and **before** `on_load()` runs: - -- `PluginLoader::load_plugin_impl()` → `set_audit_plugin_key(descriptor.plugin_key)` -- `PluginLoader::update_loaded_plugin_key()` → re‑stamps it if a key is migrated - -Stamping the identity does **not** turn on enforcement — it only labels the object so that -later calls know which plugin they belong to. This matters because printer‑agent plugins -are later invoked through `IPrinterAgent` / `NetworkAgent`, where the original `plugin_key` -is no longer available at the call site; the instance carries it instead. - -### 2. Audit context — *how strict*, for the duration of one call (set per call) - -The active plugin, mode, and scoped roots live in thread‑local state on -`PluginAuditManager`. They are set and restored by an RAII guard, -`ScopedPluginAuditContext`: - -```cpp -// constructor: remember previous state, then apply the new plugin/mode and clear scoped roots -ScopedPluginAuditContext(const std::string& plugin_key, - AuditMode mode = AuditMode::Loading); -// destructor: restore the previous plugin/mode/scoped-roots -``` - -A context is constructed at the **start of every C++ → Python trampoline call** and -destroyed when that call returns or throws. So enforcement is *per call*: outside any -trampoline call the mode is just its default and `current_plugin()` is empty, so the hook -allows everything. - -### 3. Audit modes — what "strict" means - -```cpp -enum class AuditMode { - // Import/loading phase: allow reads anywhere, only block writes - // outside allowed roots. Python needs to read stdlib modules - // during import and those are not inside plugin directories. - Loading, - - // Execution phase: block both reads and writes outside allowed - // roots, plus subprocess/socket/ctypes. - Enforcing, -}; -``` - -The check that implements this is `PluginAuditManager::check_open(path, mode)`: - -1. Empty path → allow. -2. No active plugin (`current_plugin()` empty) → allow. -3. `Loading` **and** the open is a read (`mode` has no `w`/`a`/`+`) → allow (early‑out). -4. Otherwise the path must resolve inside a **scoped allowed root** or the **global - allowed root**, else it is blocked. - -So the only difference between the two modes is step 3: `Loading` lets reads through before -the allow‑list check; `Enforcing` does not, so reads are subject to the same allow‑list as -writes. - -### Allowed roots - -There are two tiers, checked in this order: - -| Tier | Stored in | Lifetime | Set by | -|---|---|---|---| -| **Scoped** | thread‑local, cleared on every new context | one call | `add_scoped_allowed_root()` inside an `audit_setup` callback | -| **Global** | shared, mutex‑guarded | process | `add_global_allowed_root()` in `install_hook()` | - -In this version the global allow‑list contains **only `data_dir()`**. The executable -directory and resources directory are deliberately *not* allowed — plugins must not write -there. G‑code plugins additionally get the temp G‑code folder as a *scoped* root for the -duration of their `execute()` call. - -Path matching (`is_inside_allowed_root`) canonicalizes both paths with -`weakly_canonical` (resolving symlinks without requiring existence) and does a -component‑wise prefix match that rejects any `..` traversal. - -### Putting it together — the flow of one call - -``` -PluginLoader (once) set_audit_plugin_key(plugin_key) // identity stamped - │ - ▼ -C++ calls plugin->execute() ─► trampoline method - │ ├─ ScopedPluginAuditContext ctor // mode + plugin set - │ ├─ audit_setup() // e.g. add scoped roots - │ └─ PYBIND11_OVERRIDE(_PURE) ──► Python runs - │ │ - │ Python does open("/x", "w") ─┤ - │ ▼ - │ CPython raises "open" audit event - │ │ - │ PluginAuditManager::audit_hook - │ │ - │ check_open("/x","w") → blocked? - │ └─ PyErr_SetString + return -1 ► PermissionError - ▼ -trampoline returns ─► ScopedPluginAuditContext dtor // previous state restored -``` - ---- - -## Audit hook development - -The point of interest is **`PluginAuditManager.hpp` / `.cpp`** (the modes, the events, and -the policy) and the trampoline macros in **`PyPluginTrampoline.hpp`** (how each plugin -function opts into a mode). - -### Handling events - -Events are dispatched by name in `PluginAuditManager::audit_hook`. Return `0` to allow; -set a Python exception and return non‑zero (we use `-1`) to block: - -```cpp -int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) -{ - auto* mgr = static_cast(user_data); - std::string event_name(event ? event : ""); - - if (event_name == "open") { - // CPython passes ("open", path, mode, flags) - const char* path = nullptr; const char* mode = nullptr; int flags = 0; - if (!PyArg_ParseTuple(args, "s|si", &path, &mode, &flags)) { - PyErr_Clear(); - return 0; // couldn't parse — allow - } - if (!mgr->check_open(path ? path : "", mode ? mode : "r").allowed) { - PyErr_SetString(PyExc_PermissionError, - "Plugin attempted to access a blocked file path"); - return -1; // block - } - return 0; - } - - // else if (event_name == "os.rename") { ... } // see below - - return 0; // unhandled event — allow -} -``` - -To audit a new operation, add another `else if` branch. **Each event has its own argument -tuple** — you cannot assume `(path, mode, flags)`. Look the event up in the official table -and parse accordingly: - -- `os.rename` → `(src, dst, src_dir_fd, dst_dir_fd)` -- `os.remove` → `(path, dir_fd)` -- `os.mkdir` → `(path, mode, dir_fd)` -- `subprocess.Popen` → `(executable, args, cwd, env)` - -The complete, version‑specific list of audit events and their arguments: -**https://docs.python.org/3/library/audit_events.html** - -For filesystem mutations you'll usually want to route the extracted path(s) through -`check_open(path, "w")` (or a dedicated checker) so they share the same allow‑list logic. - -### Defining the audit mode of a function - -Every C++ → Python plugin call crosses a trampoline method, and those methods wrap the -pybind11 override in the `ORCA_PY_OVERRIDE_AUDITED` macro -(`PyPluginTrampoline.hpp`). The macro both (a) logs and rethrows Python exceptions at the -single boundary and (b) opens the audit context. Its signature: - -```cpp -ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, /* args... */) -``` - -| Param | Meaning | -|---|---| -| `mode` | `AuditMode::Loading` or `AuditMode::Enforcing` for this call | -| `audit_setup` | a callable (often `[] {}`) run *after* the context is constructed — use it to register scoped roots | -| `override_macro` | `PYBIND11_OVERRIDE` or `PYBIND11_OVERRIDE_PURE` | -| `ret, base, name, …` | the usual pybind11 override arguments | - -When you add a new method to any trampoline, **you must choose its mode** based on what the -function legitimately needs: - -```cpp -void on_load() override -{ - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, // imports during load → reads allowed - [] {}, // no extra setup - PYBIND11_OVERRIDE, - void, Base, on_load); -} -``` - -Rule of thumb: - -- Use **`Loading`** for lifecycle/setup calls that may import modules (`on_load`, - `on_unload`, `get_type`) or any call where you only care about restricting writes. -- Use **`Enforcing`** for calls that should also be prevented from *reading* outside the - allow‑list. Be aware this will block lazily‑imported stdlib/3rd‑party modules read from - disk during the call, so only use it where the plugin is not expected to import at call - time. - -### Adding per‑call allowed roots (the `audit_setup` callback) - -`ScopedPluginAuditContext`'s constructor **clears** the scoped roots, so any scoped root -must be added *after* construction — which is exactly what `audit_setup` is for. The G‑code -trampoline uses it to grant write access to the folder holding the current temp G‑code -file: - -```cpp -ExecutionResult execute(const GCodePluginContext& ctx) override -{ - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [&] { // runs only when a context is active - if (!ctx.gcode_path.empty()) - ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( - std::filesystem::path(ctx.gcode_path).parent_path()); - }, - PYBIND11_OVERRIDE_PURE, - ExecutionResult, GCodePlugin, execute, ctx); -} -``` - -The callback runs only when the instance has a non‑empty audit key (i.e. a context was -actually opened), so it's safe to assume enforcement is live inside it. - -### Adding a global allowed root - -If *every* plugin should be allowed a directory, add it in `install_hook()`: - -```cpp -void PluginAuditManager::install_hook() -{ - PySys_AddAuditHook(audit_hook, this); - add_global_allowed_root(data_dir()); // the only global root today - // add_global_allowed_root(std::filesystem::temp_directory_path()); // e.g. to allow /tmp -} -``` - -Prefer scoped roots over global ones — a global root widens the boundary for *all* plugins -and is process‑lifetime. Only add a global root when the access is genuinely universal. - -### Identity wiring (rarely touched) - -If you add a new way to load or re‑key plugin instances, make sure the new path also calls -`set_audit_plugin_key()` — otherwise the instance has an empty key and **no context is ever -opened**, so its calls run completely unaudited. The existing call sites are -`PluginLoader::load_plugin_impl()` and `PluginLoader::update_loaded_plugin_key()`. - ---- - -## Current policy at a glance - -| Plugin call | Mode | Effective access | -|---|---|---| -| `on_load` / `on_unload` / `get_type` | `Loading` | read anywhere; write only under `data_dir()` | -| G‑code `execute()` | `Loading` | + write under the current temp G‑code folder | -| Script `execute()` | `Loading` | read anywhere; write only under `data_dir()` | -| Printer‑agent methods | `Loading` | read anywhere; write only under `data_dir()` | - -> Modes are chosen at each trampoline call site, so this table reflects the current source — -> always check the actual `ORCA_PY_OVERRIDE_AUDITED(...)` call when in doubt. - ---- - -## Limitations - -This version is deliberately minimal. Do **not** treat it as a hardened sandbox. Known gaps: - -- **Only the `open` event is enforced.** `subprocess.Popen`, `os.system`, `socket.*`, - `ctypes.*` and friends are *not* blocked. (The `Enforcing` enum comment describes an - aspiration, not current behavior.) -- **Non‑string paths slip through the `open` check.** The audit callback parses only a - string path (`"s|si"`); any `open`‑event call whose first argument is bytes or an integer - file descriptor — including `os.open`, which additionally passes `mode = None` — fails the - parse and is allowed. Low‑level and non‑`str` opens are currently unaudited. -- **`open(path, "x")`** (exclusive create — a write) contains no `w`/`a`/`+`, so it is - classified as a read and allowed under `Loading`. -- **Non‑`open` filesystem mutations are unaudited.** `os.remove`, `os.rename`, `os.mkdir`, - `shutil.*` raise their own events, which we don't yet handle — a plugin can delete or - rename files outside `data_dir()` without tripping anything. -- Enforcement is **per process / per thread** via thread‑locals; code that hops threads - without re‑establishing a context runs unaudited. - -Closing these gaps (especially the filesystem‑mutation events and `os.open` flags) is the -natural next step for anyone hardening this into a real write‑sandbox. - ---- - -## Debugging - -Enforcement only fires while a context is active, and the read/write distinction trips -people up, so when something is unexpectedly blocked (or unexpectedly allowed), get the -facts first. - -**Temporary block log.** `check_open` logs each block just before returning, including the -mode that was actually live: - -``` -[AUDIT] block path=/tmp open_mode=w audit_mode=Loading plugin=local:.../Environment_Report_Script_ -``` - -Read it field by field: - -- `open_mode=w` → it's a **write**. Under `Loading`, writes outside the allow‑list are - *supposed* to be blocked. A blocked `open_mode=r` under `audit_mode=Loading` is - impossible from current source — if you see it, your binary is stale (see below). -- `audit_mode=` → tells you whether the live call site is `Loading` or `Enforcing`, which - is the quickest way to confirm a trampoline change actually took effect. -- `path=` → the resolved path that failed the allow‑list. Compare against `data_dir()`. - -The permanent `report_violation` log (`[AUDIT BLOCKED] …`) fires on the same blocks and -includes the plugin key, event name, path, and reason. - -**Common pitfalls** - -- **Read vs write.** `Loading` never blocks a read. If a "read" is blocked, it's actually a - write (check `open_mode`), or the mode is `Enforcing`. -- **Stale / incremental builds.** `PyPluginTrampoline.hpp` and `PluginAuditManager.hpp` are - included by many translation units. A header‑only change (e.g. flipping a trampoline's - mode) may not propagate with an incremental build. If runtime behavior contradicts the - source, do a clean rebuild of the affected targets. `PluginAuditManager.cpp` changes are - a single‑TU recompile + relink. -- **No context = no enforcement.** If a plugin's calls are never audited, check that its - instance got `set_audit_plugin_key()` (non‑empty key) and that the method actually wraps - through `ORCA_PY_OVERRIDE_AUDITED`. - ---- - -## Key files - -| File | Responsibility | -|---|---| -| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | modes, allowed roots, `audit_hook`, `check_open`, `ScopedPluginAuditContext` | -| `src/slic3r/plugin/PyPluginTrampoline.hpp` | the `ORCA_PY_*` macros (logging + audit context) | -| `src/slic3r/plugin/PythonPluginInterface.hpp` | the per‑instance audit identity | -| `src/slic3r/plugin/PluginLoader.cpp` | stamps the audit key at load / key migration | -| `src/slic3r/plugin/pluginTypes/*/*Trampoline.hpp` | per‑plugin‑type methods and their chosen modes | -| `src/slic3r/plugin/PythonInterpreter.cpp` | installs the hook once at interpreter init | diff --git a/docs/plugins/plugin_development.md b/docs/plugins/plugin_development.md deleted file mode 100644 index 37de4da6ba..0000000000 --- a/docs/plugins/plugin_development.md +++ /dev/null @@ -1,1016 +0,0 @@ -# Plugin Development - -OrcaSlicer can be extended with **Python plugins** that run inside an embedded CPython -interpreter, without recompiling the application. This document is for two audiences: - -1. **Plugin authors** — writing a new Python plugin, modifying an existing one, and - debugging it during development. -2. **OrcaSlicer contributors** — adding a brand‑new *plugin type* in C++ (a new contract - that Python plugins can implement and that the app invokes at some point in its - workflow). - -Two companion documents go deeper on adjacent topics; read them alongside this one: - -- [`plugin_audit_hook.md`](plugin_audit_hook.md) — the CPython audit hook that restricts - what plugin code may do (today: a filesystem write allow‑list). **Anyone adding a new - trampoline method must read it** — every C++→Python call must choose an audit mode. -- [`plugin_system.md`](plugin_system.md) — the catalog/loader/cloud‑subscription side of the - system (discovery, install, update). - -> **All file paths below are under `src/slic3r/plugin/`** unless stated otherwise. - ---- - -## Part 1 — Python Plugin Development - -### Where plugins live and how they are discovered - -Plugins are loaded from two roots under the OrcaSlicer data directory (`data_dir()`): - -| Root | Purpose | -|---|---| -| `data_dir()/orca_plugins/` | locally installed / side‑loaded plugins | -| `data_dir()/orca_plugins/_subscribed//` | cloud‑subscribed plugins | - -Each plugin lives in **its own subdirectory** containing exactly one entry file — either a -single `.py` file or a single `.whl` (wheel). Subdirectories whose name starts with `.` or -`__` are ignored. Discovery is driven by `PluginCatalog` (scan) and `PluginLoader` (load); -see `PluginCatalog.cpp` and `PluginLoader.cpp` (`find_installed_plugin_entry` in -`PythonFileUtils.cpp` decides which file in a folder is the entry point). - -> There are **no bundled example plugins in the repository.** The plugin snippets in this -> document are illustrative — they were written against the real bindings below, but are -> not copied from a shipped, verified plugin. Treat them as starting points and test them. - -### Anatomy of a plugin - -A plugin is packaged in **one of two forms**, and the entry file in its folder is what -distinguishes them: - -- **A single `.py` file** — the simplest form, covered first below. Metadata lives in a - PEP 723 comment block at the top of the file. -- **A wheel (`.whl`)** — a normal built Python package, for plugins that need multiple - modules or compiled code. Metadata comes from the wheel's own files instead of a PEP 723 - block. See [Wheel (`.whl`) plugins](#wheel-whl-plugins) below. - -**One plugin, many capabilities.** A plugin is a *package* that registers one or more -**capabilities**. Each capability is a single typed unit of functionality — a script you can -run, a G‑code post‑processor, a printer agent — with its own display name. A plugin's *types* -are derived from the capabilities it registers (they are descriptive tags, not a single fixed -role), so one plugin can, for example, offer both a script capability and a post‑processing -capability at once. - -**Both forms register the same way**: OrcaSlicer imports your code, instantiates the one -**package class** you marked with `@orca.plugin`, and calls its `register_capabilities()` -method to collect the capabilities it offers. Only the packaging and the metadata source -differ. - -A single‑file (`.py`) plugin has three parts: - -1. A **PEP 723 inline metadata block** (a special comment header) declaring identity and - dependencies. -2. One or more **capability classes**, each subclassing a typed base exposed by the embedded - `orca` module (a script, G‑code/post‑processing, or printer‑agent capability) and - implementing `get_name()` plus its entry method. -3. A **package class** decorated with `@orca.plugin` (subclassing `orca.base`) whose - `register_capabilities()` method calls `orca.register_capability(...)` once per capability. - -#### 1. The metadata block (PEP 723) - -OrcaSlicer reads identity and dependency metadata from a PEP 723 *inline script metadata* -block — a comment block delimited by `# /// script` and `# ///`, with each content line -prefixed by `# `. Identity fields live in a `[tool.orcaslicer.plugin]` table; dependency -fields live at the TOML root. Parsing is implemented in -`PythonFileUtils.cpp::parse_pep723_toml` / `read_python_plugin_metadata`. - -```python -# /// script -# requires-python = ">=3.12" -# dependencies = [] -# -# [tool.orcaslicer.plugin] -# name = "Sample Plugin" -# description = "Appends a short build-environment note to the exported G-code." -# author = "Your Name" -# version = "1.0.0" -# /// -``` - -| Field | Location | Required | Notes | -|---|---|---|---| -| `name` | `[tool.orcaslicer.plugin]` | recommended | display name in the Plugins dialog | -| `description` | `[tool.orcaslicer.plugin]` | recommended | shown in the plugin **Description** tab | -| `author` | `[tool.orcaslicer.plugin]` | optional | | -| `version` | `[tool.orcaslicer.plugin]` | recommended | | -| `dependencies` | TOML root | optional | array of pip requirements (see [Dependencies](#dependencies)) | -| `requires-python` | TOML root | optional | **read but not stored or enforced** against the bundled interpreter today | - -> **The metadata block no longer declares a `type`.** A plugin's type(s) are derived from the -> capability classes it registers (each capability's `get_type()`), so the same metadata block -> is used whether the plugin offers one capability or several. The PEP 723 parser -> (`parse_pep723_toml`) reads only `name`, `description`, `author`, `version`, -> `requires-python`, and `dependencies`; any other key (including a stray `type = …`) is -> ignored. The same applies to `.whl` plugins — identity comes from the wheel's `METADATA`, -> and the served types come from the registered capabilities. - -#### 2. The `orca` module — the plugin API - -The interpreter exposes a single embedded module named **`orca`** -(`PYBIND11_EMBEDDED_MODULE(orca, ...)` in `PythonPluginBridge.cpp`). It provides the capability -base classes, the package base, and capability registration, plus the **`orca.host`** submodule -for read-only access to the live slicer model graph, presets, and mesh geometry (see -[The `orca.host` module](#the-orcahost-module--read-only-host-access)). It contains: - -| Symbol | Kind | Members / purpose | -|---|---|---| -| `orca.PluginType` | enum | `PostProcessing`, `PrinterConnection`, `Automation`, `Analysis`, `Importer`, `Exporter`, `Visualization`, `Script`, `Unknown` | -| `orca.PluginResult` | enum | `Success`, `Skipped`, `RecoverableError`, `FatalError` | -| `orca.PluginContext` | class | base context, field `orca_version: str` | -| `orca.ExecutionResult` | class | fields `status`, `message`, `data`; factories below | -| `orca.PythonPluginBase` | class | the root **capability** base; subclasses must implement `get_name()` | -| `orca.base` | class | the **package** base; subclass it and override `register_capabilities()` | -| `orca.plugin` | decorator | marks the single package class for the file (exactly one per file) | -| `orca.register_capability(cls)` | function | register one capability class; call it inside `register_capabilities()` | -| `orca.gcode` | submodule | `GCodePluginContext`, `GCodePluginCapabilityBase` | -| `orca.script` | submodule | `ScriptPluginCapabilityBase` | -| `orca.printer_agent` | submodule | `PrinterAgentBase` and its data types | -| `orca.host` | submodule | read-only host access: live `Model` graph, presets/bundle, and zero-copy mesh geometry | - -`ExecutionResult` is how a plugin reports the outcome of a run: - -```python -orca.ExecutionResult.success(message="", data="") -orca.ExecutionResult.skipped(message="") -orca.ExecutionResult.failure(status, message, data="") # status is an orca.PluginResult -``` - -- `status` — an `orca.PluginResult`. -- `message` — human‑readable text; this is what surfaces in error/result dialogs. -- `data` — a free‑form string whose meaning is defined by the plugin/workflow (not - interpreted by the framework). - -#### The `orca.host` module — read-only host access - -`orca.host` (bound in `PluginHostApi.cpp`) gives plugins **read-only** access to the running -slicer. It is intended for analysis, reporting, and export plugins; nothing here mutates the -model. **Script plugins run on the main/UI thread**, so within one `execute()` the model cannot -change under you; **G-code/post-processing and printer-agent plugins run on a background thread** -while the GUI keeps running. Either way, treat everything as a momentary snapshot and do not stash -references across runs. - -**Entry points** (each raises `RuntimeError` if called before the GUI/model is ready): - -```python -import orca -model = orca.host.model() # the active Model -plater = orca.host.plater() # the Plater -bundle = orca.host.preset_bundle() # presets (prints/printers/filaments/...) -``` - -**Model graph:** `Model.objects()` → `ModelObject`; each object has `volumes()`/`volume(i)` -(→ `ModelVolume`) and `instances()`/`instance(i)` (→ `ModelInstance`). Bounding boxes are a -`host.BoundingBox` value type (`min`/`max`/`size`/`center` as `(x, y, z)` mm tuples, plus -`radius`/`defined`). - -**Mesh geometry — `ModelVolume.mesh()` → `host.TriangleMesh`:** - -| Member | Returns | Notes | -|---|---|---| -| `vertex_count()` / `triangle_count()` (`facets_count()`) / `is_empty()` | `int` / `bool` | numpy-free | -| `vertex(i)` / `triangle(i)` | `(x, y, z)` / `(a, b, c)` tuple | numpy-free, bounds-checked | -| `vertices()` | `(N, 3)` float32 ndarray | **read-only, zero-copy**, requires numpy | -| `triangles()` | `(M, 3)` int32 ndarray | vertex indices; **read-only, zero-copy**, requires numpy | -| `face_normals()` | `(M, 3)` float32 ndarray | computed copy, requires numpy | -| `volume()` / `bounding_box()` / `is_manifold()` | `float` / `BoundingBox` / `bool` | numpy-free | - -Coordinates are **local** (the volume's own frame, in mm). The `vertices()`/`triangles()` -arrays are zero-copy views into the live mesh and are marked read-only — writing to them raises -`ValueError`. Their lifetime is pinned to an immutable mesh snapshot, so they stay valid even if -the volume's mesh is later replaced. - -**Worked example** (declare numpy in the PEP 723 block so the bundled `uv` installs it): - -```python -# /// script -# dependencies = ["numpy"] -# /// -import orca, numpy as np - -class MeshReport(orca.script.ScriptPluginCapabilityBase): - def get_name(self): return "Mesh Report" - def execute(self): - model = orca.host.model() - for obj in model.objects(): - for vol in obj.volumes(): - mesh = vol.mesh() - V = np.asarray(mesh.vertices()) # (N, 3) float32, read-only - T = np.asarray(mesh.triangles()) # (M, 3) int32 - # World-space coordinates for the first instance (row-vector convention): - M = obj.instance(0).matrix() @ vol.matrix() # 4x4 float64 - world = (np.c_[V.astype(np.float64), np.ones(len(V))] @ M.T)[:, :3] - print(vol.name, V.shape, T.shape, world.min(0), world.max(0)) - return orca.ExecutionResult.success() - -@orca.plugin -class MeshReportPlugin(orca.base): - def register_capabilities(self): - orca.register_capability(MeshReport) -``` - -> If the instance is mirrored (`instance.is_left_handed()` is `True`, i.e. `det(M) < 0`), flip -> triangle winding / negate face normals when computing outward-facing normals in world space. - -**numpy requirement:** `vertices()`, `triangles()`, `face_normals()`, and the `matrix()` -accessors on `ModelVolume`/`ModelInstance` require numpy and raise a clear `ImportError` if it -is not installed (declare `dependencies = ["numpy"]`). Everything else — counts, `vertex(i)`/ -`triangle(i)`, `volume()`, `bounding_box()`, `is_manifold()`, and the `offset`/`rotation`/ -`scaling_factor`/`mirror` tuple accessors — works without numpy. - -#### The `orca.host.ui` module — dialogs and interactive windows - -`orca.host.ui` lets a plugin show host‑owned UI: a native message box, a native progress -dialog, a modal HTML dialog, and non‑modal interactive windows. **A plugin must never import -its own GUI toolkit** -(PyQt/wxPython/tkinter): a `script` plugin shares the host's UI thread, so a second toolkit's -event loop would clash with wxWidgets, and a `gcode`/`printer-agent` plugin runs off the main -thread where toolkit calls would crash. These host calls run on the main thread for you and -block the calling code until they return. - -```python -# Native message box -> returns "ok" | "cancel" | "yes" | "no" -choice = orca.host.ui.message("Export finished. Open the folder?", - title="My Plugin", buttons="yes_no", icon="question") - -# Modal HTML dialog -> returns the orca.submit() payload (dict), or None if dismissed -result = orca.host.ui.show_dialog(html="

Hello

...", title="Report", - width=820, height=600) - -# Non-modal, persistent, interactive window -> returns a UiWindow handle -win = orca.host.ui.create_window(html=PAGE, title="Panel", - on_message=self.on_message, on_close=self.on_close) -win.post({"type": "data", "rows": [...]}) # push a payload to the page -win.is_open() # bool -win.close() -``` - -`message` arguments: `buttons` is `"ok"|"ok_cancel"|"yes_no"|"yes_no_cancel"`; `icon` is -`"info"|"warning"|"error"|"question"`. - -**Progress dialogs:** - -Use `create_progress_dialog()` for host-owned native progress. It returns a -`ProgressDialog` handle and also works as a context manager, so `close()` is called on exit. -The default style is `PD_APP_MODAL | PD_AUTO_HIDE`; add `PD_CAN_ABORT` if the user should be -able to cancel. `maximum` defaults to `100` (values `<= 0` are treated as `100`). - -For script plugins, put the dialog inside `execute(self)` and update it between chunks of -work. Do not create the dialog and then run one long uninterrupted operation such as a single -`time.sleep(...)` or blocking network call; the dialog only gets useful repaint/cancel -checkpoints when you call `update()` or `pulse()`. - -```python -style = (orca.host.ui.PD_APP_MODAL | - orca.host.ui.PD_AUTO_HIDE | - orca.host.ui.PD_CAN_ABORT | - orca.host.ui.PD_ELAPSED_TIME | - orca.host.ui.PD_REMAINING_TIME) - -with orca.host.ui.create_progress_dialog("My Plugin", - "Preparing...", - maximum=len(items), - style=style) as progress: - for index, item in enumerate(items, start=1): - process(item) - - # update() returns False if the dialog was closed or cancelled. - if not progress.update(index, f"Processed {index}/{len(items)}"): - return orca.ExecutionResult.skipped("Cancelled by user") -``` - -For indeterminate work, pulse the dialog instead of setting a numeric value: - -```python -with orca.host.ui.create_progress_dialog("My Plugin", - "Waiting for printer...", - style=orca.host.ui.PD_APP_MODAL | - orca.host.ui.PD_CAN_ABORT) as progress: - while not finished(): - if not progress.pulse("Waiting for printer..."): - return orca.ExecutionResult.skipped("Cancelled by user") - wait_for_next_poll() -``` - -Handle methods: - -| Python call | Effect | -|---|---| -| `progress.update(value, message="")` | set the determinate progress value; returns `False` if closed/cancelled | -| `progress.pulse(message="")` | advance an indeterminate progress step; returns `False` if closed/cancelled | -| `progress.start_pulse(interval_ms=100, message="")` | start timer-driven pulsing on the UI thread | -| `progress.stop_pulse()` | stop timer-driven pulsing | -| `progress.close()` | close the dialog | -| `progress.is_open()` | return whether the host still has the dialog registered | - -Because `start_pulse()` has no return value, use explicit `update()` or `pulse()` calls at -natural cancellation points if the dialog includes `PD_CAN_ABORT`. - -The style constants exposed by `orca.host.ui` mirror `wxProgressDialog`: `PD_APP_MODAL`, -`PD_AUTO_HIDE`, `PD_CAN_ABORT`, `PD_CAN_SKIP`, `PD_ELAPSED_TIME`, `PD_ESTIMATED_TIME`, and -`PD_REMAINING_TIME`. `PD_CAN_SKIP` is available for style parity, but the current Python -handle does not expose a separate "skip" state. - -**The page talks back through `window.orca`** (injected automatically; the page supplies raw, -self‑contained HTML/CSS/JS): - -| JS call | Effect | -|---|---| -| `orca.postMessage(obj)` | deliver `obj` to the plugin's `on_message(obj)` | -| `orca.onMessage(cb)` | `cb(data)` runs for each `win.post(data)` (and modal pushes) | -| `orca.submit(obj)` | (modal) close and return `obj` from `show_dialog` | -| `orca.close()` | close the dialog / window | - -**Theming (automatic light/dark):** - -The host injects a stylesheet that matches OrcaSlicer's **current theme** (the active -light/dark mode, fonts, background/foreground, accent and border colors) *before* your page -renders. An unstyled page already looks native — ``, headings, `button`, -`input`/`select`/`textarea`, `table`, links and scrollbars get sensible themed defaults — and -the theme is also exposed as CSS variables so you can match the rest of the UI: - -| Variable | Meaning | -|---|---| -| `--orca-bg` | window/background color | -| `--orca-fg` | primary text color | -| `--orca-muted` | secondary / label text color | -| `--orca-accent` | accent color (buttons, links, focus) | -| `--orca-accent-fg` | text color on the accent | -| `--orca-border` | subtle border / separator / row‑hover color | -| `--orca-font` | UI font stack | - -The injected rules use only low specificity and never `!important`, so **any CSS your page -ships overrides them**. Prefer the variables (e.g. `border:1px solid var(--orca-border)`) over -hardcoded colors so your dialog follows light *and* dark mode automatically. The UI sample -([`host_ui_panel.py`](examples/host_ui_panel.py)) relies on this and uses no fixed colors. - -**Threading & lifecycle:** - -- Host UI calls run on the main thread and **block the calling code** until they return - (`message`/`show_dialog` when the dialog closes; `create_window`/`create_progress_dialog` - as soon as the window/dialog is shown; progress updates after the host applies them). From - a `script` plugin — already on the UI thread — they run inline; from a background-thread - plugin (`gcode`/`printer-agent`) they marshal to the main thread first. -- `on_message(data)` runs on the **UI thread** — keep it quick; offload heavy work to a - `threading.Thread` and push results back with `win.post(...)`. -- A **modal** dialog (`show_dialog`) fits a one‑shot `execute()`. A **persistent** panel - (`create_window`) is best opened from `on_load()` so it lives for the plugin's lifetime; the - host closes a plugin's windows automatically when it is unloaded/reloaded or the app exits. -- Content is loaded as raw HTML — prefer **self‑contained** pages (inline CSS/JS). There is no - CSP and developer tools are disabled. - -See [`examples/host_ui_panel.py`](examples/host_ui_panel.py) for a non‑modal interactive panel -that browses the whole `orca.host` read-only API. - -#### 3. Registration - -Registration has two parts, both resolved at **module import / load time**. - -**Capabilities** — each capability is a class that subclasses a typed base (see -[Capability types and entry points](#capability-types-and-entry-points)) and implements -`get_name(self) -> str`. The name is how the capability appears in the UI and how presets -refer to it, so it must be **unique within the plugin** and **must not contain a `;`** — that -character is reserved as a separator in preset references, and a `;` in a capability name -fails the load. - -**The package** — exactly one class per file is decorated with `@orca.plugin` and subclasses -`orca.base`. Its `register_capabilities(self)` method calls `orca.register_capability(Cls)` -once for each capability class you want to expose: - -```python -@orca.plugin -class SamplePlugin(orca.base): - def register_capabilities(self): - orca.register_capability(GCodeBenchmark) - orca.register_capability(EnvironmentReport) -``` - -OrcaSlicer instantiates the package class (it must be callable as `SamplePlugin()` with no -arguments), calls `register_capabilities()`, then instantiates each registered capability. - -Rules enforced when a plugin loads (most in `PythonPluginBridge.cpp`): - -- The `@orca.plugin` class **must** subclass `orca.base`, and there must be **exactly one** - per file — a second `@orca.plugin` fails the load. -- Each class passed to `orca.register_capability` must subclass a capability base (ultimately - `orca.PythonPluginBase`); otherwise it raises `value_error`. -- Every capability must resolve `get_name()` (checked in the bridge); the loader - (`PluginLoader.cpp`) additionally rejects the plugin if the resulting `(type, name)` pair is - not unique across it. -- A capability class you never pass to `register_capability` is **invisible** to OrcaSlicer, - even if it is defined in the file. - -#### Wheel (`.whl`) plugins - -For anything beyond a single file — multiple modules, packaged resources, or compiled -extensions — ship a standard Python **wheel** as the plugin folder's entry file. The plugin -*code* is identical to the `.py` case: somewhere in the importable package's top‑level code -(typically its `__init__.py`) you define your capability classes and the `@orca.plugin` -package class that registers them. What changes is **where metadata comes from** and that the -wheel is validated on install (`read_wheel_plugin_metadata` in `PythonFileUtils.cpp`). - -Identity and dependencies are read from the wheel's `*.dist-info/` files instead of a -PEP 723 block: - -| Plugin field | Wheel source | -|---|---| -| `name` | `METADATA` → `Name` (**required**) | -| `version` | `METADATA` → `Version` (**required**) | -| `description` | `METADATA` → `Summary` | -| `author` | `METADATA` → `Author` | -| `dependencies` | `METADATA` → `Requires-Dist` | - -As with `.py` plugins, the wheel does **not** declare a type — the plugin's served types come -from the capabilities its `@orca.plugin` package class registers at load time. - -Additional wheel rules enforced at install time: - -- The wheel must contain exactly **one `.dist-info` directory** with `METADATA`, `WHEEL`, - and `RECORD` present. -- **Platform/ABI compatibility is checked** from the `WHEEL` file's `Tag:` lines. Pure - Python wheels (`*-none-any`) are accepted everywhere; platform‑specific wheels must match - the current interpreter's ABI tag and OS (see `PythonInterpreter::python_abi_tag()`). - Ship a pure‑Python wheel unless you genuinely need a compiled extension. -- The importable entry package is chosen in priority order: core‑metadata `Import-Name`, - then `top_level.txt` (if it names a single package), then the normalized `Name`. - -### Capability types and entry points - -Each typed base defines the method(s) OrcaSlicer will call and the type returned by -`get_type()`. Every capability **must** implement `get_name(self) -> str`. Lifecycle hooks -`on_load()` / `on_unload()` are optional and available on every capability (defaults do -nothing). - -| Base class | `get_type()` returns | Required methods | Invoked by | -|---|---|---|---| -| `orca.script.ScriptPluginCapabilityBase` | `Script` | `get_name()`, `execute(self) -> ExecutionResult` | the **Plugins dialog → Run** action | -| `orca.gcode.GCodePluginCapabilityBase` | `PostProcessing` | `get_name()`, `execute(self, ctx) -> ExecutionResult` | **G‑code export / post‑processing** during slicing | -| `orca.printer_agent.PrinterAgentBase` | `PrinterConnection` | `get_name()` + ~30 agent methods (`get_agent_info`, `connect_printer`, …) | the **network / printer‑agent** layer on load | - -> **`get_name()` is required; `get_type()` usually isn't.** Every capability must implement -> `get_name()` — it is pure virtual on the root base, and a missing override fails the load. -> The typed C++ bases already implement `get_type()` (e.g. `ScriptPluginCapability::get_type()` -> returns `Script`), so a subclass of a *typed* base does **not** need to override it. Only a -> capability that subclasses the **root** `orca.PythonPluginBase` directly must set its own -> `get_type()`. - -> **Threading.** `ScriptPluginCapabilityBase.execute()` runs on the **main/UI thread**: live -> host handles are safe to read for the whole call and `orca.host.ui` dialogs open inline, but a -> slow `execute()` **freezes the UI**. Keep it quick — offload heavy work to your own -> `threading.Thread` (which must not touch the model) and surface results through a -> `create_window` panel. `GCodePluginCapabilityBase` / `PrinterAgentBase` instead run on -> background (slicing / network) threads. - -The G‑code context (`orca.gcode.GCodePluginContext`) is passed to `execute` and exposes -read/write fields: - -| Field | Meaning | -|---|---| -| `orca_version` | OrcaSlicer version string (inherited from `PluginContext`) | -| `gcode_path` | absolute path to the temporary G‑code file being post‑processed | -| `host` | target host, when exporting to a network printer | -| `output_name` | the output file name | - -> **Filesystem access is audited.** While `execute()` runs, the audit hook restricts -> writes to an allow‑list. G‑code plugins additionally get the folder containing -> `gcode_path` added as a scoped writable root, so appending to / rewriting the current -> G‑code file is allowed; writing elsewhere outside `data_dir()` is blocked. See -> [`plugin_audit_hook.md`](plugin_audit_hook.md). - -### Complete examples - -**Minimal script plugin** — one capability, runs from the Plugins dialog, no context: - -```python -# /// script -# [tool.orcaslicer.plugin] -# name = "Hello Script" -# description = "Smallest possible script plugin." -# author = "Your Name" -# version = "1.0.0" -# /// -import orca - - -class HelloScript(orca.script.ScriptPluginCapabilityBase): - def get_name(self): - return "Hello Script" - - def on_load(self): - # Optional: runs once when the capability is loaded. - pass - - def execute(self): - return orca.ExecutionResult.success("Hello from a script plugin") - - -@orca.plugin -class HelloPlugin(orca.base): - def register_capabilities(self): - orca.register_capability(HelloScript) -``` - -**Multi-capability plugin** — one package that exposes a post‑processing capability *and* a -script capability: - -```python -# /// script -# [tool.orcaslicer.plugin] -# name = "Sample Plugin" -# description = "Demonstrates registering several capabilities from one plugin." -# author = "Your Name" -# version = "1.0.0" -# /// -import orca - - -class EnvironmentReport(orca.gcode.GCodePluginCapabilityBase): - def get_name(self): - return "Environment Report" - - def execute(self, ctx): - # ctx.gcode_path / ctx.output_name / ctx.host / ctx.orca_version are available. - # Writing to the current G-code file's folder is permitted by the audit hook. - try: - with open(ctx.gcode_path, "a", encoding="utf-8") as f: - f.write(f"\n; processed by Environment Report for {ctx.output_name}\n") - except Exception as exc: - return orca.ExecutionResult.failure( - orca.PluginResult.RecoverableError, - f"could not append report: {exc}") - return orca.ExecutionResult.success("report appended") - - -class GCodeBenchmark(orca.script.ScriptPluginCapabilityBase): - def get_name(self): - return "G-code Benchmark" - - def execute(self): - return orca.ExecutionResult.success("benchmark complete") - - -@orca.plugin -class SamplePlugin(orca.base): - def register_capabilities(self): - orca.register_capability(EnvironmentReport) - orca.register_capability(GCodeBenchmark) -``` - -For a copy‑pasteable starter that registers a script, a post‑processing, and a printer‑agent -capability in one package, see -[`examples/multi_capability_skeleton.py`](examples/multi_capability_skeleton.py). - -> **Capability names and presets.** When a capability is chosen for a setting (for example a -> post‑processing capability), its `get_name()` is what the preset stores. The full reference -> saved alongside it is `;;` — which is why a -> capability name may not contain `;`. See -> [Plugin references in presets](plugin_system.md#plugin-references-in-presets) for how this -> is used to restore missing plugins. - -### Dependencies - -List third‑party requirements in the PEP 723 root `dependencies` array. On install, -OrcaSlicer resolves them with a bundled `uv` into the plugin's environment -(`PluginLoader.cpp`): - -```python -# dependencies = ["requests==2.32.3", "humanize"] -``` - -Keep dependencies minimal — every dependency is code that runs under the same audit policy -as your plugin and must be fetched at install time. - -### Modifying an existing plugin - -1. Locate its folder under `data_dir()/orca_plugins//` (or, for subscribed plugins, - under `.../_subscribed//`). -2. Edit the `.py` entry file. If you change the metadata block, bump `version` so the change - is visible in the Plugins dialog. -3. Reload (see the iteration workflow below). Note that plugin instances are captured at - load time — a running OrcaSlicer will not pick up source edits until the plugin is - reloaded or the app is restarted. - -> Editing a `.whl` plugin in place is not supported — rebuild and reinstall the wheel. - -### How errors are surfaced - -There are **three distinct error surfaces**. Knowing which one you are looking at tells you -what kind of failure occurred. - -**1. A message box** — a *runtime* failure of an explicit run. You get one when: - -- your `execute()` **raised an exception** — it is caught at the C++→Python trampoline - boundary, the full traceback is logged, and the exception is rethrown and shown; or -- your `execute()` **returned a failure** (`ExecutionResult.failure(...)`, i.e. status - `RecoverableError` / `FatalError`). - -For **script** plugins the dialog title is *“Script Plugin Failed”* (or *“Script Plugin”* -for a returned failure / success), and the body text is the exception message or your -`ExecutionResult.message`. For **post‑processing** plugins the failure is raised as a -slicing error (`"Post-processing plugin failed/raised…"`) and surfaces through the -normal slicing‑error path. Source: `PluginsDialog.cpp` (`run_script_plugin`, -`complete_with_error`) and `PostProcessor.cpp`. - -**2. The plugin details / description area** in the Plugins dialog — a *persistent* error -state stored on the plugin descriptor, not a single run. When a plugin fails to load or has -invalid metadata, the descriptor records an error (`set_error` / `normalized_error` in -`PluginDescriptor.hpp`); for a metadata‑invalid plugin **the error text replaces the -description** shown in the dialog (`PluginsDialog.cpp`). This reflects *state* — “this -plugin is currently broken” — rather than the result of one execution. - -**3. The Python log file** — the full traceback. `sys.stderr` is teed to: - -``` -data_dir()/log/python_______.log -``` - -(`install_python_stderr_redirect` in `PythonInterpreter.cpp`.) This is the **only** place -errors from background threads your plugin spawns will appear — those never cross back to -C++ and never produce a dialog. C++‑side context (load/discovery messages) goes to the main -session log via Boost. - -**How to act on each:** - -- **Message box** → read the message line, then open the `python_*.log` for the file/line - of the traceback. Dialogs show only the message, not the stack. -- **Details‑area / Diagnostics error** → the plugin didn’t load; usually a registration - problem (a capability that doesn’t subclass a typed base, a missing `get_name()`, a - duplicate capability name, or no `@orca.plugin` package class) or an import error. Fix it, - then reload. -- **Anything blocked with a `PermissionError` about a file path** → the audit hook blocked a - write/read outside the allow‑list. See the *Debugging* section of - [`plugin_audit_hook.md`](plugin_audit_hook.md) and the `[AUDIT BLOCKED]` log line. - -**Prefer returning a result over raising** for failures you anticipate: -`ExecutionResult.failure(orca.PluginResult.RecoverableError, "clear user-facing reason")` -gives the user a clean message. Raise for genuine bugs — you’ll get a full traceback in the -log to debug from. - -### Testing and iterating during development - -A practical loop: - -1. **Edit** the plugin source in its `orca_plugins//` folder. -2. **Reload** — reopen the Plugins dialog / re‑trigger discovery, or restart OrcaSlicer if - in doubt (instances are captured at load time). -3. **Run** — for a script plugin use the Plugins dialog **Run** action; for a - post‑processing plugin run a slice/export so the G‑code pipeline invokes it. -4. **Watch the log** — keep `data_dir()/log/python_*.log` open (e.g. `tail -f`). Tracebacks, - `print()` output, and audit blocks all land there. -5. **Iterate.** Use `ExecutionResult` messages for expected outcomes; rely on the log for - stack traces. - -Tips: - -- Confirm the plugin shows the right name and version in the Plugins dialog, and that **each - capability you registered** appears (with the expected type) in its expandable capability - list. A capability that is never passed to `orca.register_capability` will not appear. -- Develop against small, fast inputs; for post‑processing plugins keep a tiny test model so - each export cycle is quick. -- Remember the audit allow‑list: write only under `data_dir()` (or, for G‑code plugins, the - current G‑code folder). A surprise `PermissionError` is almost always this. - ---- - -## Part 2 — Adding a New Plugin Type in C++ - -This part is for OrcaSlicer contributors extending the plugin *framework* with a new -contract — say an “importer” capability type. The system has no per‑type registry/switch for -*instantiation*: a capability's Python class subclasses a typed base, the package's -`register_capabilities()` registers it via `register_capability`, and the rest of the app -reaches the loaded capability instance by `std::dynamic_pointer_cast` at the -call site. So adding a type means: define a base + context + result, add a trampoline that -forwards into Python (with an audit mode), register pybind11 bindings, wire one call site, -and add the files to the build. - -Use the existing `gcode`, `script`, and `printerAgent` types under -`src/slic3r/plugin/pluginTypes/` as references — `script` is the simplest, `gcode` shows a -context + scoped audit root, `printerAgent` shows a wide multi‑method interface. - -### Step 1 — Define the plugin contract (the base class) - -Create `pluginTypes//PluginCapability.hpp`. Subclass `PluginCapabilityInterface`, hardcode -`get_type()` to your `PluginCapabilityType`, declare your pure‑virtual entry method(s) and any -context struct, and declare a static `RegisterBindings`. The G‑code base -(`pluginTypes/gcode/GCodePluginCapability.hpp`) is the canonical small example: - -```cpp -#ifndef slic3r_GCodePluginCapability_hpp_ -#define slic3r_GCodePluginCapability_hpp_ - -#include "../../PythonPluginInterface.hpp" - -namespace Slic3r { - -struct GCodePluginContext : public PluginContext { - std::string gcode_path; - std::string host; - std::string output_name; -}; - -class GCodePluginCapability : public PluginCapabilityInterface -{ -public: - PluginCapabilityType get_type() const override { return PluginCapabilityType::PostProcessing; } - - virtual ExecutionResult execute(const GCodePluginContext& ctx) = 0; - - static void RegisterBindings(pybind11::module_ &module, - pybind11::enum_ &pluginTypes); -}; - -} // namespace Slic3r - -#endif /* slic3r_GCodePluginCapability_hpp_ */ -``` - -The shared building blocks come from `PythonPluginInterface.hpp`: - -- `PluginCapabilityInterface` — `virtual std::string get_name() const = 0` (the capability's - name, provided by Python), `virtual PluginCapabilityType get_type() const` (defaults to - `Unknown`; typed bases override it), virtual `on_load()` / `on_unload()`, plus the C++‑only - audit identity (`set_audit_plugin_key`). -- `struct PluginContext { std::string orca_version; }` — derive your context from this. -- `struct ExecutionResult { PluginResult status; std::string message, data; }` with static - `success` / `skipped` / `failure`. -- `enum class PluginCapabilityType { … }` and the `plugin_capability_type_to_string` / - `plugin_capability_type_from_string` / `plugin_capability_type_display_name` maps. - -If your type needs a new `PluginCapabilityType` value, **add it to the enum and to all three -maps** in `PythonPluginInterface.hpp`, choosing the string the maps translate. Reuse an -existing value (e.g. `Automation`) if it fits. - -### Step 2 — Decide the API surface - -Decide exactly what the plugin must receive and return, and expose **only that**: - -- **Inputs** go in the context struct (mirror `GCodePluginContext`). Keep it to data the - plugin legitimately needs. -- **Outputs** should be an `ExecutionResult` (status + message + `data` string) unless your - type genuinely needs richer return data — in which case define and bind a small result - struct. -- **Keep it minimal and stable.** The bindings are an API surface plugins depend on; - removing or renaming a bound field/method breaks existing plugins. Add fields rather than - repurpose them, and prefer the smallest interface that does the job. -- **Avoid exposing internal slicer/GUI types.** The current API deliberately exposes only - plain data (strings, enums). Passing raw engine objects to plugins widens both the - compatibility and the security surface. - -### Step 3 — Add the trampoline (and choose an audit mode) - -Create `pluginTypes//PluginCapabilityTrampoline.hpp`. Subclass -`PyPluginCommonTrampoline` (which already provides the `get_name` and -`on_load`/`on_unload` trampolines) and forward each virtual into Python via -`ORCA_PY_OVERRIDE_AUDITED`. The G‑code trampoline -(`pluginTypes/gcode/GCodePluginCapabilityTrampoline.hpp`) in full: - -```cpp -#ifndef slic3r_GCodePluginCapabilityTrampoline_hpp_ -#define slic3r_GCodePluginCapabilityTrampoline_hpp_ - -#include - -#include "../../PyPluginTrampoline.hpp" -#include "../../PluginAuditManager.hpp" -#include "GCodePluginCapability.hpp" - -namespace Slic3r { -class PyGCodePluginCapabilityTrampoline : public PyPluginCommonTrampoline -{ -public: - using PyPluginCommonTrampoline::PyPluginCommonTrampoline; - - ExecutionResult execute(const GCodePluginContext& ctx) override - { - ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, - [&] { - // G-code post-processing plugins may also write into the folder holding the - // current temp G-code file, in addition to the globally-allowed data_dir(). - // The setup callback runs AFTER the context is constructed so the scoped root - // is not cleared by ScopedPluginAuditContext's constructor. - - if (!ctx.gcode_path.empty()) - ::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root( - std::filesystem::path(ctx.gcode_path).parent_path()); - }, - PYBIND11_OVERRIDE_PURE, ExecutionResult, GCodePluginCapability, execute, ctx); - } -}; -} // namespace Slic3r - -#endif -``` - -The macros (`PyPluginTrampoline.hpp`) do two jobs at this single boundary: log + rethrow the -Python traceback, and open the filesystem audit scope. - -``` -ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, /*args...*/) -``` - -| Argument | Meaning | -|---|---| -| `mode` | `AuditMode::Loading` (permissive reads, writes restricted to allow‑list) or `AuditMode::Enforcing` (reads also restricted) — see the audit doc | -| `audit_setup` | a lambda run *after* the audit context is opened; use it to `add_scoped_allowed_root(...)`. Pass `[] {}` if none | -| `override_macro` | pybind11’s own `PYBIND11_OVERRIDE` (has a C++ fallback) or `PYBIND11_OVERRIDE_PURE` (pure virtual, no fallback) | -| `ret, base, name, …` | the standard pybind11 override arguments | - -> **You must choose an audit mode for every new trampoline method.** Most lifecycle/entry -> calls use `Loading` (so the plugin can still import modules). Read -> [`plugin_audit_hook.md`](plugin_audit_hook.md) before picking `Enforcing`. - -### Step 4 — Register the Python bindings - -Implement `RegisterBindings` in `pluginTypes//PluginCapability.cpp`: create a submodule, -bind the context/result structs, and bind the base class with its trampoline. The G‑code -implementation (`pluginTypes/gcode/GCodePluginCapability.cpp`) in full: - -```cpp -void GCodePluginCapability::RegisterBindings(pybind11::module_& module, pybind11::enum_& pluginTypes) -{ - (void) pluginTypes; - - auto gcode = module.def_submodule("gcode", "G-code API"); - - py::class_(gcode, "GCodePluginContext", "Context shared with G-code plugins") - .def(py::init<>()) - .def_readwrite("gcode_path", &GCodePluginContext::gcode_path) - .def_readwrite("host", &GCodePluginContext::host) - .def_readwrite("output_name", &GCodePluginContext::output_name); - - py::class_>(gcode, "GCodePluginCapabilityBase") - .def(py::init<>()) - .def("get_type", &GCodePluginCapability::get_type) - .def("execute", &GCodePluginCapability::execute); -} -``` - -The base class is bound as `GCodePluginCapabilityBase` (the name plugin authors subclass) and -inherits `get_name` from the root `PythonPluginBase`, so you only bind the type‑specific -methods here. Then **call your `RegisterBindings` from `bind_python_api`** in -`PythonPluginBridge.cpp`, next to the existing ones (look for the -`// Make sure you register your bindings here` comment): - -```cpp -// Make sure you register your bindings here -GCodePluginCapability::RegisterBindings(m, pluginTypes); -PrinterAgentPluginCapability::RegisterBindings(m, pluginTypes); -ScriptPluginCapability::RegisterBindings(m, pluginTypes); -PluginHostApi::RegisterBindings(m); -// YourTypeCapability::RegisterBindings(m, pluginTypes); // <-- add this -``` - -The shared `PluginCapabilityType` / `PluginResult` / `PluginContext` / `ExecutionResult` / -`PythonPluginBase` bindings, the package base (`orca.base`), and the `@orca.plugin` / -`orca.register_capability` entry points are already defined once in that same function — you -only add your type‑specific submodule. - -### Step 5 — Add audit hooks - -Auditing is not optional. Each trampoline method you wrote in Step 3 already opts into a mode -through `ORCA_PY_OVERRIDE_AUDITED`. If your type needs a per‑call writable directory (as -G‑code does for the temp folder), grant it as a **scoped** root in the `audit_setup` lambda; -prefer scoped roots over widening the global allow‑list. If your type performs a sensitive -operation the current hook doesn’t yet police, consider extending the hook itself. All of -this is documented in [`plugin_audit_hook.md`](plugin_audit_hook.md) — read it before -finalizing the modes. - -### Step 6 — Hook the type into an OrcaSlicer workflow - -Nothing runs your plugin until some part of the app invokes it. Pick the invocation pattern -that matches your type and model it on an existing one: - -| Type | Where it’s invoked | Pattern | -|---|---|---| -| `gcode` | `PostProcessor.cpp` (G‑code export / post‑processing) | resolve the preset's capability refs, `dynamic_pointer_cast(cap->instance)`, build `GCodePluginContext`, call `execute(ctx)` under the GIL | -| `script` | `PluginsDialog.cpp` (Run action) | `get_plugin_capability_by_name(...)`, `dynamic_pointer_cast(cap->instance)`, call `execute()` | -| `printerAgent` | `NetworkAgentFactory.cpp`, wired in `GUI_App.cpp` | register via `subscribe_on_capability_load_callback` / `subscribe_on_capability_unload_callback`; the callback filters by `capability.type == PluginCapabilityType::PrinterConnection`, then registers/deregisters an agent | - -For your new type, add a call site (or an on‑capability‑load callback) that: - -1. obtains a loaded capability (via `PluginLoader::get_plugin_capabilities_by_type(...)` or - `get_plugin_capability_by_name(...)`) and does - `std::dynamic_pointer_cast(cap->instance)`; -2. on a successful cast, builds the context and invokes your entry method under the GIL; -3. if your type needs unload cleanup, add a case to the capability‑teardown switch (keyed on - `PluginCapabilityType`) in `PluginLoader.cpp`. - -> **Disabled / missing plugins must not change existing behavior.** Every existing path is -> gated on a successful `dynamic_pointer_cast` (or a `type ==` check) and iterates only over -> installed/selected plugins, so when none of your type is installed the loop or callback -> simply finds nothing and does nothing. Follow the same pattern — never run unconditional -> work on behalf of a plugin type that isn’t present. - -### Step 7 — Add the files to the build - -List your new `.hpp` / `.cpp` files in `src/slic3r/CMakeLists.txt`, alongside the existing -plugin‑type sources (search for `plugin/pluginTypes/gcode/GCodePluginCapability.cpp` — the block is -around lines 615–623): - -```cmake - plugin/pluginTypes//PluginCapability.hpp - plugin/pluginTypes//PluginCapability.cpp - plugin/pluginTypes//PluginCapabilityTrampoline.hpp -``` - -### Recipe at a glance - -1. **Enum/maps** (if new type): add a `PluginCapabilityType` value + the three string maps in - `PythonPluginInterface.hpp`. -2. **Contract**: `pluginTypes//PluginCapability.hpp` — base + context + result + static - `RegisterBindings`. -3. **Trampoline**: `pluginTypes//PluginCapabilityTrampoline.hpp` — forward each virtual via - `ORCA_PY_OVERRIDE_AUDITED`, choosing an audit mode. -4. **Bindings**: `pluginTypes//PluginCapability.cpp` `RegisterBindings`, then call it from - `bind_python_api` in `PythonPluginBridge.cpp`. -5. **Audit**: confirm the modes / scoped roots per `plugin_audit_hook.md`. -6. **Workflow**: add a call site / on‑load callback that casts and invokes; gate it so an - absent type is a no‑op. -7. **Build**: add the files to `src/slic3r/CMakeLists.txt`. - ---- - -## Part 3 — Testing and Verification - -There is **no dedicated automated test suite for the Python plugin system today.** -Verification is primarily manual, with targeted Catch2 tests where the logic is pure C++. - -### Manual testing - -- **Loading** — install/side‑load a plugin into `data_dir()/orca_plugins//`, open the - Plugins dialog, and confirm it appears with the correct name and version and that each - registered capability is listed (with the expected type). A plugin that fails to load shows - its error in the Diagnostics tab (see - [How errors are surfaced](#how-errors-are-surfaced)). -- **Execution** — script plugins: use the dialog **Run** action. Post‑processing plugins: - run a slice/export and confirm the plugin ran (e.g. its effect on the G‑code, plus log - output). Printer‑agent plugins: verify the agent registers on load and deregisters on - unload. -- **Error handling** — deliberately make the plugin (a) raise an exception and (b) return - `ExecutionResult.failure(...)`; confirm the message box text, and that the full traceback - appears in `data_dir()/log/python_*.log`. Confirm an invalid‑metadata plugin surfaces its - error in the details area rather than crashing. -- **Audit** — confirm a write outside the allow‑list is blocked with a `PermissionError` and - an `[AUDIT BLOCKED]` log line, and that legitimate writes (under `data_dir()`, or the - G‑code folder for G‑code plugins) succeed. - -### Automated tests where appropriate - -Add **targeted Catch2 tests** (under `tests/`) for the pure‑C++ pieces that don’t need a -running interpreter or GUI — for example: - -- PEP 723 metadata parsing (`parse_pep723_toml` / `read_python_plugin_metadata` in - `PythonFileUtils.cpp`): valid blocks, missing fields, malformed arrays. -- Capability reference parsing/serialization (`parse_capability_ref` in `Config.cpp`) — see - `tests/libslic3r/test_config.cpp` and `tests/slic3rutils/test_plugin_capability_identifier.cpp` - for local vs. cloud refs and malformed input. -- The audit allow‑list logic (`PluginAuditManager::check_open`, `is_inside_allowed_root`): - inside/outside roots, `..` traversal, read vs write under each mode. -- Type‑string round‑trips (`plugin_capability_type_from_string` / `plugin_capability_type_to_string`). - -Anything that requires the embedded interpreter, file installs, or GUI dialogs is currently -best covered by the manual steps above. - -### Cross‑platform and regression checks - -- **Cross‑platform** — the plugin code must build and run on Windows, macOS, and Linux. Be - careful with path handling (the audit allow‑list canonicalizes paths; keep using - `std::filesystem` / the existing helpers), and with line endings in the PEP 723 parser - (it already strips `\r`). -- **No regressions** — changes to the framework must not alter behavior when no plugin of a - given type is installed (Step 6). When touching the trampoline/audit headers, note that - `PyPluginTrampoline.hpp` and `PluginAuditManager.hpp` are included by many translation - units; a header‑only change may need a clean rebuild of the affected targets to take - effect (see the audit doc’s *Debugging* section). -- **Backward compatibility** — don’t rename or remove bound fields/methods or - `PluginCapabilityType` values that existing plugins or installed profiles may depend on; add - rather than repurpose. - ---- - -## Key files - -| File | Responsibility | -|---|---| -| `src/slic3r/plugin/PythonPluginInterface.hpp` | `PluginCapabilityType`, `PluginContext`, `PluginResult`, `ExecutionResult`, `PluginCapabilityInterface`, type‑string maps | -| `src/slic3r/plugin/PythonPluginBridge.{hpp,cpp}` | the `orca` module (`bind_python_api`), `@orca.plugin` / `register_capability`, package + capability capture/instantiation | -| `src/slic3r/plugin/PyPluginPackage.hpp` | the package base (`orca.base`) and its `register_capabilities` | -| `src/slic3r/plugin/PyPluginTrampoline.hpp` | the `ORCA_PY_*` trampoline macros (traceback logging + audit scope) and common trampolines | -| `src/slic3r/plugin/pluginTypes//` | per‑type capability base (`*PluginCapability.hpp/.cpp`) and trampoline (`*PluginCapabilityTrampoline.hpp`) | -| `src/slic3r/plugin/PluginDescriptor.hpp` | per‑plugin metadata + error state (`set_error`, `normalized_error`, `is_metadata_valid`) | -| `src/slic3r/plugin/PythonFileUtils.cpp` | PEP 723 / wheel metadata parsing, entry‑file discovery | -| `src/slic3r/plugin/PluginCatalog.cpp`, `PluginLoader.cpp` | discovery, install, load lifecycle, dependency install | -| `src/slic3r/plugin/PythonInterpreter.cpp` | interpreter init, audit‑hook install, traceback formatting, `stderr` → log file | -| `src/slic3r/GUI/PluginsDialog.cpp` | Plugins dialog: details/error area, script **Run**, error dialogs | -| `src/slic3r/GUI/PostProcessor.cpp` | resolves the preset's plugin refs and invokes post‑processing (G‑code) capabilities during export | -| `src/slic3r/CMakeLists.txt` (~609–623) | build list for plugin sources | -| [`plugin_audit_hook.md`](plugin_audit_hook.md) | the audit hook: modes, allow‑list, extending it | diff --git a/docs/plugins/plugin_system.md b/docs/plugins/plugin_system.md deleted file mode 100644 index 330e1519a7..0000000000 --- a/docs/plugins/plugin_system.md +++ /dev/null @@ -1,351 +0,0 @@ -# Plugin System Overview - -OrcaSlicer can be extended at runtime with **Python plugins** that execute inside an -embedded CPython interpreter — no recompilation, no patching the C++ core. This document is -the **architectural overview**: what the pieces are, how they fit together, and the -lifecycle of a plugin from discovery to teardown. - -It is the map; the other two plugin docs are the detail: - -- [`plugin_development.md`](plugin_development.md) — how to *write* a Python plugin and how to - *add a new plugin type* in C++ (the authoring/extension guide). -- [`plugin_audit_hook.md`](plugin_audit_hook.md) — the CPython audit hook that constrains - what plugin code may do (the security deep‑dive). - -> **All paths below are under `src/slic3r/plugin/`** unless stated otherwise. - ---- - -## What the system provides - -- **Extensibility without rebuilding** — users drop a plugin into a folder (or subscribe to - one from the cloud) and OrcaSlicer loads it. -- **Capabilities, not single‑purpose plugins** — one plugin is a *package* that registers one - or more **capabilities**, each a typed unit of functionality (e.g. `post-processing`, - `script`, `printer-connection`). Each capability type has a fixed C++ entry point and is - invoked at a specific place in the app; a plugin's "types" are simply the set of capability - types it registers. -- **Presets remember the plugins they use** — when a preset references a plugin capability, - the full reference is stored in the preset and can be restored from OrcaCloud on another - machine (see [Plugin references in presets](#plugin-references-in-presets)). -- **A single, narrow API surface** — plugins see only the embedded `orca` module, not the - slicer internals. -- **A security boundary** — file access by plugin code is filtered by an audit hook with a - write allow‑list (groundwork; see the audit doc for current scope). -- **Isolation of failure** — a misbehaving plugin reports an error and is unloaded rather - than taking down the app; tracebacks are persisted to a log file. - ---- - -## Architecture at a glance - -``` - ┌──────────────────────────────────────────────┐ - app startup ───► │ PluginManager (singleton orchestrator) │ - (GUI_App::OnInit) │ owns: CloudPluginService, PluginCatalog, │ - │ PluginLoader │ - └───────┬───────────────┬───────────────┬───────┘ - │ │ │ - discover (scan) │ install/ │ load/unload │ - ▼ download ▼ ▼ - ┌──────────────────┐ ┌───────────────┐ ┌────────────────────┐ - │ PluginCatalog │ │ CloudPlugin │ │ PluginLoader │ - │ manifest-only │ │ Service │ │ threaded loads, │ - │ inventory of │ │ (cloud fetch/ │ │ deps (uv), audit │ - │ PluginDescriptor│ │ download) │ │ key, capabilities │ - └──────────────────┘ └───────────────┘ └─────────┬──────────┘ - │ instantiates via - ▼ - ┌─────────────────────────────────────────────────────────────────────────────────┐ - │ Embedded CPython (PythonInterpreter, singleton) │ - │ PythonPluginBridge → `orca` module + @orca.plugin/register_capability + capture │ - │ PyPluginTrampoline → C++↔Python call boundary (traceback logging + audit scope) │ - │ PluginAuditManager → CPython audit hook (filesystem policy) │ - │ pluginTypes/* (gcode, script, printerAgent) → typed capability bases + tramps │ - └─────────────────────────────────────────────────────────────────────────────────┘ - │ get_plugin_capability_* + dynamic_pointer_cast - ▼ - workflow call sites: PostProcessor (G-code post-processing) · - PluginsDialog "Run" (script) · NetworkAgentFactory (printer agent) -``` - -Two broad layers: - -- **Orchestration (C++, no Python):** `PluginManager`, `PluginCatalog`, `CloudPluginService`, - `PluginLoader`, `PluginDescriptor`. These discover, install, and manage plugins as data. -- **Execution (the C++↔Python bridge):** `PythonInterpreter`, `PythonPluginBridge`, - `PyPluginTrampoline`, `PluginAuditManager`, and the per‑type bases under `pluginTypes/`. - These turn a discovered plugin into a live object the app can call. - ---- - -## Core components - -| Component | Responsibility | -|---|---| -| `PluginManager` | Top‑level **singleton orchestrator**. Owns the catalog, loader, and cloud service; exposes `initialize()`, `discover_plugins()`, install/update/delete, and `shutdown()`. | -| `PluginCatalog` | **Manifest‑only inventory.** Scans the plugin directories, parses each plugin's metadata into a `PluginDescriptor`, and splits results into valid vs. invalid. Loads no Python. | -| `CloudPluginService` | Thin wrapper over the cloud agent: fetch subscribed/owned plugin manifests, download a plugin payload, unsubscribe/delete. | -| `PluginLoader` | **Load/unload lifecycle.** Installs dependencies (bundled `uv`), imports the module, instantiates the package and its capabilities, stamps their audit identity, runs `on_load()`, and keeps the live capability instances keyed by a `PluginCapabilityIdentifier`. Provides `get_plugin_capabilities_by_type()` / `get_plugin_capability_by_name()` and on‑load/unload + on‑capability‑load/unload callbacks. | -| `PluginDescriptor` | The canonical record for one plugin: key, paths, capability/display types, version, changelog, dependencies, cloud overlay, and any error/validity state. | -| `PythonInterpreter` | **Singleton RAII wrapper around embedded CPython.** Init/finalize, GIL handoff, `sys.path`, module loading, and installing the audit hook + stderr‑to‑log redirect. | -| `PythonPluginBridge` | Defines the embedded **`orca` module**, the `@orca.plugin` decorator + `orca.base` package class + `register_capability` entry, and captures/instantiates the package and the capability classes it registers. | -| `PyPluginTrampoline` | The pybind11 override base at the **C++↔Python boundary**: logs Python tracebacks and opens the per‑call audit scope. | -| `pluginTypes/*` | Per‑type C++ capability bases + trampolines (`GCodePluginCapability`, `ScriptPluginCapability`, `PrinterAgentPluginCapability`) that define each type's entry method and dispatch. | -| `PluginAuditManager` | **Singleton CPython audit hook**: filesystem policy (write allow‑list), scoped roots, `Loading`/`Enforcing` modes. See the audit doc. | - ---- - -## Plugin packaging and discovery - -A plugin is a folder under one of two roots, containing a single **entry file**: - -| Root | Source | -|---|---| -| `data_dir()/orca_plugins/` | locally installed / side‑loaded | -| `data_dir()/orca_plugins/_subscribed//` | cloud‑subscribed (per logged‑in user) | - -The entry file is either a single **`.py`** (metadata in a PEP 723 comment block) or a -**`.whl`** wheel (metadata from the wheel's `METADATA`). The **capabilities** the plugin -registers determine which workflows can run it — there is no separate `type` declaration in the -metadata. Metadata and packaging details are in -[`plugin_development.md`](plugin_development.md). - -**Discovery vs. loading are separate stages.** `PluginCatalog` *scans* directories and -produces `PluginDescriptor`s — it parses manifests only and never executes plugin code. A -*catalog entry* is just data; a *loaded plugin* is a live Python instance created later by -`PluginLoader`. Cloud manifests are merged into the catalog as an overlay once a user is -logged in. - ---- - -## The plugin lifecycle - -``` -1. App startup (GUI_App::OnInit, after network init) - │ -2. PluginManager::initialize() - │ └─ PythonInterpreter::initialize() (MAIN THREAD ONLY) - │ ├─ start embedded CPython, set sys.path / python home - │ ├─ install the audit hook (global allowed root = data_dir()) - │ ├─ tee sys.stderr → data_dir()/log/python_*.log - │ └─ release the GIL (PyEval_SaveThread) - │ -3. discover_plugins() ─► PluginCatalog scans local + cloud roots - │ → PluginDescriptor list (valid / invalid) - │ (cloud login later: fetch_plugins_from_cloud → catalog overlay) - │ -4. PluginLoader::load_plugin() (worker thread, serialized) - │ ├─ install dependencies via bundled `uv`; extract bundled .whl deps onto sys.path - │ ├─ begin capture → import module (runs @orca.plugin, marking the package class) - │ ├─ finalize capture → instantiate package, call register_capabilities(), - │ │ then instantiate each registered capability and cache its get_name() - │ ├─ set_audit_plugin_key(descriptor.plugin_key) // audit identity - │ ├─ on_load() (under the GIL) - │ └─ store the capabilities; fire on-load + on-capability-load callbacks - │ -5. Use: a workflow call site resolves a capability (get_plugin_capability_by_name / - │ get_plugin_capabilities_by_type) + dynamic_pointer_cast, - │ builds the type's context, and calls the entry method (under the GIL). - │ Each call crosses a trampoline that opens a ScopedPluginAuditContext. - │ -6. Unload / shutdown: set_shutting_down → unload_plugin / unload_all_plugins - (the instance's destructor runs on_unload() + Py_DECREF under the GIL) - → PythonInterpreter::shutdown() -``` - -A few load‑time invariants worth knowing: - -- **`set_audit_plugin_key()` is what arms enforcement.** Without it the instance has an empty - key and its calls run unaudited. It is stamped at load and re‑stamped on key migration - (`update_loaded_plugin_key`). See the audit doc. -- A module must mark exactly one package class with `@orca.plugin` (a subclass of - `orca.base`), and that class's `register_capabilities()` must register at least one valid - capability via `orca.register_capability(...)`, or the load fails. Each capability must - resolve `get_name()`, and `(type, name)` must be unique within the plugin. - ---- - -## Execution model: how the app calls a plugin - -Capabilities are reached **by type, not by name**. There is no per‑type instantiation -registry: a capability's Python class subclasses a typed C++ base, the package registers it -via `register_capability`, and each workflow call site narrows the stored capability instance -(`PluginCapabilityInterface`) with `std::dynamic_pointer_cast`. If the cast -succeeds, the capability is present and is invoked; if not (no such capability installed or -enabled), the path is a no‑op — which is how the system guarantees that absent/disabled -capabilities never change existing behavior. - -| Capability type | Entry method | Invoked by | -|---|---|---| -| `post-processing` (G‑code) | `execute(ctx)` | `PostProcessor` during G‑code export, resolving the preset's plugin refs | -| `script` | `execute()` | the **Plugins dialog → Run** action | -| `printer-connection` | agent methods | `NetworkAgentFactory`, registered through a loader on‑capability‑load callback wired in `GUI_App` | - -The on‑load / on‑unload **callbacks** (`PluginLoader::subscribe_on_load_callback` / -`subscribe_on_unload_callback`) and the per‑capability variants -(`subscribe_on_capability_load_callback` / `subscribe_on_capability_unload_callback`) are how -subsystems react to plugins and capabilities appearing or disappearing — e.g. the -printer‑agent layer registers/deregisters an agent for each `PrinterConnection` capability, -and the Plugins dialog refreshes. Adding a new type and wiring a call site is covered in -[`plugin_development.md`](plugin_development.md). - ---- - -## Threading and the GIL - -- **The interpreter is initialized on the main thread.** CPython is started once via - `PythonInterpreter` (singleton). Initializing it off the main thread risks heap - corruption, so `PluginManager::initialize()` does it eagerly and synchronously. -- **After init the GIL is released** (`PyEval_SaveThread`) and reacquired at shutdown, so - other threads may take it. -- **Plugin loads run on worker threads**, serialized by a static mutex so module imports - don't race. Discovery can also run on a background thread (`discover_plugins(async=true)`), - though startup discovery is synchronous. -- **Every touch of Python from a non‑main thread acquires the GIL** through the - `PythonGILState` RAII guard (`PyGILState_Ensure` / `Release`) — load, execute, and the - instance destructor (`on_unload` + `Py_DECREF`) all wrap in it. - ---- - -## Cloud subscriptions - -`CloudPluginService` wraps the cloud agent (`OrcaCloudServiceAgent`) and is gated on login. -It fetches the manifests of subscribed/owned plugins, merges them into the catalog as an -overlay, and downloads a plugin's payload (sniffing the file to tell a `.whl` from a `.py`) -to a temporary file. `PluginManager` sets the loader's cloud user id, and `PluginLoader` -installs the downloaded payload under `orca_plugins/_subscribed//`. Logging out -unloads cloud plugins. The cloud auth token (`orca_refresh_token.sec`) is owned by the cloud -agent, not by the plugin layer. - ---- - -## Plugin references in presets - -When a setting points at a plugin capability (for example `post_process_plugin`), the value -the setting stores is just the capability's **name**. So that the reference survives being -copied to another machine — where the plugin might not be installed — each preset also carries -a `plugins` array that records the **full reference** for every capability it uses. - -Each entry is a single string with three `;`‑separated fields: - -``` -;; -``` - -```json -{ - "plugins": [ - "Sample Plugin;1f998ea9-0183-4cc5-957f-4eef659ba4e6;G-code Benchmark (.py)", - "master_plugin;;header-stamp" - ], - "post_process_plugin": ["G-code Benchmark (.py)", "header-stamp"] -} -``` - -- The **`cloud_uuid`** is present for plugins subscribed from OrcaCloud and **empty** for - local‑only plugins (note the adjacent `;;`). It is what lets OrcaSlicer offer to restore a - missing plugin automatically. -- Because `;` is the field separator, a **capability name may not contain `;`** (the loader - rejects such a plugin), and plugin display names have any `;` replaced with `_` - (`sanitize_plugin_name`). -- The `plugins` array is an internal manifest (`coStrings`, `comDevelop` mode — not a - user‑edited field). Fields that hold a capability name are flagged `support_plugin`; on - save the array is **pruned** to only the references still used by such a field, so stale - entries drop out. -- Parsing/serialization lives in `Config.cpp` (`parse_capability_ref` → - `PluginCapabilityRef{ name, capability_name, uuid }`); the `plugins` option is defined in - `PrintConfig.cpp` and is tracked on **process, printer, and filament** presets. See - `tests/libslic3r/test_config.cpp` and - `tests/slic3rutils/test_plugin_capability_identifier.cpp`. - -## Restoring missing plugins - -When you prepare to slice, OrcaSlicer resolves the active process, printer, and filament -presets' `plugins` arrays against the loaded catalog (`Plater::refresh_missing_plugin_block`). -Any reference it cannot satisfy is surfaced as a **non-closable notification**, and while any -remain the **Slice button stays blocked** — there is no "slice anyway" path; you resolve the -reference (or change the setting that pulls it in). References are sorted into four buckets: - -- **Missing OrcaCloud plugins** (ref carries a UUID) — notification action **Install Plugins**, - which subscribes to, installs, loads, and enables each one so it becomes usable immediately. -- **Missing local plugins** (no UUID) — cannot be fetched automatically; the action **Find on - OrcaCloud** just opens a browser search on the OrcaCloud plugins page. It is a suggestion - only: it neither closes the notification nor unblocks slicing. -- **Inactive plugins** — the package is installed locally but the referenced capability is not - active (plugin not loaded, or capability disabled). Action **Activate Now** loads/enables it - locally, with no download. -- **Broken references** — the plugin is installed and loaded but no longer provides the - referenced capability (renamed/removed/outdated). Activation cannot fix this, so it is - informational, with **Find on OrcaCloud** to look for an update. - -The bucketing lives in `PluginResolver` (`get_missing_cloud_plugins`, `get_missing_local_plugins`, -`get_inactive_plugins`, `get_broken_plugins`); the notifications and the slice block are driven -from `Plater.cpp` (`refresh_missing_plugin_block`). - -## The Plugins dialog - -The Plugins dialog (`PluginsDialog.cpp` + `resources/web/dialog/PluginsDialog/`) presents each -installed plugin as an expandable row (Activate · Name · Version · Status). Expanding a plugin -shows a **capability tree** — one row per registered capability with its own enable checkbox, -type label, and (for runnable script capabilities) a **Run** button. The details pane is -tabbed: - -| Tab | Shows | -|---|---| -| **Plugin Info** | thumbnail, source, types, author, version (with an update badge) | -| **Description** | the plugin's own description, taken from its Python/wheel metadata | -| **Changelog** | version / date / changes table | -| **Diagnostics** | load status and any error state | - -Installing is done from a **Browse plugins** split dropdown that opens the OrcaCloud plugins -hub, with an **Install local plugin** option for side‑loading a `.py` or `.whl` directly. -Per‑plugin and per‑capability enablement is persisted in a per‑plugin `.install_state.json` -sidecar (written by `PluginManager`). - ---- - -## Security and observability - -- **Security** — all C++→Python calls cross a trampoline that opens a per‑call audit context; - the `PluginAuditManager` audit hook then filters sensitive operations (today: a filesystem - write allow‑list rooted at `data_dir()`, plus scoped roots such as the current G‑code - folder). This is groundwork, not a hardened sandbox — read - [`plugin_audit_hook.md`](plugin_audit_hook.md) for exactly what is and isn't enforced. -- **Observability** — Python `sys.stderr` (plugin tracebacks, including from - plugin‑spawned threads) is teed to `data_dir()/log/python_*.log`; C++‑side - load/discovery messages go to the main session log. How errors surface in the UI (message - box vs. the plugin details area) is described in - [`plugin_development.md`](plugin_development.md#how-errors-are-surfaced). - ---- - -## Related documents - -- [`plugin_development.md`](plugin_development.md) — authoring Python plugins; adding a new - C++ plugin type; testing and debugging. -- [`plugin_audit_hook.md`](plugin_audit_hook.md) — the audit hook: modes, allow‑list, - extending the policy. - -## Key files - -| File | Role | -|---|---| -| `src/slic3r/plugin/PluginManager.{hpp,cpp}` | top‑level orchestrator; startup `initialize()` / `discover_plugins()` / `shutdown()` | -| `src/slic3r/plugin/PluginCatalog.{hpp,cpp}` | directory scan → `PluginDescriptor` inventory | -| `src/slic3r/plugin/PluginLoader.{hpp,cpp}` | threaded load/unload, dependency install, capability registry, audit‑key stamping | -| `src/slic3r/plugin/PluginDescriptor.hpp` | the per‑plugin record (types, changelog, `sanitize_plugin_name`) | -| `src/slic3r/plugin/CloudPluginService.{hpp,cpp}` | cloud fetch / download / subscribe / unsubscribe | -| `src/slic3r/plugin/PythonInterpreter.{hpp,cpp}` | embedded CPython, GIL handoff, audit‑hook + log install | -| `src/slic3r/plugin/PythonPluginBridge.{hpp,cpp}` | the `orca` module, `@orca.plugin` / `register_capability`, package + capability capture | -| `src/slic3r/plugin/PyPluginPackage.hpp` | the package base (`orca.base`) + `register_capabilities` | -| `src/slic3r/plugin/PyPluginTrampoline.hpp` | C++↔Python boundary macros (traceback logging + audit scope) | -| `src/slic3r/plugin/pluginTypes/*` | per‑type capability bases + trampolines | -| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | the CPython audit hook and policy | -| `src/libslic3r/Config.cpp` | `parse_capability_ref`, the `plugins` array (de)serialization | -| `src/libslic3r/PrintConfig.cpp` | the `plugins` / `post_process_plugin` option definitions | -| `src/slic3r/GUI/PostProcessor.cpp` | resolves preset plugin refs and runs G‑code capabilities | -| `src/slic3r/GUI/PluginPickerDialog.{hpp,cpp}` | pick a capability as a setting value | -| `src/slic3r/GUI/Plater.cpp` | the missing‑plugins resolution dialog on slice (`reslice`) | -| `src/slic3r/GUI/GUI_App.cpp` | startup wiring (init, discovery, on‑load / on‑capability‑load callbacks) and shutdown | -| `src/slic3r/GUI/PluginsDialog.cpp` | the Plugins dialog (capability tree, tabs, Run, Browse plugins) | diff --git a/resources/orca_plugins/BBLPrinterAgentPlugin.py b/resources/orca_plugins/BBLPrinterAgentPlugin.py deleted file mode 100644 index 68cca25711..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( - "bbl", - "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)