From 32756f0402764cf82512a2d2fe161094a1506113 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Fri, 3 Jul 2026 16:30:28 +0800 Subject: [PATCH] add orca plugin example --- sandboxes/orca_plugin_example.py | 1322 ++++++++++++++++++++++++++++++ 1 file changed, 1322 insertions(+) create mode 100644 sandboxes/orca_plugin_example.py diff --git a/sandboxes/orca_plugin_example.py b/sandboxes/orca_plugin_example.py new file mode 100644 index 0000000000..238d8f8129 --- /dev/null +++ b/sandboxes/orca_plugin_example.py @@ -0,0 +1,1322 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["numpy"] +# +# [tool.orcaslicer.plugin] +# name = "Orca Host Inspector Panel" +# description = "An interactive panel that browses the whole orca.host read-only API and demos every orca.host.ui facility." +# author = "OrcaSlicer" +# version = "2.0.0" +# /// +"""Orca Host Inspector — the worked sample for `orca.host` and `orca.host.ui`. + +Run it from the Plugins dialog. It opens a NON-MODAL window (OrcaSlicer stays +usable) with a sidebar of sections, each exercising one part of the API: + + Overview plater state, scene statistics, current printer/process/filaments + Presets every PresetCollection, preset flags, any preset's full config + Config the complete merged full_config as a searchable table + Model Model -> Object -> Volume/Instance tree with lazy mesh geometry + Assembly objects grouped by module_name, per-instance assemble transforms + UI Toolkit live demos of message/show_dialog/create_window/ProgressDialog + +The page and the plugin talk JSON through the injected `window.orca` bridge: + + page --orca.postMessage({command:'fetch', section})--> plugin.on_message() + page --orca.postMessage({command:'mesh', object, volume})--> " + page --orca.postMessage({command:'ui', action, ...})--> " + plugin --win.post({command:'section'|'mesh'|..., ok, data})--> page (orca.onMessage) + +Everything is fetched lazily: a section is built only when it is first shown, +and heavy mesh geometry (zero-copy numpy arrays, world-space math, the little +point-cloud previews) only when you ask for it. "Refresh" drops the cache and +refetches the visible section. + +Style rules the page follows (see the plugin docs): + - no hardcoded colors — only the host-injected --orca-* theme variables, so + the panel matches light and dark mode automatically; + - self-contained HTML (inline CSS/JS, no external resources); + - on_message runs on the UI thread, so long work (the progress demos) is + offloaded to a thread and results are pushed back with win.post(). + +numpy is declared as a dependency for the mesh arrays and 4x4 matrices; every +numpy-dependent feature degrades gracefully if it is missing. The assembly +transforms need bindings added in July 2026 — on older builds the panel shows +what is missing instead of failing. +""" + +import re +import threading +import time + +import orca + +try: + import numpy as np +except Exception: + np = None + + +# Soft caps so a heavy scene cannot produce an unusably large payload. When a +# cap trims output the payload says so explicitly rather than truncating silently. +MAX_OBJECTS = 50 +MAX_VOLUMES = 20 +MAX_INSTANCES = 20 +MAX_PRESET_NAMES = 500 +MAX_PREVIEW_POINTS = 4000 + + +# --------------------------------------------------------------------------- # +# small converters — every builder returns plain JSON-able dicts/lists. +# Cast numpy scalars with float()/int(): the bridge serializes unknown types +# with str(), which would silently turn them into strings. +# --------------------------------------------------------------------------- # +def vec(v): + return [round(float(c), 4) for c in v] + + +def bbox_dict(bb): + """host.BoundingBox -> dict (or None when the box is undefined).""" + if not bb.defined: + return None + return {"min": vec(bb.min), "max": vec(bb.max), "size": vec(bb.size), + "center": vec(bb.center), "radius": round(float(bb.radius), 4)} + + +def enum_name(value): + return getattr(value, "name", str(value)) + + +def config_rows(keys, get_value): + """Sorted [{k, v}] for a config; None values become ''. Complete, not sampled.""" + return [{"k": k, "v": "" if (v := get_value(k)) is None else str(v)} + for k in sorted(keys)] + + +def preset_dict(preset): + """Every documented host.Preset attribute, in one dict.""" + return { + "name": preset.name, + "alias": preset.alias, + "label": preset.label(), + "type": enum_name(preset.type), + "file": preset.file, + "bundle_id": preset.bundle_id, + "config_key_count": len(preset.config_keys()), + "is_default": preset.is_default, + "is_system": preset.is_system, + "is_user": preset.is_user(), + "is_external": preset.is_external, + "is_visible": preset.is_visible, + "is_dirty": preset.is_dirty, + "is_compatible": preset.is_compatible, + "is_project_embedded": preset.is_project_embedded, + "is_from_bundle": preset.is_from_bundle(), + } + + +def split_config_list(value): + """Split a serialized per-extruder option ("#FF0000;#00FF00") into parts.""" + if value is None: + return [] + return [part.strip().strip('"') for part in str(value).split(";")] + + +# --------------------------------------------------------------------------- # +# section builders +# --------------------------------------------------------------------------- # +def build_overview(): + plater = orca.host.plater() + model = orca.host.model() + bundle = orca.host.preset_bundle() + objects = model.objects() + + cfg = bundle.full_config_value + colors = split_config_list(cfg("filament_colour")) + types = split_config_list(cfg("filament_type")) + filaments = [] + for i, preset in enumerate(bundle.current_filament_presets()): + filaments.append({ + "name": preset.name if preset else "", + "color": colors[i] if i < len(colors) else "", + "type": types[i] if i < len(types) else "", + "is_system": bool(preset.is_system) if preset else False, + "is_dirty": bool(preset.is_dirty) if preset else False, + }) + + printer = bundle.current_printer_preset() + process = bundle.current_process_preset() + return { + "plater": { + "project_dirty": plater.is_project_dirty(), + "presets_dirty": plater.is_presets_dirty(), + "in_snapshot": plater.inside_snapshot_capture(), + }, + "stats": { + "objects": len(objects), + # derived from instances: ModelObject.printable is only the import-time flag + "printable_objects": sum(1 for o in objects if printable_instance_count(o)), + "volumes": sum(o.volume_count() for o in objects), + "instances": sum(o.instance_count() for o in objects), + "triangles": sum(o.facets_count() for o in objects), + "materials": model.material_count(), + "plate_index": model.current_plate_index(), + "max_z": round(float(model.max_z()), 3), + "config_keys": len(bundle.full_config_keys()), + }, + "bbox": bbox_dict(model.bounding_box()), + "bbox_approx": bbox_dict(model.bounding_box_approx()), + "painted": { + "support": model.is_fdm_support_painted(), + "seam": model.is_seam_painted(), + "multimaterial": model.is_mm_painted(), + "fuzzy_skin": model.is_fuzzy_skin_painted(), + }, + "design": {"designer": model.designer(), "design_id": model.design_id(), + "model_id": model.id()}, + "setup": { + "printer": {"name": printer.name, "is_system": printer.is_system, + "is_dirty": printer.is_dirty}, + "process": {"name": process.name, "is_system": process.is_system, + "is_dirty": process.is_dirty}, + "filaments": filaments, + "highlights": [{"k": key, "v": str(cfg(key))} + for key in ("printer_model", "nozzle_diameter", + "printable_height", "layer_height", + "sparse_infill_density", "enable_prime_tower") + if cfg(key) is not None], + }, + } + + +# label -> how to reach the PresetCollection on the bundle +COLLECTIONS = [ + ("prints", "Process"), + ("printers", "Printer"), + ("filaments", "Filament"), + ("sla_prints", "SLA print"), + ("sla_materials", "SLA material"), +] + + +def build_presets(): + bundle = orca.host.preset_bundle() + collections = [] + for attr, label in COLLECTIONS: + coll = getattr(bundle, attr) + names = coll.preset_names() + selected = coll.selected_preset() + collections.append({ + "key": attr, + "label": label, + "size": coll.size(), + "selected_name": coll.selected_preset_name(), + # selected = as saved on disk; edited = selected + unsaved modifications + "selected": {"name": selected.name, "is_dirty": selected.is_dirty, + "config_key_count": len(selected.config_keys())}, + "edited": preset_dict(coll.edited_preset()), + "names": names[:MAX_PRESET_NAMES], + "truncated": max(0, len(names) - MAX_PRESET_NAMES), + }) + return { + "collections": collections, + "filament_names": bundle.current_filament_preset_names(), + } + + +def build_preset_config(collection_key, name): + bundle = orca.host.preset_bundle() + if collection_key not in {attr for attr, _ in COLLECTIONS}: + raise ValueError(f"unknown collection {collection_key!r}") + preset = getattr(bundle, collection_key).find_preset(name) + if preset is None: + raise ValueError(f"preset {name!r} not found") + return {"preset": preset_dict(preset), + "rows": config_rows(preset.config_keys(), preset.config_value)} + + +def build_config(): + bundle = orca.host.preset_bundle() + rows = config_rows(bundle.full_config_keys(), bundle.full_config_value) + return {"rows": rows, "count": len(rows)} + + +def volume_dict(index, volume): + return { + "index": index, + "id": volume.id(), + "name": volume.name, + "type": enum_name(volume.type()), + "roles": { + "model_part": volume.is_model_part(), + "modifier": volume.is_modifier(), + "negative": volume.is_negative_volume(), + "support_enforcer": volume.is_support_enforcer(), + "support_blocker": volume.is_support_blocker(), + }, + "extruder": volume.extruder_id(), # 1-based; -1 = none/inherited + "offset": vec(volume.offset()), + "rotation": vec(volume.rotation()), # radians + "scale": vec(volume.scaling_factor()), + "mirror": vec(volume.mirror()), + "facets": volume.facets_count(), + "volume_mm3": round(float(volume.volume()), 3), + "manifold": volume.is_manifold(), + "mesh_errors": volume.mesh_errors_count(), + "painted": {"support": volume.is_fdm_support_painted(), + "seam": volume.is_seam_painted(), + "multimaterial": volume.is_mm_painted(), + "fuzzy_skin": volume.is_fuzzy_skin_painted()}, + "bbox": bbox_dict(volume.bounding_box()), + "config": config_rows(volume.config_keys(), volume.config_value), + } + + +def assemble_dict(instance): + """Assemble-view placement of one instance; needs bindings added 2026-07. + getattr-guarded so the panel still runs on an older OrcaSlicer build.""" + if not hasattr(instance, "assemble_offset"): + return {"available": False} + return { + "available": True, + "initialized": instance.is_assemble_initialized(), + "offset": vec(instance.assemble_offset()), + "rotation": vec(instance.assemble_rotation()), # radians + "offset_to_assembly": vec(instance.offset_to_assembly()), + } + + +def instance_dict(index, instance): + return { + "index": index, + "id": instance.id(), + "printable_flag": instance.printable, + "is_printable": instance.is_printable(), # also requires inside print volume + "offset": vec(instance.offset()), + "rotation": vec(instance.rotation()), # radians + "scale": vec(instance.scaling_factor()), + "mirror": vec(instance.mirror()), + "left_handed": instance.is_left_handed(), + "bbox": bbox_dict(instance.bounding_box()), + "assemble": assemble_dict(instance), + } + + +def printable_instance_count(obj): + """The GUI's printable toggle writes ModelInstance.printable only; + ModelObject.printable keeps its import-time value. So an object's effective + printable state must be derived from its instances.""" + return sum(1 for i in range(obj.instance_count()) if obj.instance(i).printable) + + +def object_dict(index, obj): + volumes = [volume_dict(i, obj.volume(i)) + for i in range(min(obj.volume_count(), MAX_VOLUMES))] + instances = [instance_dict(i, obj.instance(i)) + for i in range(min(obj.instance_count(), MAX_INSTANCES))] + return { + "index": index, + "id": obj.id(), + "name": obj.name, + "module_name": obj.module_name, + "input_file": obj.input_file, + "printable": obj.printable, # raw import-time flag, see above + "counts": {"volumes": obj.volume_count(), "instances": obj.instance_count(), + "printable_instances": printable_instance_count(obj), + "facets": obj.facets_count(), "parts": obj.parts_count(), + "materials": obj.materials_count(), + "mesh_errors": obj.mesh_errors_count()}, + "flags": {"multiparts": obj.is_multiparts(), "cut": obj.is_cut(), + "custom_layering": obj.has_custom_layering()}, + "painted": {"support": obj.is_fdm_support_painted(), + "seam": obj.is_seam_painted(), + "multimaterial": obj.is_mm_painted(), + "fuzzy_skin": obj.is_fuzzy_skin_painted()}, + "z_range": [round(float(obj.min_z()), 3), round(float(obj.max_z()), 3)], + "bbox": bbox_dict(obj.bounding_box()), # world, all instances + "raw_bbox": bbox_dict(obj.raw_mesh_bounding_box()), # untransformed meshes + "config": config_rows(obj.config_keys(), obj.config_value), + "volumes": volumes, + "volumes_omitted": max(0, obj.volume_count() - len(volumes)), + "instances": instances, + "instances_omitted": max(0, obj.instance_count() - len(instances)), + } + + +def build_model(): + model = orca.host.model() + count = model.object_count() + shown = min(count, MAX_OBJECTS) + return { + # carried along so the extruder swatches never depend on a stale Overview + "filament_colors": split_config_list( + orca.host.preset_bundle().full_config_value("filament_colour")), + "id": model.id(), + "object_count": count, + "objects_omitted": count - shown, + "material_count": model.material_count(), + "plate_index": model.current_plate_index(), + "max_z": round(float(model.max_z()), 3), + "bbox": bbox_dict(model.bounding_box()), + "bbox_approx": bbox_dict(model.bounding_box_approx()), + "objects": [object_dict(i, model.object(i)) for i in range(shown)], + } + + +def build_assembly(): + """Group objects by module_name (the assembly path recorded in the 3mf) into + a tree, and report each instance's Assemble-view transform.""" + model = orca.host.model() + root = {"name": "", "groups": {}, "objects": []} + count = min(model.object_count(), MAX_OBJECTS) + has_modules = False + + for i in range(count): + obj = model.object(i) + module = (obj.module_name or "").strip() + has_modules = has_modules or bool(module) + + node = root + for part in [p for p in re.split(r"[/\\]", module) if p]: + node = node["groups"].setdefault(part, {"name": part, "groups": {}, "objects": []}) + + instances = [{"index": j, "offset": vec(obj.instance(j).offset()), + "printable": obj.instance(j).printable, + "assemble": assemble_dict(obj.instance(j))} + for j in range(min(obj.instance_count(), MAX_INSTANCES))] + bb = bbox_dict(obj.raw_mesh_bounding_box()) + node["objects"].append({"index": i, "name": obj.name, + "printable_instances": printable_instance_count(obj), + "instance_count": obj.instance_count(), + "size": bb["size"] if bb else None, + "instances": instances, + "instances_omitted": obj.instance_count() - len(instances)}) + + def freeze(node): # dict-of-groups -> sorted list, for stable JSON + return {"name": node["name"], + "groups": [freeze(node["groups"][k]) for k in sorted(node["groups"])], + "objects": node["objects"]} + + return {"tree": freeze(root), + "object_count": count, + "objects_omitted": model.object_count() - count, + "has_modules": has_modules} + + +def build_mesh(object_index, volume_index): + """Heavy on purpose: full TriangleMesh access, numpy views, world-space math + and a sampled world-space point cloud for the previews.""" + obj = orca.host.model().object(object_index) + volume = obj.volume(volume_index) + mesh = volume.mesh() + + data = { + "vertex_count": mesh.vertex_count(), + "triangle_count": mesh.triangle_count(), + "empty": mesh.is_empty(), + "manifold": mesh.is_manifold(), + "volume_mm3": round(float(mesh.volume()), 3), + "bbox": bbox_dict(mesh.bounding_box()), + # numpy-free element access (always available, bounds-checked) + "vertex0": vec(mesh.vertex(0)) if not mesh.is_empty() else None, + "triangle0": list(mesh.triangle(0)) if not mesh.is_empty() else None, + "numpy": None, + "preview": None, + } + if np is None or mesh.is_empty(): + return data + + 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 + tri = V[T].astype(np.float64) # (M, 3, 3) corner positions + area = 0.5 * np.linalg.norm(np.cross(tri[:, 1] - tri[:, 0], + tri[:, 2] - tri[:, 0]), axis=1).sum() + data["numpy"] = { + "vertices": f"shape={V.shape} dtype={V.dtype} writeable={V.flags.writeable} " + f"zero_copy={V.base is not None}", + "triangles": f"shape={T.shape} dtype={T.dtype}", + "normals": f"shape={N.shape} dtype={N.dtype}", + "surface_area_mm2": round(float(area), 3), + } + + # World-space math under the first instance, using the row-vector convention + # world = [V 1] @ (instance_matrix @ volume_matrix).T + if obj.instance_count(): + instance = obj.instance(0) + homog = np.c_[V.astype(np.float64), np.ones(len(V))] + world = (homog @ (instance.matrix() @ volume.matrix()).T)[:, :3] + sample = world[::max(1, len(world) // MAX_PREVIEW_POINTS)] + data["numpy"]["world_bbox"] = {"min": vec(world.min(0)), "max": vec(world.max(0))} + data["preview"] = {"points": [vec(p) for p in sample], + "sampled": len(sample), "total": len(world)} + # The same mesh placed by the Assemble view (new 2026-07 API, guarded). + if hasattr(instance, "assemble_matrix") and instance.is_assemble_initialized(): + asm = (homog @ (instance.assemble_matrix() @ volume.matrix()).T)[:, :3] + data["numpy"]["assemble_bbox"] = {"min": vec(asm.min(0)), "max": vec(asm.max(0))} + return data + + +SECTION_BUILDERS = { + "overview": build_overview, + "presets": build_presets, + "config": build_config, + "model": build_model, + "assembly": build_assembly, +} + + +# --------------------------------------------------------------------------- # +# the page — sidebar app, themed exclusively via the injected --orca-* vars +# --------------------------------------------------------------------------- # +PAGE = r""" + + + + + + +
+

Host Inspector

+ + + +
+
+ +
+
+ + + + +""" + + +# The modal page demoed from the UI Toolkit tab: orca.submit(payload) resolves the +# blocking show_dialog() call with that payload; orca.close() resolves it with None. +MODAL_PAGE = r""" + + +

Modal dialog

+

show_dialog() blocks the plugin until this closes, then returns the submitted payload.

+

+

+ + + +

+ +""" + +CHILD_PAGE = r""" + + +

Child window

+

Same create_window() API — one plugin, several windows.

+

+
+ + +""" + + +# --------------------------------------------------------------------------- # +# the plugin +# --------------------------------------------------------------------------- # +class HostInspectorPanel(orca.script.ScriptPluginCapabilityBase): + win = None + child = None + + def get_name(self): + return "Host Inspector" + + def execute(self): + # Capability objects are instantiated once per plugin load, so a second + # Run lands on the same instance — close any previous windows first, or + # the old panel would keep posting into the new one. + if self.win is not None and self.win.is_open(): + self.win.close() + if self.child is not None: + self.child.close() + self.child = None + # 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=940, + height=680, + 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. Every branch posts + # a reply carrying ok/error so the page never waits on a silent failure. + def on_message(self, msg): + msg = msg or {} + command = msg.get("command") + if command == "fetch": + self.send_section(msg.get("section", "")) + elif command == "mesh": + self.send_mesh(int(msg.get("object", -1)), int(msg.get("volume", -1))) + elif command == "preset_config": + self.send_preset_config(msg.get("collection", ""), msg.get("name", "")) + elif command == "ui": + self.run_ui_action(msg) + + def on_close(self): + if self.child is not None: + self.child.close() + self.child = None + print("Host Inspector closed") + + def send_section(self, section): + builder = SECTION_BUILDERS.get(section) + reply = {"command": "section", "section": section} + if builder is None: + self.win.post({**reply, "ok": False, "error": f"unknown section {section!r}"}) + return + try: + self.win.post({**reply, "ok": True, "data": builder()}) + except Exception as exc: + self.win.post({**reply, "ok": False, "error": str(exc)}) + + def send_mesh(self, object_index, volume_index): + reply = {"command": "mesh", "object": object_index, "volume": volume_index} + try: + self.win.post({**reply, "ok": True, + "data": build_mesh(object_index, volume_index)}) + except Exception as exc: + self.win.post({**reply, "ok": False, "error": str(exc)}) + + def send_preset_config(self, collection, name): + reply = {"command": "preset_config", "collection": collection, "name": name} + try: + self.win.post({**reply, "ok": True, + "data": build_preset_config(collection, name)}) + except Exception as exc: + self.win.post({**reply, "ok": False, "error": str(exc)}) + + # ---------------------------------------------------------------- ui demos + def run_ui_action(self, msg): + action = msg.get("action", "") + reply = {"command": "ui_result", "action": action} + + def report(result): + self.win.post({**reply, "ok": True, "result": result}) + + try: + if action == "message": + clicked = orca.host.ui.message( + msg.get("text") or "Hello from Host Inspector", + title="Host Inspector", + buttons=msg.get("buttons", "ok"), + icon=msg.get("icon", "info")) + report(f"user clicked {clicked!r}") + elif action == "modal": + # We are on the UI thread, so this nests a modal event loop and + # blocks right here until the dialog closes. on_message still + # fires while the dialog is open (the log updates live). + result = orca.host.ui.show_dialog( + html=MODAL_PAGE, title="Host Inspector — modal", width=420, height=280, + on_message=lambda m: self.win.post( + {**reply, "ok": True, "result": f"modal posted {m!r} while open"})) + report(f"show_dialog returned {result!r}") + elif action == "progress": + threading.Thread(target=self.progress_demo, daemon=True).start() + report("started on a worker thread…") + elif action == "pulse": + threading.Thread(target=self.pulse_demo, daemon=True).start() + report("started on a worker thread…") + elif action == "child_open": + if self.child is not None and self.child.is_open(): + report("child already open") + else: + self.child = orca.host.ui.create_window( + title="Host Inspector — child", html=CHILD_PAGE, + width=380, height=240, on_message=self.on_child_message, + on_close=lambda: self.win.post( + {**reply, "ok": True, "result": "child window closed"})) + report("child opened") + elif action == "child_close": + if self.child is not None: + self.child.close() + else: + report("no child window") + elif action == "child_state": + is_open = self.child is not None and self.child.is_open() + report(f"child.is_open() = {is_open}") + else: + self.win.post({**reply, "ok": False, "error": f"unknown action {action!r}"}) + except Exception as exc: + self.win.post({**reply, "ok": False, "error": str(exc)}) + + def on_child_message(self, msg): + if (msg or {}).get("command") == "ping": + self.child.post({"command": "pong"}) + self.win.post({"command": "ui_result", "action": "child", + "ok": True, "result": "child pinged; replied with pong"}) + + # Both demos run on a worker thread: ProgressDialog calls marshal to the UI + # thread internally, and win.post() is thread-safe, so the app stays live. + def progress_demo(self): + 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_ESTIMATED_TIME | orca.host.ui.PD_REMAINING_TIME) + outcome = "completed 40 steps" + with orca.host.ui.create_progress_dialog( + "Host Inspector", "Crunching very important numbers…", + maximum=40, style=style) as progress: + for step in range(1, 41): + time.sleep(0.05) + if not progress.update(step, f"Step {step}/40"): + outcome = f"cancelled at step {step}" + break + self.win.post({"command": "ui_result", "action": "progress", + "ok": True, "result": outcome}) + + def pulse_demo(self): + # ui.ProgressDialog(...) is the constructor form of create_progress_dialog(). + with orca.host.ui.ProgressDialog( + "Host Inspector", "Waiting for something indeterminate…", + style=orca.host.ui.PD_APP_MODAL | orca.host.ui.PD_AUTO_HIDE) as progress: + for _ in range(10): # manual single-shot pulses... + time.sleep(0.1) + if not progress.pulse("Manual pulse()…"): + break + progress.start_pulse(80, "Host-timed start_pulse()…") # ...then host-timed + time.sleep(1.5) + progress.stop_pulse() + was_open = progress.is_open() + self.win.post({"command": "ui_result", "action": "pulse", "ok": True, + "result": f"pulsed manually + via timer (is_open while shown: {was_open})"}) + + +@orca.plugin +class HostInspectorPlugin(orca.base): + def register_capabilities(self): + orca.register_capability(HostInspectorPanel)