diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 5a7223d..0807e8d 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -95,7 +95,7 @@ You can also pass the original human-readable name; `normalize_value()` canonica 3. **Handwrite a flattened version** of the stock profile. Open the stock JSON, manually copy every field from the inherited parent into the child JSON, drop the `inherits` field. Use `examples/profiles/community_merged_*.json` as the shape reference. Save in `profiles/user/`. -After v1.4.2 the workflow **surfaces** `slice.metadata.print_settings_id` in `slice_summary.txt` (next to the gcode); the **agent** (or you, if you're driving the CLI) should inspect it and compare against the preset you requested. If the names don't match, treat the slice as authoritative about what Orca actually did — Orca silently fell back to a different profile. +The workflow **surfaces** `slice.metadata.print_settings_id` in the slice result, and it is stamped into the gcode's own metadata next to the file; the **agent** (or you, if you're driving the CLI) should inspect it and compare against the preset you requested. If the names don't match, treat the slice as authoritative about what Orca actually did; it silently fell back to a different profile. ### `warning` event with `kind:"no_supports_variant"` diff --git a/adapters/hermes/install.py b/adapters/hermes/install.py index b93b208..469a043 100644 --- a/adapters/hermes/install.py +++ b/adapters/hermes/install.py @@ -473,16 +473,18 @@ def main(argv=None) -> int: return 0 # Install. - # Pre-flight (read-only): verify the run.py anchor BEFORE copying any - # files. Hermes auto-imports tools/*, so copying first and then failing - # the anchor check would leave a partial install — the form tool would - # register while run.py stays unwired. + # Pre-flight (read-only): note whether the run.py FALLBACK anchor is + # present. It no longer gates the install. The u1-form plugin publishes + # the form callback itself from its pre_gateway_dispatch hook (the primary, + # upgrade-durable path), so a missing anchor only means the belt-and- + # suspenders run.py patch is skipped, not a broken form. run_txt = run_py.read_text() - if RUN_PY_MARKER not in run_txt and RUN_PY_ANCHOR not in run_txt: - print(f"ERROR: anchor {RUN_PY_ANCHOR!r} not found in gateway/run.py.") - print(" Hermes may have changed the clarify wiring layout. Aborting") - print(" before copying anything — no files were modified.") - return 2 + _run_anchor_present = (RUN_PY_MARKER in run_txt or RUN_PY_ANCHOR in run_txt) + if not _run_anchor_present: + print(f"note: run.py anchor {RUN_PY_ANCHOR!r} not found; skipping the") + print(" optional run.py fallback patch. The u1-form plugin publishes") + print(" the form callback itself, so the form still works.") + print() print("[1/6] copy tools/ (form_gateway) + remove pre-plugin layout files") for name, src in tools_src.items(): @@ -528,22 +530,25 @@ def main(argv=None) -> int: print(f" {_install_hook_plugin(venv_python, hook_plugin_src, dry_run=a.dry_run)}") print() - print("[5/6] patch gateway/run.py (anchor-based, marker-guarded)") - status = _patch_run_py(run_py, dry_run=a.dry_run) - print(f" {status}: {run_py}") - if status == "anchor-not-found": - # Unreachable in practice (pre-flight above checks the same strings), - # kept as a belt-and-braces guard against races. - print() - print(f" ERROR: anchor {RUN_PY_ANCHOR!r} not found in gateway/run.py.") - print(" Hermes may have changed the clarify wiring layout. Aborting.") - return 2 - if status == "malformed-block": - print() - print(" ERROR: found the u1 begin marker without its end marker in run.py.") - print(" Refusing to edit a half-present block — inspect run.py, then") - print(" restore the .u1-bak backup or remove the block manually.") - return 2 + print("[5/6] patch gateway/run.py (OPTIONAL fallback; anchor-based, marker-guarded)") + if not _run_anchor_present: + print(" skipped: no anchor in this Hermes build; the plugin publish covers it") + else: + status = _patch_run_py(run_py, dry_run=a.dry_run) + print(f" {status}: {run_py}") + if status == "anchor-not-found": + # Anchor vanished between pre-flight and here (a race). Non-fatal: + # the plugin's pre_gateway_dispatch publish carries the form. + print(" note: anchor vanished since pre-flight; skipping the fallback") + print(" patch. The u1-form plugin publish covers the form.") + elif status == "malformed-block": + # A half-present prior block (begin marker, no end). _patch_run_py + # refused to write it. Non-fatal now that the plugin carries the + # form; flag it so the operator can tidy run.py. + print(" WARNING: found the u1 begin marker without its end marker in") + print(" run.py; left it untouched. Inspect run.py and restore") + print(" the .u1-bak backup or remove the block manually. The") + print(" plugin publish still carries the form regardless.") if a.dry_run: print() @@ -556,9 +561,11 @@ def main(argv=None) -> int: print(" ", _verify_hook_plugin(venv_python)) print() - print("Done. Restart the Hermes gateway so both plugins load and the patched") - print("gateway/run.py takes effect. First inbound Telegram message installs") - print("send_form on the live adapter class (watch for the") + print("Done. Restart the Hermes gateway so the plugins load (and any run.py") + print("fallback patch takes effect). Each inbound Telegram message installs") + print("send_form on the live adapter class AND publishes the form callback") + print("from the plugin, so the form keeps working across Hermes upgrades even") + print("if the run.py fallback is absent (watch for the") print("'u1-form: TelegramAdapter.send_form installed' log line).") return 0 diff --git a/adapters/hermes/plugin/__init__.py b/adapters/hermes/plugin/__init__.py index 88671f7..0ca3577 100644 --- a/adapters/hermes/plugin/__init__.py +++ b/adapters/hermes/plugin/__init__.py @@ -17,17 +17,29 @@ hook that patches ``send_form`` onto the LIVE Telegram adapter class. * ``telegram_patch.py`` — the class-level patch (inline-keyboard renderer, callback router, answers-file writer). - * The gateway's ``run.py`` anchor patch (applied by ``install.py``) - publishes a per-turn form callback into ``tools.form_gateway`` keyed - by ``agent.session_id`` — the same value Hermes' registry dispatch - passes to tool handlers. That bridge exists because generic dispatch - hands handlers only (task_id, session_id, user_task): there is no - callback kwarg and no agent reference, so an ``agent.form_callback`` - attribute alone is unreachable from a registered tool. + * The per-turn form callback published into ``tools.form_gateway``. + A registered tool cannot reach it any other way: generic dispatch + hands handlers only (task_id, session_id, user_task), with no callback + kwarg and no agent reference, so an ``agent.form_callback`` attribute + alone is unreachable. Two publishers, primary + fallback: + 1. PRIMARY (upgrade-durable): ``_pre_gateway_dispatch`` builds the + callback from its own context (live adapter + inbound chat_id + + gateway loop) and publishes it every inbound message. The plugin + lives on the persistent volume, so this survives a Hermes package + upgrade that replaces ``gateway/run.py``. + 2. FALLBACK (older Hermes): the ``run.py`` anchor patch applied by + ``install.py`` publishes the same callback from the gateway's + per-turn locals. Kept for Hermes builds whose dispatch context is + thinner than the primary path needs. Last-writer-wins per turn; + both build an equivalent callback, so they coexist safely. + Publishing only renders the operator FORM; it is not the print-start + boundary (that stays in ``u1_print_start_gate.py``, untouched), so + where the callback is wired has no bearing on model-free confirm. """ from __future__ import annotations +import asyncio import json import logging from typing import Any, Callable, Dict, Optional @@ -196,6 +208,104 @@ def _form_handler(args: Dict[str, Any], **kwargs: Any) -> str: # fires before agent dispatch on every inbound message, so send_form exists # before any form callback can run. +def _acquire_loop(): + """The gateway event loop, captured while we are ON it. + + ``pre_gateway_dispatch`` runs synchronously inside the gateway's async + dispatch coroutine, so ``get_running_loop()`` returns the loop the + adapter's ``send_form`` coroutine must be scheduled on. Returns None when + no loop runs in this thread (e.g. unit tests, or a future Hermes that + dispatches hooks off-loop) so the caller skips the plugin publish and + lets the run.py fallback carry the turn.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + +def _make_form_callback(adapter, chat_id, session_key, loop): + """Build the per-turn form callback the u1_kit / form tool invokes. + + Same contract as the gateway run.py patch's ``_form_callback_sync``, but + sourced from the plugin's own dispatch context so it survives a Hermes + upgrade that replaces run.py: render ``form_schema`` via the patched + adapter's ``send_form``, block on the form_gateway primitive, return the + answer dict. Fail-soft: every failure path returns an ``{"_error": ...}`` + dict (the driving tool surfaces it) and never raises.""" + def _callback(form_schema): + import uuid + from tools import form_gateway as _fmod + if not hasattr(adapter, "send_form"): + return {"_error": "active adapter has no send_form (plugin not loaded?)"} + form_id = uuid.uuid4().hex[:10] + _fmod.register(form_id, session_key or "", form_schema) + try: + adapter.pause_typing_for_chat(chat_id) + except Exception: + pass + try: + fut = asyncio.run_coroutine_threadsafe( + adapter.send_form( + chat_id=chat_id, form_schema=form_schema, + form_id=form_id, session_key=session_key or "", + metadata=None, + ), + loop, + ) + except Exception as exc: + _fmod.clear_session(session_key or "") + return {"_error": f"form prompt could not be scheduled: {exc}"} + try: + send_result = fut.result(timeout=15) + if not getattr(send_result, "success", False): + _fmod.clear_session(session_key or "") + return {"_error": "form prompt send failed"} + except Exception as exc: + logger.warning("u1-form: form send failed: %s", exc) + _fmod.clear_session(session_key or "") + return {"_error": f"form send exception: {exc}"} + response = _fmod.wait_for_response( + form_id, timeout=float(_fmod.get_form_timeout())) + if response is None: + return {"_timeout": True} + return response + return _callback + + +def _publish_form_callback(adapter, event, session_store) -> bool: + """Publish a per-turn form callback into tools.form_gateway from the + plugin's own dispatch context (the upgrade-durable primary path). + + Best-effort and fail-soft: any missing ingredient (no addressable chat, + not on the gateway loop, form_gateway absent) just returns False and + leaves the run.py fallback, where present, to carry the turn. Returns + True when a callback was published.""" + source = getattr(event, "source", None) + chat_id = getattr(source, "chat_id", None) + if source is None or chat_id is None: + return False # internal / None event; no chat to address + loop = _acquire_loop() + if loop is None: + return False # not on the gateway loop; run.py fallback carries it + try: + from tools import form_gateway + except ImportError: + return False + session_key = "" + try: + gen = getattr(session_store, "_generate_session_key", None) + if gen is not None: + session_key = gen(source) or "" + except Exception: + # A private-API drift just means we key on "" -> form_gateway's + # __default__ (latest registration) resolves it for a single + # operator, exactly as the run.py path already relies on. + session_key = "" + form_gateway.set_form_callback( + session_key, _make_form_callback(adapter, chat_id, session_key, loop)) + return True + + def _pre_gateway_dispatch(**kwargs: Any) -> None: try: from . import telegram_patch @@ -223,6 +333,19 @@ def _pre_gateway_dispatch(**kwargs: Any) -> None: except Exception: logger.warning("u1-form: proactive callback-handler " "registration failed", exc_info=True) + # PRIMARY form-callback publish (upgrade-durable). Builds + # the callback from this dispatch context and publishes it + # into form_gateway so the form works even when a Hermes + # upgrade has wiped the run.py fallback patch. Fail-soft: + # a False return (no chat / off-loop) just leaves the + # run.py fallback to carry the turn where it is present. + try: + _publish_form_callback( + adapter, kwargs.get("event"), + kwargs.get("session_store")) + except Exception: + logger.warning("u1-form: form-callback publish failed", + exc_info=True) except Exception: logger.warning("u1-form: pre_gateway_dispatch patch attempt failed", exc_info=True) diff --git a/adapters/hermes/tools/form_gateway.py b/adapters/hermes/tools/form_gateway.py index 721b251..60b6720 100644 --- a/adapters/hermes/tools/form_gateway.py +++ b/adapters/hermes/tools/form_gateway.py @@ -10,11 +10,9 @@ 1. **Native UI** (button-driven): adapter renders the schema as inline keyboards, handles taps in-place, and on Submit calls ``resolve_gateway_form(form_id, answer_dict)`` to unblock the agent. - 2. **Text fallback**: adapter shows the schema's ``text_fallback`` field - and the user types the one-line answer; the gateway's - ``_handle_message`` intercept resolves with the typed line (sets - ``answer = {"_text": ""}``); the form tool can route through - ``u1_form.parse_answers`` itself. + 2. **Text fallback**: the schema also carries a ``text_fallback`` field so + an adapter without native UI can present a typed one-line path to the + same answer. Module-level state (same shape as ``clarify_gateway``) so platform adapters call ``resolve_gateway_form`` without holding a back-reference to the @@ -49,15 +47,6 @@ class _FormEntry: schema: Dict[str, Any] event: threading.Event = field(default_factory=threading.Event) response: Optional[Dict[str, Any]] = None - awaiting_text: bool = False # set when adapter falls back to text intake - - def signature(self) -> Dict[str, object]: - return { - "form_id": self.form_id, - "session_key": self.session_key, - "schema_version": self.schema.get("version"), - "fields": [f.get("id") for f in self.schema.get("fields", [])], - } _lock = threading.RLock() @@ -151,41 +140,6 @@ def cancel_gateway_form(form_id: str) -> bool: return resolve_gateway_form(form_id, {"_cancelled": True}) -def get_pending_for_session(session_key: str) -> Optional[_FormEntry]: - """Return the OLDEST pending form entry for a session, or None. - - Used by the text-fallback intercept in ``_handle_message`` — when a form - is awaiting a free-form text response (operator typed instead of - tapping), the next user message in that session is captured. - """ - with _lock: - ids = _session_index.get(session_key) or [] - for fid in ids: - entry = _entries.get(fid) - if entry is None: - continue - if entry.awaiting_text: - return entry - return None - - -def mark_awaiting_text(form_id: str) -> bool: - """Flip an entry into text-capture mode (operator typed instead of tapping).""" - with _lock: - entry = _entries.get(form_id) - if entry is None: - return False - entry.awaiting_text = True - return True - - -def has_pending(session_key: str) -> bool: - """True when this session has at least one pending form entry.""" - with _lock: - ids = _session_index.get(session_key) or [] - return any(_entries.get(fid) is not None for fid in ids) - - def clear_session(session_key: str) -> int: """Drop every pending form for a session (e.g. on session boundary). @@ -280,11 +234,6 @@ def get_notify(session_id: str = "") -> Optional[Any]: return get_form_callback(session_id) -def set_notify(session_id: str, callback: Any) -> None: - """Publish the form callback for a session. Alias of set_form_callback.""" - set_form_callback(session_id, callback) - - def invoke_form(session_id: str, form_schema: Dict[str, Any]) -> Dict[str, Any]: """Render ``form_schema`` to the operator and block until they submit, returning the answer dict. diff --git a/adapters/hermes/tools/u1_kit_tool.py b/adapters/hermes/tools/u1_kit_tool.py index d4ab363..f6870ba 100644 --- a/adapters/hermes/tools/u1_kit_tool.py +++ b/adapters/hermes/tools/u1_kit_tool.py @@ -395,11 +395,6 @@ def u1_kit_tool( return f"{_passthrough}\n{_summary}" if _passthrough else _summary -def check_u1_kit_requirements() -> bool: - """u1_kit needs the workflow script at the expected deploy path.""" - return Path(DEFAULT_WORKFLOW_SCRIPT).exists() - - # ============================================================================= # Function-calling tool schema # ============================================================================= @@ -448,4 +443,4 @@ def check_u1_kit_requirements() -> bool: # a runtime-registered toolset can never satisfy). The u1-form plugin registers # u1_kit as its own OFFERED toolset via ctx.register_tool (see # adapters/hermes/plugin/__init__.py register()). This module just exposes the -# handler (u1_kit_tool) + schema (U1_KIT_SCHEMA) + check (check_u1_kit_requirements). +# handler (u1_kit_tool) + schema (U1_KIT_SCHEMA). diff --git a/adapters/telegram/u1_form_telegram.py b/adapters/telegram/u1_form_telegram.py index cb8b471..08a731a 100644 --- a/adapters/telegram/u1_form_telegram.py +++ b/adapters/telegram/u1_form_telegram.py @@ -266,9 +266,14 @@ def _stepper_state(form: dict[str, Any], field: dict[str, Any]): base = ((form["schema"].get("temp_current_by_material") or {}).get(mat) or {}).get(field["id"]) rng = ((form["schema"].get("temp_range_by_material") or {}).get(mat) or {}).get(field["id"]) lo, hi = (rng[0], rng[1]) if rng else (cfg.get("min", 0), cfg.get("max", 300)) - else: + elif field.get("advanced"): base = _num(_resolved_for_selected_profile(form).get(field["id"])) lo, hi = cfg.get("min", 0), cfg.get("max", 999) + else: + # A plain top-level count (e.g. Copies): the dial starts at the field's + # own default; there is no profile to resolve against. + base = _num(field.get("default")) + lo, hi = cfg.get("min", 0), cfg.get("max", 999) return target, base, (lo, hi), steps, unit @@ -304,6 +309,14 @@ def _paginate(opts: list, page: int, size: int) -> tuple[list, int, int]: def _selection_label_for(form: dict[str, Any], field: dict[str, Any]) -> str: """Human-readable echo of a field's current selection (for the review card).""" + if field.get("stepper"): + # A stepper carries its value in form["steps"], not an option index. + t = (form.get("steps") or {}).get(field["id"]) + unit = (field.get("stepper") or {}).get("unit", "") + if t is not None: + return f"{t}{unit}" + d = field.get("default") + return f"{d}{unit} (default)" if d not in (None, "") else "not set" val = form["selections"].get(field["id"]) opts = field["options"] if field["type"] == "multi_select": @@ -384,6 +397,32 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], # its alternatives pack two-up beneath. Keeps each setting a scannable unit # instead of a tall column of every option (live 2026-07-15: the flat stack # read as "a fat chunk of info"). Advanced fields never paginate (<=7 opts). + # Numeric controls render as a +/- STEPPER so the operator dials ANY exact + # value in one row. A full-width header shows the current value; the row + # below is the steps ([5,1] -> -5/-/+/+5, [1] -> -/+), clamped to range. + # State lives in form["steps"]. This handles both an advanced override + # (base = the profile's resolved value, tap the header to keep it) and a + # plain top-level count like Copies (base = the field default, no profile + # language, tap the header to reset to the default). Works on a grouped + # screen too, so Copies sits inline with the other setup questions. + if field.get("stepper"): + target, base, _rng, steps, unit = _stepper_state(form, field) + name = (field.get("label", field["id"]).replace(" temperature", "") + .replace(" density", "").replace(" loops", "").replace(" layers", "")) + if not field.get("advanced"): + val = target if target is not None else base + head = f"{name}: {val}{unit}" + elif target is None: + head = f"{name}: keep profile" + (f" ({base}{unit})" if base is not None else "") + else: + head = f"{name}: {target}{unit}" + (f" · profile {base}{unit}" if base is not None else "") + rows.append([{"text": head, "callback_data": f"T:{fi}:k"}]) + minus = [{"text": (f"−{s}" if s > 1 else "−"), "callback_data": f"T:{fi}:-{s}"} + for s in sorted(steps, reverse=True)] + plus = [{"text": (f"+{s}" if s > 1 else "+"), "callback_data": f"T:{fi}:{s}"} + for s in sorted(steps)] + rows.append(minus + plus) + return rows if field.get("advanced"): # A full-width HEADER (the setting name + its current value; tap = keep # the profile default) followed by the alternatives as BARE values three- @@ -391,26 +430,6 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], # repeat it (live 2026-07-15: "Infill 10% / Infill 15% / ..." doubled the # text and truncated wider labels at two-up). The default option is first # in every advanced field, so the header row lands before the values. - if field.get("stepper"): - # A numeric control (temperature, infill, walls, shells) renders as a - # +/- STEPPER so the operator dials ANY exact value in one row. A - # full-width header shows the setting and the current target (tap it - # to keep the profile); the row below is the steps ([5,1] -> -5/-/+/+5, - # [1] -> -/+), clamped to range. State lives in form["steps"]. - target, base, _rng, steps, unit = _stepper_state(form, field) - name = (field.get("label", field["id"]).replace(" temperature", "") - .replace(" density", "").replace(" loops", "").replace(" layers", "")) - if target is None: - head = f"{name}: keep profile" + (f" ({base}{unit})" if base is not None else "") - else: - head = f"{name}: {target}{unit}" + (f" · profile {base}{unit}" if base is not None else "") - rows.append([{"text": head, "callback_data": f"T:{fi}:k"}]) - minus = [{"text": (f"−{s}" if s > 1 else "−"), "callback_data": f"T:{fi}:-{s}"} - for s in sorted(steps, reverse=True)] - plus = [{"text": (f"+{s}" if s > 1 else "+"), "callback_data": f"T:{fi}:{s}"} - for s in sorted(steps)] - rows.append(minus + plus) - return rows alts: list[dict[str, str]] = [] for oi, opt in enumerate(field["options"]): oid = _opt_id(opt) diff --git a/scripts/u1_camera.py b/scripts/u1_camera.py index 44d964a..eb9c058 100755 --- a/scripts/u1_camera.py +++ b/scripts/u1_camera.py @@ -146,12 +146,46 @@ def start_monitor(host: str, port: int, interval: float) -> list[dict]: ) -def fetch_monitor(host: str, port: int, output: str) -> dict: - image = http_get(f"{moonraker_base(host, port)}/server/files/camera/monitor.jpg", timeout=10) +def _looks_complete_jpeg(image: bytes) -> bool: + """A full JPEG starts with SOI (FFD8FF) and ends with EOI (FFD9). The U1 + camera occasionally returns a frame truncated a few hundred bytes short of + the declared length, which passes the SOI check but is missing the EOI.""" + return (len(image) > 1024 and image[:3] == b"\xff\xd8\xff" + and b"\xff\xd9" in image[-16:]) + + +def fetch_monitor(host: str, port: int, output: str, attempts: int = 3) -> dict: + """Fetch monitor.jpg, retrying on a truncated / transient read. + + The U1's MJPEG endpoint intermittently closes the connection a few hundred + bytes short of the declared Content-Length (http.client.IncompleteRead) or + hands back a JPEG missing its end-of-image marker. A single short frame must + NOT abort a gated start (live 2026-07-18: a 487-byte-short frame refused a + real print), so re-GET a few times before giving up. Persistent failure + still raises so the caller stays fail-closed.""" + url = f"{moonraker_base(host, port)}/server/files/camera/monitor.jpg" + last_exc: Exception | None = None + image = b"" + for attempt in range(1, attempts + 1): + try: + image = http_get(url, timeout=10) + except Exception as exc: # IncompleteRead, timeout, connection reset + last_exc, image = exc, b"" + if _looks_complete_jpeg(image): + break + if attempt < attempts: + time.sleep(0.5 * attempt) + else: + if last_exc is not None: + raise last_exc + raise ValueError( + f"camera returned {len(image)} bytes but never a complete JPEG " + f"in {attempts} attempts (truncated frame)") out = Path(output) out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(image) - return {"output": str(out), "bytes": len(image), "jpeg_magic": image[:3] == b"\xff\xd8\xff"} + return {"output": str(out), "bytes": len(image), + "jpeg_magic": image[:3] == b"\xff\xd8\xff"} def capture_photo(host: str, port: int, output: str, diff --git a/scripts/u1_config.py b/scripts/u1_config.py index fff6bbb..01411d0 100755 --- a/scripts/u1_config.py +++ b/scripts/u1_config.py @@ -166,9 +166,6 @@ def __repr__(self) -> str: return repr(get_config_path()) def __eq__(self, other: object) -> bool: return get_config_path() == other - @property - def exists_path(self) -> Path: - return get_config_path() def exists(self) -> bool: return get_config_path().exists() def read_text(self, *args, **kwargs) -> str: diff --git a/scripts/u1_form.py b/scripts/u1_form.py index ee8ce98..d98bc3a 100644 --- a/scripts/u1_form.py +++ b/scripts/u1_form.py @@ -114,8 +114,10 @@ "wall_loops", {"2": "2", "3": "3", "4": "4"}), ("brim", "Brim", [("default", "Brim: profile default"), ("off", "Brim: off"), - ("auto", "Brim: auto")], - "brim_type", {"off": "no_brim", "auto": "auto_brim"}), + ("outer", "Brim: outer"), ("auto", "Brim: auto"), + ("ears", "Brim: mouse ears")], + "brim_type", {"off": "no_brim", "outer": "outer_only", + "auto": "auto_brim", "ears": "brim_ears"}), ("fuzzy", "Fuzzy skin", [("default", "Fuzzy skin: profile default"), ("off", "Fuzzy skin: off"), ("on", "Fuzzy skin: on (outer walls)")], @@ -180,7 +182,7 @@ "infill_pattern": {"grid": "grid", "gyroid": "gyroid", "honeycomb": "honeycomb", "triangles": "triangles", "cubic": "cubic"}, "walls": {"2": "2", "3": "3", "4": "4"}, - "brim": {"off": "off", "auto": "auto"}, + "brim": {"off": "off", "outer": "outer", "auto": "auto", "ears": "mouse ears"}, "fuzzy": {"off": "off", "on": "on"}, "top_shell": {"3": "3", "4": "4", "5": "5"}, "bottom_shell": {"3": "3", "4": "4"}, @@ -198,7 +200,8 @@ def _display_resolved(fid: str, raw: Any) -> str: if fid in ("infill", "infill_pattern", "walls", "top_shell", "bottom_shell"): return s if fid == "brim": - return "off" if s in ("", "no_brim") else "on" + return {"no_brim": "off", "outer_only": "outer", "auto_brim": "auto", + "brim_ears": "mouse ears"}.get(s, "off" if s == "" else s) if fid == "fuzzy": return "off" if s in ("", "none") else "on" if fid == "one_wall_top": @@ -246,31 +249,39 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str ("nozzle_temp", "Nozzle temperature", "nozzle_temperature", (("default", "Nozzle: profile default"),) + tuple((str(v), f"Nozzle {v}°C") for v in _NOZZLE_SPAN)), + ("nozzle_temp_first", "First-layer nozzle", "nozzle_temperature_initial_layer", + (("default", "First-layer nozzle: profile default"),) + + tuple((str(v), f"First layer {v}°C") for v in _NOZZLE_SPAN)), ("bed_temp", "Bed temperature", "hot_plate_temp", (("default", "Bed: profile default"), ("0", "Bed: off")) + tuple((str(v), f"Bed {v}°C") for v in _BED_SPAN)), ) _TEMP_BY_ID = {fid: base_key for fid, _lbl, base_key, _opts in TEMP_FIELDS} -# Numeric advanced controls render as a +/- STEPPER (not an option grid) so the -# operator dials any exact value. steps = the step buttons offered ([5, 1] gives +# Numeric controls render as a +/- STEPPER (not an option grid) so the operator +# dials any exact value. steps = the step buttons offered ([5, 1] gives # -5/-/+/+5; [1] gives -/+). unit is the display suffix; for a process override # it's also the value suffix ("%" for infill, "" for counts). min/max bound the # dial. Temperature fields override their bound per material via -# temp_range_by_material; the process fields use these fixed bounds. +# temp_range_by_material; the process fields use these fixed bounds. "quantity" +# is the odd one out: a plain top-level count (copies), not a profile override, +# so it dials from the field default (1) with no "keep profile" language. _STEPPER = { "nozzle_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 300}, + "nozzle_temp_first": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 300}, "bed_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 120}, "infill": {"steps": (5, 1), "unit": "%", "min": 5, "max": 100}, "walls": {"steps": (1,), "unit": "", "min": 1, "max": 8}, "top_shell": {"steps": (1,), "unit": "", "min": 0, "max": 15}, "bottom_shell": {"steps": (1,), "unit": "", "min": 0, "max": 15}, + "quantity": {"steps": (5, 1), "unit": "", "min": 1, "max": 50}, } # Bare labels for the temp buttons under the per-setting header (mirrors # _ADVANCED_SHORT for the process controls). _ADVANCED_SHORT.update({ "nozzle_temp": {str(v): f"{v}°C" for v in _NOZZLE_SPAN}, + "nozzle_temp_first": {str(v): f"{v}°C" for v in _NOZZLE_SPAN}, "bed_temp": {"0": "off", **{str(v): f"{v}°C" for v in _BED_SPAN}}, }) @@ -285,7 +296,6 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str ("1", "1 copy"), ("2", "2 copies"), ("3", "3 copies"), ("4", "4 copies"), ("6", "6 copies"), ("9", "9 copies"), ) -_QUANTITY_IDS = {qid for qid, _ in QUANTITY_OPTIONS} _TOOL_RE = re.compile(r"^t([0-9])$", re.IGNORECASE) @@ -296,7 +306,7 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str # collide with parts/profile numbers. _ADV_INFILL_RE = re.compile(r"^infill\s*(\d{1,3})\s*%?$", re.IGNORECASE) _ADV_WALLS_RE = re.compile(r"^walls?\s*(\d)$", re.IGNORECASE) -_ADV_BRIM_RE = re.compile(r"^brim\s*(off|auto|on)$", re.IGNORECASE) +_ADV_BRIM_RE = re.compile(r"^brim\s*(off|auto|on|outer|ears|mouse\s*ears?)$", re.IGNORECASE) _ADV_FUZZY_RE = re.compile(r"^fuzzy(?:[\s-]*skin)?(?:\s+(on|off))?$", re.IGNORECASE) # NOTE: bare "grid" stays an infill-pattern token (pre-existing grammar); # support style needs the word: "tree supports" / "grid supports" / "tree". @@ -459,12 +469,13 @@ def parse_answers(line: str, spec: dict[str, Any]) -> dict[str, Any]: m = _QTY_RE.match(tok) if m: q = int(next(g for g in m.groups() if g)) - if str(q) not in _QUANTITY_IDS: - errors.append( - f"quantity {q} not offered " - f"(have {', '.join(i for i, _ in QUANTITY_OPTIONS)})") - else: + _qcfg = _STEPPER["quantity"] + if _qcfg["min"] <= q <= _qcfg["max"]: _set(values, "quantity", q, errors, tok) + else: + errors.append( + f"quantity {q} out of range " + f"({_qcfg['min']}-{_qcfg['max']})") continue # Advanced overrides (v2.3), only when the spec offers them. Each maps @@ -500,8 +511,9 @@ def _adv_set(field_id: str, option_id: str) -> bool: continue m = _ADV_BRIM_RE.match(tok) if m: - _adv_set("brim", "auto" if m.group(1).lower() == "on" - else m.group(1).lower()) + _g = m.group(1).lower().replace(" ", "") + _adv_set("brim", {"on": "outer", "mouseears": "ears", + "mouseear": "ears"}.get(_g, _g)) continue m = _ADV_FUZZY_RE.match(tok) if m: @@ -624,7 +636,7 @@ def _finalize(values: dict[str, Any], spec: dict[str, Any], errors: list[str], except (TypeError, ValueError): errors.append(f"invalid {_tfid} value {_traw!r}") continue - if _tbase == "nozzle_temperature": + if _tbase in ("nozzle_temperature", "nozzle_temperature_initial_layer"): _tok, (_tlo, _thi) = u1_temps.nozzle_in_range(_t_material, _tiv), u1_temps.nozzle_range(_t_material) else: _tok, (_tlo, _thi) = u1_temps.bed_in_range(_t_material, _tiv), u1_temps.bed_range(_t_material) @@ -1082,13 +1094,15 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N "options": [{"id": s, "label": _sup_lbl.get(s, s)} for s in _sup_ordered], "default": "no-supports"}) # Quantity (v2.3): copies of the lone part — only when the workflow - # offered it (single-part jobs). Rides the setup group so it doesn't add - # a screen; compact packs the six options two per row. + # offered it (single-part jobs). Rides the setup group so it doesn't add a + # screen. Renders as a +/- stepper (the old 1-9 option grid capped too low); + # options are kept inert so the default resolves and text mode still parses. if spec.get("offer_quantity"): fields.append({"id": "quantity", "type": "single_select", - "label": "Quantity", "group": _GROUP, "compact": True, + "label": "Copies", "group": _GROUP, "options": [{"id": i, "label": l} for i, l in QUANTITY_OPTIONS], - "default": "1", "required": False}) + "default": "1", "required": False, + "stepper": _STEPPER["quantity"]}) profiles = spec.get("profiles", []) if profiles: _pfield = {"id": "profile", "type": "single_select", "label": "Print profile", @@ -1175,6 +1189,7 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N # the renderer dependency-free — it just filters to these bounds. schema["temp_range_by_material"] = { str(m): {"nozzle_temp": list(u1_temps.nozzle_range(m)), + "nozzle_temp_first": list(u1_temps.nozzle_range(m)), "bed_temp": list(u1_temps.bed_range(m))} for m in _tcm } @@ -1272,16 +1287,21 @@ def parse_answers_json(obj: dict[str, Any], spec: dict[str, Any]) -> dict[str, A errors.append(f"unknown action {obj['action']!r}") # quantity (v2.3) — honored only when the spec offered it (single-part - # jobs). Option ids are strings ("1".."9"); a widget can only send an - # offered id, so anything else fails loudly. + # jobs). The stepper sends an absolute count, so validate against the + # control's range rather than a fixed option list. if spec.get("offer_quantity") and obj.get("quantity") not in (None, ""): - q = str(obj["quantity"]).strip() - if q in _QUANTITY_IDS: - values["quantity"] = int(q) + _qcfg = _STEPPER["quantity"] + try: + q = int(round(float(str(obj["quantity"]).strip()))) + except (TypeError, ValueError): + errors.append(f"invalid quantity {obj['quantity']!r}") else: - errors.append( - f"quantity {obj['quantity']!r} not offered " - f"(have {', '.join(i for i, _ in QUANTITY_OPTIONS)})") + if _qcfg["min"] <= q <= _qcfg["max"]: + values["quantity"] = q + else: + errors.append( + f"quantity {q} out of range " + f"({_qcfg['min']}-{_qcfg['max']})") # Advanced overrides (v2.3) — honored only when the spec offered them. # "default" (or absent) = no override; anything else must be an offered diff --git a/scripts/u1_kit_workflow.py b/scripts/u1_kit_workflow.py index 02658e2..c8925d5 100644 --- a/scripts/u1_kit_workflow.py +++ b/scripts/u1_kit_workflow.py @@ -2145,9 +2145,12 @@ def _first_int(_d, _k): continue _entry = {} _n = _first_int(_flat, "nozzle_temperature") + _nf = _first_int(_flat, "nozzle_temperature_initial_layer") _b = _first_int(_flat, "hot_plate_temp") if _n is not None: _entry["nozzle_temp"] = _n + if _nf is not None: + _entry["nozzle_temp_first"] = _nf if _b is not None: _entry["bed_temp"] = _b if _entry: @@ -5754,10 +5757,23 @@ def _commit_kit_legacy(args, request_id, operator, out_dir, events_file, # Persist plate + plan state BEFORE any bed-clear routing so _action_start # (below) reads plate_filename / gcode_hash / tool / material from state. + # + # A fresh plan supersedes any bed-clear prompt armed for a PRIOR slice + # (request ids are content-derived, so a re-slice reuses the request). Drop + # that stale pending NOW, before the bed capture below: a capture failure + # must not leave an old prompt the operator could confirm against this new + # plan (live 2026-07-18: a short camera frame stranded a rev-4 prompt while + # the plan had advanced to rev 5, so the operator's YES redeemed the old + # one and the Stage-2 gate refused). On capture success _action_start + # re-arms a fresh one. Clearing only ever tightens the gate (a missing + # pending refuses), so it is safe. + _cleared_safety = dict((u1_request.read_request(request_id) or {}).get("safety") or {}) + _cleared_safety.pop("pending_bed_clear_start", None) persist_phase = "awaiting_confirm" if action == "start" else "complete" u1_request.write_request( request_id, phase=persist_phase, + safety=_cleared_safety, kit={"parts": kit["parts"], "part_count": kit["part_count"], "selected": [p["part_id"] for p in selected], "orient_mode": values.get("orient")}, plates=plates_state, diff --git a/scripts/u1_slice_workflow.py b/scripts/u1_slice_workflow.py index dab6681..bcce239 100755 --- a/scripts/u1_slice_workflow.py +++ b/scripts/u1_slice_workflow.py @@ -231,8 +231,6 @@ def _audit(request_id: str, event: str, operator: str, **details): return None -DEFAULT_OUT_BASE=ROOT/'artifacts'/'slice_workflow' - # Mirrored events file for harness/recovery. Set by run_workflow once out_dir # is known. Both --json-events stdout AND the human-readable mode write to # this file, so the file is always a complete audit trail regardless of how @@ -570,49 +568,6 @@ def _shell_quote(s: str) -> str: return "'" + s.replace("'", "'\\''") + "'" -def _cmd_prefix(script_path: str, model_path: str, args) -> str: - """Build the cumulative invocation prefix from args that are already set. - Each subsequent need_input's next_command extends this prefix with one - more flag + the option's value. - - v2.0 Phase 2: includes --request-id so agent re-invocations pin to the - same on-disk request. Recovery via content hash still works without it - but explicit --request-id is more robust against context loss + faster - (no hash recompute, no directory scan).""" - parts = ['python3', script_path, _shell_quote(model_path), '--json-events'] - # Pin request_id so chained next_command invocations all hit the same - # request folder. This is what makes the agent's flow context-loss-resistant. - if getattr(args, 'request_id', None): - parts += ['--request-id', args.request_id] - if getattr(args, 'orient', None): - parts += ['--orient', args.orient] - if getattr(args, 'tool', None): - parts += ['--tool', args.tool] - if getattr(args, 'material', None): - parts += ['--material', _shell_quote(args.material)] - if getattr(args, 'profile', None): - parts += ['--profile', _shell_quote(args.profile)] - if getattr(args, 'supports', None): - parts += ['--supports', args.supports] - if getattr(args, 'nozzle', None) and args.nozzle != '0.4': - parts += ['--nozzle', args.nozzle] - return ' '.join(parts) - -def triage_stl(stl: Path)->dict[str,Any]: - tris=parse_stl(stl); xmin,xmax,ymin,ymax,zmin,zmax=bbox(tris) - vol=(xmax-xmin)*(ymax-ymin)*(zmax-zmin)/1000.0 - return {'dims_mm':[round(xmax-xmin,2), round(ymax-ymin,2), round(zmax-zmin,2)], 'tris': int(tris.shape[0]), 'bbox_volume_cm3': round(vol,2)} - -def choose_default(options: list[dict[str,Any]], supplied: str|None=None): - if supplied: - for o in options: - if supplied == o.get('value') or supplied.lower() in str(o.get('label','')).lower(): return o.get('value') - return supplied - for o in options: - if o.get('recommended'): return o.get('value') - return options[0].get('value') if options else None - - def promote_to_supports_variant(profile_value: str) -> str | None: """Pre-v1.5.1 behavior — superseded by apply_supports_override below. @@ -969,7 +924,8 @@ def apply_profile_overrides(process_path: Path, overrides: dict[str, str], # patch the FILAMENT profile — the U1 stores every temp as a single-element list # (nozzle_temperature=['220']), so each override is written as [''] across # the key's siblings to keep the filament self-consistent. -FILAMENT_OVERRIDE_KEYS = ('nozzle_temperature', 'hot_plate_temp') +FILAMENT_OVERRIDE_KEYS = ('nozzle_temperature', 'nozzle_temperature_initial_layer', + 'hot_plate_temp') # When bed temp is overridden, every plate variant must move together — Orca # stamps whichever the active bed_type selects, and _materialize_flat_filament @@ -1008,13 +964,21 @@ def apply_filament_overrides(filament_path: Path, overrides: dict[str, Any], except (TypeError, ValueError): continue clean[k] = (u1_temps.clamp_nozzle(material, iv) - if k == 'nozzle_temperature' else u1_temps.clamp_bed(material, iv)) + if k in ('nozzle_temperature', 'nozzle_temperature_initial_layer') + else u1_temps.clamp_bed(material, iv)) if not clean: return filament_path data = _flatten_filament_profile(filament_path) if 'nozzle_temperature' in clean: for key in _NOZZLE_SIBLINGS: data[key] = [str(clean['nozzle_temperature'])] + if 'nozzle_temperature_initial_layer' in clean: + # First-layer nozzle overrides ONLY the initial layer. The main-nozzle + # write above set both siblings, so this lets the operator dial a hotter + # (or cooler) first layer for adhesion without changing the rest of the + # print. Applied after the sibling write so it wins. + data['nozzle_temperature_initial_layer'] = [ + str(clean['nozzle_temperature_initial_layer'])] if 'hot_plate_temp' in clean: for key in _BED_SIBLINGS: data[key] = [str(clean['hot_plate_temp'])] @@ -1693,36 +1657,6 @@ def _bboxes_differ(stl_a: Path, stl_b: Path, tol_mm: float = 0.5) -> bool: except Exception: return True # if we can't compare, err toward "show both" -def _trim_option_payload(opts: list[dict[str, Any]], keep_keys: tuple[str, ...] = ('label', 'value', 'recommended', 'material', 'loaded', 'supports_status', 'source', 'has_supports')) -> list[dict[str, Any]]: - """Strip large/internal fields from need_input option payloads. Notably - drops 'path' from profile options (multi-KB file paths the agent doesn't - need — workflow resolves by value internally). Token-saving for --json-events - consumers; reduces typical preset event from ~3KB to ~500B.""" - return [{k: v for k, v in o.items() if k in keep_keys} for o in opts] - -def write_slice_summary(out_dir: Path, slice_res: dict[str, Any]) -> Path: - """Write a terse text summary alongside the gcode. Agents should read this - instead of re-parsing the gcode (gcode reads inline 12KB of base64 thumbnail - data on every read; this is ~300 bytes).""" - meta = slice_res.get('metadata', {}) - moonraker = (slice_res.get('moonraker_metadata') or {}) if isinstance(slice_res.get('moonraker_metadata'), dict) else {} - summary_path = out_dir / 'slice_summary.txt' - lines = [ - f"time = {slice_res.get('time', '?')}", - f"weight_g = {slice_res.get('weight_g', '?')}", - f"layer_count = {moonraker.get('layer_count', '?')}", - f"layer_height = {meta.get('layer_height', '?')}", - f"profile = {meta.get('print_settings_id', '?')}", - f"material = {meta.get('filament_type', '?')}", - f"tool_idx = {slice_res.get('tool_idx', '?')}", - f"tool_rewrites= {slice_res.get('tool_rewrites', 0)}", - f"thumbnails = {slice_res.get('thumbnails', {}).get('ok', False)}", - f"warnings = {', '.join(slice_res.get('warnings', [])) or 'none'}", - f"gcode = {slice_res.get('gcode', '?')}", - ] - summary_path.write_text('\n'.join(lines) + '\n') - return summary_path - def run_workflow(args)->dict[str,Any]: """v1.4.6 flow: dual-render (source + auto-oriented if different) BEFORE asking questions. User sees both orientation options visually before diff --git a/scripts/u1_toolmap.py b/scripts/u1_toolmap.py index 0a5eac2..95cec0c 100755 --- a/scripts/u1_toolmap.py +++ b/scripts/u1_toolmap.py @@ -28,7 +28,6 @@ def _default_map_path() -> Path: EXTRUDER_NAMES = ["extruder", "extruder1", "extruder2", "extruder3"] EXTRUDER_CHANNEL = {"extruder": 0, "extruder1": 1, "extruder2": 2, "extruder3": 3} -CHANNEL_EXTRUDER = {v: k for k, v in EXTRUDER_CHANNEL.items()} _UNKNOWN_MATERIALS = {"", "UNKNOWN", "NONE"} diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 7d00064..55d62ad 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -564,6 +564,186 @@ class _Gw: assert mod._pre_gateway_dispatch(gateway=_Gw(), event=None) is None +# --------------------------------------------------------------------------- # +# PRIMARY (upgrade-durable) form-callback publish. The plugin publishes the +# form callback from pre_gateway_dispatch itself, from its own dispatch +# context (live adapter + inbound chat_id + gateway loop), so the form keeps +# working after a Hermes package upgrade wipes the run.py fallback patch. +# These prove the publish happens with NO run.py involvement. +# --------------------------------------------------------------------------- # + +class _Src: + def __init__(self, chat_id="c1"): + self.chat_id = chat_id + + +class _Event: + def __init__(self, chat_id="c1"): + self.source = _Src(chat_id) + + +class _SessionStore: + def _generate_session_key(self, source): + return "sess-" + str(getattr(source, "chat_id", "?")) + + +def _telegram_gw(adapter): + class _Gw: + adapters = {"telegram": adapter} + return _Gw() + + +def test_pre_dispatch_publishes_form_callback_without_run_py(monkeypatch, tmp_path): + """After an inbound message, form_gateway resolves a callback for the + session, wired by the plugin, not by any run.py patch.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _patch_ensure(monkeypatch) + fg = _fake_form_gateway(monkeypatch) + monkeypatch.setattr(mod, "_acquire_loop", lambda: object()) # sentinel loop + + class _Adapter: + def send_form(self, **kw): + return None + + def _u1_ensure_cb_handler(self): + pass + + mod._pre_gateway_dispatch(gateway=_telegram_gw(_Adapter()), + event=_Event("c1"), session_store=_SessionStore()) + assert fg.get_form_callback("sess-c1") is not None, ( + "pre_gateway_dispatch must publish the form callback keyed by session") + # __default__ also set, so the kit path (which resolves via the contextvar + # session key, not agent.session_id) keeps working on a mismatch. + assert fg.get_form_callback("some-other-session") is not None + + +def test_pre_dispatch_publish_noop_for_internal_event(monkeypatch, tmp_path): + """event=None (internal / non-addressable) publishes nothing and never + crashes; the existing cancel-handler path still runs.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _patch_ensure(monkeypatch) + fg = _fake_form_gateway(monkeypatch) + monkeypatch.setattr(mod, "_acquire_loop", lambda: object()) + + class _Adapter: + def send_form(self, **kw): + return None + + def _u1_ensure_cb_handler(self): + pass + + mod._pre_gateway_dispatch(gateway=_telegram_gw(_Adapter()), + event=None, session_store=_SessionStore()) + assert fg.get_form_callback("") is None # registry stays empty + + +def test_pre_dispatch_publish_skipped_off_loop(monkeypatch, tmp_path): + """With no running gateway loop (older Hermes / off-loop dispatch) the + plugin skips its publish and leaves the run.py fallback to carry it.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _patch_ensure(monkeypatch) + fg = _fake_form_gateway(monkeypatch) + monkeypatch.setattr(mod, "_acquire_loop", lambda: None) + + class _Adapter: + def send_form(self, **kw): + return None + + def _u1_ensure_cb_handler(self): + pass + + mod._pre_gateway_dispatch(gateway=_telegram_gw(_Adapter()), + event=_Event("c1"), session_store=_SessionStore()) + assert fg.get_form_callback("sess-c1") is None + + +def _rich_form_gateway(monkeypatch): + """tools.form_gateway fake with the full deterministic surface the + published callback drives (register/wait/clear/timeout).""" + fg = types.ModuleType("tools.form_gateway") + fg.events = [] + fg.register = lambda fid, sk, sch: fg.events.append(("register", fid, sk)) + fg.clear_session = lambda sk: fg.events.append(("clear", sk)) + fg.get_form_timeout = lambda: 1.0 + fg.wait_for_response = lambda fid, timeout: {"tool": "T0"} + tools_pkg = types.ModuleType("tools") + tools_pkg.form_gateway = fg + monkeypatch.setitem(sys.modules, "tools", tools_pkg) + monkeypatch.setitem(sys.modules, "tools.form_gateway", fg) + return fg + + +def test_make_form_callback_renders_and_returns_answer(monkeypatch, tmp_path): + """The published callback: register -> schedule send_form on the loop -> + confirm the send succeeded -> block for the operator's answer -> return.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + fg = _rich_form_gateway(monkeypatch) + sent = {} + + class _Adapter: + def send_form(self, **kw): + sent.update(kw) + return "coro" # only handed to the (faked) scheduler; never awaited + + def pause_typing_for_chat(self, chat_id): + sent["paused"] = chat_id + + class _Fut: + def result(self, timeout): + return types.SimpleNamespace(success=True, message_id="m1") + + monkeypatch.setattr(mod.asyncio, "run_coroutine_threadsafe", + lambda coro, loop: _Fut()) + + cb = mod._make_form_callback(_Adapter(), "chatX", "sessX", object()) + ans = cb({"version": 1, "fields": [{"id": "tool"}]}) + assert ans == {"tool": "T0"} + assert sent["chat_id"] == "chatX" and sent["session_key"] == "sessX" + assert sent["metadata"] is None # single-operator DM; no topic + assert sent.get("paused") == "chatX" + assert any(e[0] == "register" for e in fg.events) + + +def test_make_form_callback_reports_send_failure(monkeypatch, tmp_path): + """A failed send returns an _error dict and clears the pending form; the + driving tool surfaces it, and nothing hangs.""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + fg = _rich_form_gateway(monkeypatch) + + class _Adapter: + def send_form(self, **kw): + return "coro" + + def pause_typing_for_chat(self, chat_id): + pass + + class _Fut: + def result(self, timeout): + return types.SimpleNamespace(success=False) + + monkeypatch.setattr(mod.asyncio, "run_coroutine_threadsafe", + lambda coro, loop: _Fut()) + + cb = mod._make_form_callback(_Adapter(), "chatX", "sessX", object()) + out = cb({"version": 1, "fields": [{"id": "tool"}]}) + assert "_error" in out + assert any(e[0] == "clear" for e in fg.events) + + +def test_make_form_callback_errors_without_send_form(monkeypatch, tmp_path): + """An adapter that never got send_form patched yields a clean error, not a + crash (plugin-not-loaded guard).""" + mod = _load_plugin_pkg(monkeypatch, tmp_path) + _rich_form_gateway(monkeypatch) + + class _Adapter: + pass # no send_form + + cb = mod._make_form_callback(_Adapter(), "chatX", "sessX", object()) + out = cb({"version": 1, "fields": [{"id": "tool"}]}) + assert "_error" in out and "send_form" in out["_error"] + + def _fake_form_gateway(monkeypatch): """Stand-in for tools.form_gateway with the callback registry surface.""" fg = types.ModuleType("tools.form_gateway") @@ -1042,28 +1222,35 @@ def test_install_removes_pre_plugin_layout_files(tmp_path, monkeypatch): assert not (sp / "tools" / "u1_form_telegram.py").exists() -def test_install_aborts_before_copying_when_anchor_missing(tmp_path, monkeypatch): - """Unrecognized Hermes: the (read-only) anchor check must run BEFORE any - file copy — otherwise Hermes auto-imports an orphaned half-install.""" +def test_install_continues_when_anchor_missing(tmp_path, monkeypatch): + """Unrecognized Hermes (no clarify anchor): the run.py FALLBACK patch is + skipped, but the install still SUCCEEDS and deploys the plugin. The plugin + publishes the form callback itself, so the form works without the run.py + edit (that is the whole upgrade-durability point).""" venv, sp, run_py = _fake_hermes( tmp_path, monkeypatch, run_py_text="def start():\n pass # layout changed upstream\n") monkeypatch.setattr(hermes_install.subprocess, "run", _stub_subprocess_run([])) rc = hermes_install.main(["--venv", str(venv)]) - assert rc == 2 - assert list((sp / "tools").iterdir()) == [] # nothing copied - assert not (tmp_path / "hermes-home").exists() # no plugin deployed - assert run_py.read_text().startswith("def start()") # untouched + assert rc == 0 # non-fatal now + assert (sp / "tools" / "form_gateway.py").exists() # tools copied + assert (tmp_path / "hermes-home" / "plugins" / "u1-form").exists() # plugin deployed + assert run_py.read_text().startswith("def start()") # untouched (no anchor) assert not run_py.with_suffix(run_py.suffix + ".u1-bak").exists() -def test_install_refuses_malformed_marker_block(tmp_path, monkeypatch): - """Begin marker without end marker: never edit blind.""" +def test_install_warns_but_continues_on_malformed_marker_block(tmp_path, monkeypatch): + """Begin marker without end marker: never edit blind, but no longer fatal. + The plugin publish carries the form, so the install completes and leaves + the malformed run.py untouched for the operator to tidy.""" venv, sp, run_py = _fake_hermes(tmp_path, monkeypatch) - run_py.write_text(_STOCK_RUN_PY + "\n" + hermes_install.RUN_PY_MARKER + "\n") + original = _STOCK_RUN_PY + "\n" + hermes_install.RUN_PY_MARKER + "\n" + run_py.write_text(original) monkeypatch.setattr(hermes_install.subprocess, "run", _stub_subprocess_run([])) rc = hermes_install.main(["--venv", str(venv)]) - assert rc == 2 + assert rc == 0 + assert run_py.read_text() == original # left untouched, never edited blind + assert (tmp_path / "hermes-home" / "plugins" / "u1-form").exists() # plugin still deployed def test_uninstall_restores_backup_removes_plugin_and_disables(tmp_path, monkeypatch): diff --git a/tests/test_advanced_settings.py b/tests/test_advanced_settings.py index 236289e..9ad5dc2 100644 --- a/tests/test_advanced_settings.py +++ b/tests/test_advanced_settings.py @@ -74,7 +74,8 @@ def test_schema_offers_advanced_fields_flagged(): adv = [f for f in schema["fields"] if f.get("advanced")] assert [f["id"] for f in adv] == ["infill", "infill_pattern", "walls", "brim", "fuzzy", "top_shell", "bottom_shell", "one_wall_top", "raft", - "support_style", "nozzle_temp", "bed_temp"] + "support_style", "nozzle_temp", "nozzle_temp_first", + "bed_temp"] assert all(f["group"] == "advanced" and f["default"] == "default" for f in adv) # not offered -> absent entirely schema2 = u1_form.build_form_schema(_spec(offer=False)) @@ -101,6 +102,26 @@ def test_json_answers_default_means_no_override(): assert res["ok"] and "overrides" not in res["values"] +def test_brim_types_map_to_all_orca_values(): + # the U1 Orca fork has exactly these four brim_type values + for pick, orca in (("off", "no_brim"), ("outer", "outer_only"), + ("auto", "auto_brim"), ("ears", "brim_ears")): + res = u1_form.parse_answers_json( + {"parts": "all", "tool": "T0", "material": "PLA", "profile": 1, + "brim": pick}, _spec()) + assert res["ok"], (pick, res["errors"]) + assert res["values"]["overrides"]["brim_type"] == orca, pick + + +def test_brim_text_tokens_cover_the_types(): + for tok, orca in (("brim off", "no_brim"), ("brim outer", "outer_only"), + ("brim on", "outer_only"), ("brim ears", "brim_ears"), + ("brim mouse ears", "brim_ears"), ("brim auto", "auto_brim")): + res = u1_form.parse_answers(f"all | T0 | PLA | profile 1 | {tok}", _spec()) + assert res["ok"], (tok, res["errors"]) + assert res["values"]["overrides"]["brim_type"] == orca, tok + + def test_json_answers_unknown_advanced_option_fails_loudly(): # a categorical control rejects an unknown option id... res = u1_form.parse_answers_json( @@ -215,7 +236,8 @@ def test_advanced_buttons_self_describing_and_review_not_duplicated(): "brim": "Brim", "fuzzy": "Fuzzy", "support_style": "Support", "top_shell": "Top", "bottom_shell": "Bottom", "one_wall_top": "One wall", "raft": "Raft", - "nozzle_temp": "Nozzle", "bed_temp": "Bed"}[f["id"]] + "nozzle_temp": "Nozzle", "nozzle_temp_first": "First", + "bed_temp": "Bed"}[f["id"]] assert all(key in tg._opt_label(o) for o in f["options"]), f["id"] # Review: exactly ONE advanced-related button (opens the tweak menu), and # NO per-advanced-field Edit rows. diff --git a/tests/test_filament_temp_override.py b/tests/test_filament_temp_override.py index 3e660d3..9c7917b 100644 --- a/tests/test_filament_temp_override.py +++ b/tests/test_filament_temp_override.py @@ -46,6 +46,34 @@ def test_override_patches_both_layers_and_all_bed_plates(tmp_path): assert out != src +def test_first_layer_nozzle_overrides_only_the_initial_layer(tmp_path): + src = _write_filament(tmp_path) # profile nozzle 245, initial 250 + # main nozzle sets both siblings, then the separate first-layer value wins + # on the initial-layer key + out = wf.apply_filament_overrides( + src, {"nozzle_temperature": 235, "nozzle_temperature_initial_layer": 245}, + tmp_path, material="PETG") + d = json.loads(out.read_text()) + assert d["nozzle_temperature"] == ["235"] # main layer + assert d["nozzle_temperature_initial_layer"] == ["245"] # first layer, independent + + +def test_first_layer_nozzle_alone_leaves_main_at_profile(tmp_path): + src = _write_filament(tmp_path) # profile nozzle 245, initial 250 + out = wf.apply_filament_overrides( + src, {"nozzle_temperature_initial_layer": 240}, tmp_path, material="PETG") + d = json.loads(out.read_text()) + assert d["nozzle_temperature"] == ["245"] # untouched profile value + assert d["nozzle_temperature_initial_layer"] == ["240"] # only the first layer moved + + +def test_first_layer_nozzle_clamps_to_material_envelope(tmp_path): + src = _write_filament(tmp_path) + out = wf.apply_filament_overrides( + src, {"nozzle_temperature_initial_layer": 999}, tmp_path, material="PETG") + assert json.loads(out.read_text())["nozzle_temperature_initial_layer"] == ["270"] # PETG max + + def test_override_clamps_out_of_range_to_material_envelope(tmp_path): src = _write_filament(tmp_path) # PETG nozzle max is 270, bed max 90 (u1_temps) diff --git a/tests/test_quantity.py b/tests/test_quantity.py index 4b6e450..e90790f 100644 --- a/tests/test_quantity.py +++ b/tests/test_quantity.py @@ -111,16 +111,14 @@ def fake_list_profiles(nozzle=None, history_print_settings_id=None): # ---------- schema ---------- -def test_schema_quantity_field_rides_setup_group(): +def test_schema_quantity_is_a_stepper_on_the_setup_group(): schema = u1_form.build_form_schema(_spec()) q = next(f for f in schema["fields"] if f["id"] == "quantity") - assert q["type"] == "single_select" and q["label"] == "Quantity" - assert q["group"] == "setup" and q["compact"] is True + assert q["label"] == "Copies" and q["group"] == "setup" assert q["default"] == "1" and q["required"] is False - assert [o["id"] for o in q["options"]] == ["1", "2", "3", "4", "6", "9"] - # self-describing labels — the shared setup screen shows bare buttons - assert q["options"][0]["label"] == "1 copy" - assert all("cop" in o["label"] for o in q["options"]) + # rendered as a +/- stepper (1..50), no longer a capped 1-9 option grid + assert q.get("stepper") == {"steps": (5, 1), "unit": "", "min": 1, "max": 50} + assert not q.get("advanced") # a plain top-level count, not a profile override # not offered -> absent entirely schema2 = u1_form.build_form_schema(_spec(offer=False)) assert "quantity" not in [f["id"] for f in schema2["fields"]] @@ -182,10 +180,17 @@ def test_text_repeated_same_quantity_is_harmless(): assert r["values"]["quantity"] == 3 -def test_text_unoffered_count_rejected(): +def test_text_in_range_count_now_accepted(): + # 5 was NOT in the old 1/2/3/4/6/9 grid; the stepper's 1..50 range accepts it r = u1_form.parse_answers("T0 | PLA | profile 1 | x5", _spec()) + assert r["ok"], r["errors"] + assert r["values"]["quantity"] == 5 + + +def test_text_out_of_range_count_rejected(): + r = u1_form.parse_answers("T0 | PLA | profile 1 | x99", _spec()) assert not r["ok"] - assert any("quantity 5 not offered" in e for e in r["errors"]) + assert any("out of range" in e for e in r["errors"]) def test_text_quantity_ignored_when_not_offered(): @@ -212,11 +217,18 @@ def test_json_quantity_defaults_to_one(): assert r["ok"] and r["values"]["quantity"] == 1 -def test_json_unoffered_count_fails_loudly(): +def test_json_count_past_nine_accepted(): + r = u1_form.parse_answers_json( + {"tool": "T0", "material": "PLA", "profile": 1, "quantity": "12"}, _spec()) + assert r["ok"], r["errors"] + assert r["values"]["quantity"] == 12 + + +def test_json_out_of_range_count_fails_loudly(): r = u1_form.parse_answers_json( - {"tool": "T0", "material": "PLA", "profile": 1, "quantity": "5"}, _spec()) + {"tool": "T0", "material": "PLA", "profile": 1, "quantity": "99"}, _spec()) assert not r["ok"] - assert any("quantity" in e for e in r["errors"]) + assert any("out of range" in e for e in r["errors"]) def test_json_quantity_ignored_when_not_offered(): @@ -246,24 +258,50 @@ def test_echo_parse_shows_quantity_only_when_plural(): # ---------- renderer ---------- -def test_renderer_quantity_shares_setup_screen_and_review_echoes(): +def test_renderer_quantity_is_a_stepper_that_dials_past_nine(): schema = u1_form.build_form_schema(_spec()) form = tg.new_form(schema) - # quantity renders on the SAME screen as orient + supports (no new step) + # quantity renders on the SAME screen as supports (no new step) screen_ids = [[f["id"] for f in sc] for sc in tg._screens(form)] setup = next(sc for sc in screen_ids if "supports" in sc) assert "quantity" in setup - # default review line reads "1 copy" - form["current"] = tg.REVIEW_FIELD - assert "1 copy" in tg.render_screen(form)["text"] - # pick 3 copies (grouped radio: marks, group Next advances) fi = tg._field_index(form, "quantity") - three = next(i for i, o in enumerate(tg._field(form, "quantity")["options"]) - if tg._opt_id(o) == "3") - tg.apply_callback(form, f"s:{fi}:{three}") + form["current"] = "quantity" + kb = tg.render_screen(form)["keyboard"] + # a header showing the current count (tap = reset) + one step row -5/-/+/+5 + assert any(b.get("callback_data") == f"T:{fi}:k" and "Copies: 1" in b["text"] + for row in kb for b in row) + step_cbs = [b["callback_data"] for row in kb for b in row + if b.get("callback_data", "").startswith(f"T:{fi}:") + and b["callback_data"] != f"T:{fi}:k"] + assert step_cbs == [f"T:{fi}:-5", f"T:{fi}:-1", f"T:{fi}:1", f"T:{fi}:5"] + # dial PAST the old 9 cap: +5 +5 +1 -> 12 (the whole point) + tg.apply_callback(form, f"T:{fi}:5") + tg.apply_callback(form, f"T:{fi}:5") + tg.apply_callback(form, f"T:{fi}:1") + assert form["steps"]["quantity"] == 12 + assert tg.answer_json(form)["quantity"] == "12" + # review echoes the dialed count form["current"] = tg.REVIEW_FIELD - assert "3 copies" in tg.render_screen(form)["text"] - assert tg.answer_json(form)["quantity"] == "3" + assert "12" in tg.render_screen(form)["text"] + + +def test_renderer_quantity_clamps_and_resets(): + schema = u1_form.build_form_schema(_spec()) + form = tg.new_form(schema) + fi = tg._field_index(form, "quantity") + # can't dial below the floor of 1 + for _ in range(5): + tg.apply_callback(form, f"T:{fi}:-5") + assert form["steps"]["quantity"] == 1 + # clamps at the 50 ceiling + for _ in range(20): + tg.apply_callback(form, f"T:{fi}:5") + assert form["steps"]["quantity"] == 50 + assert tg.answer_json(form)["quantity"] == "50" + # tap the header -> reset to default (omitted from answers -> workflow uses 1) + tg.apply_callback(form, f"T:{fi}:k") + assert "quantity" not in tg.answer_json(form) # ---------- commit path (workflow) ---------- diff --git a/tests/test_temp_form.py b/tests/test_temp_form.py index 2da636e..b459152 100644 --- a/tests/test_temp_form.py +++ b/tests/test_temp_form.py @@ -30,10 +30,11 @@ def _spec(): def test_schema_offers_temperature_fields_and_ranges(): sch = u1_form.build_form_schema(_spec()) temp = [f for f in sch["fields"] if f.get("category") == "temperature"] - assert [f["id"] for f in temp] == ["nozzle_temp", "bed_temp"] + assert [f["id"] for f in temp] == ["nozzle_temp", "nozzle_temp_first", "bed_temp"] assert all(f.get("material_dynamic") and f.get("advanced") for f in temp) assert {"key": "temperature", "label": "\U0001f525 Temperature"} in sch["advanced_categories"] assert sch["temp_range_by_material"]["PLA"]["nozzle_temp"] == [190, 240] + assert sch["temp_range_by_material"]["PLA"]["nozzle_temp_first"] == [190, 240] assert sch["temp_range_by_material"]["PLA"]["bed_temp"] == [0, 70] @@ -46,6 +47,21 @@ def test_parse_in_range_temp_becomes_filament_override(): assert res["values"]["filament_overrides"] == {"nozzle_temperature": 230, "hot_plate_temp": 0} +def test_parse_first_layer_nozzle_maps_to_initial_layer_key(): + res = u1_form.parse_answers_json( + {"tool": "T0", "profile": 1, "nozzle_temp": "230", "nozzle_temp_first": "235"}, _spec()) + assert res["ok"], res["errors"] + assert res["values"]["filament_overrides"] == { + "nozzle_temperature": 230, "nozzle_temperature_initial_layer": 235} + + +def test_parse_first_layer_nozzle_out_of_range_fails_for_material(): + res = u1_form.parse_answers_json( + {"tool": "T0", "profile": 1, "nozzle_temp_first": "280"}, _spec()) # PLA max 240 + assert not res["ok"] + assert any("out of range for PLA" in e for e in res["errors"]) + + def test_parse_out_of_range_temp_fails_loudly(): res = u1_form.parse_answers_json( {"tool": "T0", "profile": 1, "nozzle_temp": "280"}, _spec()) # PLA max 240 diff --git a/tests/test_u1_camera_retry.py b/tests/test_u1_camera_retry.py new file mode 100644 index 0000000..9fb10df --- /dev/null +++ b/tests/test_u1_camera_retry.py @@ -0,0 +1,75 @@ +"""fetch_monitor retries a truncated / transient camera read instead of +aborting a gated start. Live 2026-07-18: the U1 camera returned a frame 487 +bytes short of its declared length (http.client.IncompleteRead) during a kit +drill, so _capture_bed_and_issue_token failed and the start gate refused a real +print. A single short frame must not do that; persistent failure still raises so +the caller stays fail-closed. +""" +from __future__ import annotations + +from http.client import IncompleteRead + +import pytest + +import u1_camera + +_SOI = b"\xff\xd8\xff" +_EOI = b"\xff\xd9" +_COMPLETE = _SOI + b"\x00" * 4000 + _EOI # full JPEG: SOI .. EOI +_TRUNCATED = _SOI + b"\x00" * 4000 # missing the EOI marker + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch): + monkeypatch.setattr(u1_camera.time, "sleep", lambda *a, **k: None) + + +def _seq_http_get(monkeypatch, results): + """Stub http_get to return/raise `results` in order (last item repeats).""" + calls = {"n": 0} + + def fake(url, timeout=10.0): + r = results[min(calls["n"], len(results) - 1)] + calls["n"] += 1 + if isinstance(r, Exception): + raise r + return r + + monkeypatch.setattr(u1_camera, "http_get", fake) + return calls + + +def test_looks_complete_jpeg(): + assert u1_camera._looks_complete_jpeg(_COMPLETE) + assert not u1_camera._looks_complete_jpeg(_TRUNCATED) # no EOI + assert not u1_camera._looks_complete_jpeg(_SOI + _EOI) # too small + assert not u1_camera._looks_complete_jpeg(b"nope" * 500) # not a JPEG + + +def test_retries_after_incomplete_read(tmp_path, monkeypatch): + calls = _seq_http_get(monkeypatch, [IncompleteRead(b"x" * 94992), _COMPLETE]) + out = tmp_path / "bed.jpg" + res = u1_camera.fetch_monitor("h", 7125, str(out)) + assert calls["n"] == 2 # first raised, retry won + assert res["bytes"] == len(_COMPLETE) and res["jpeg_magic"] + assert out.read_bytes() == _COMPLETE + + +def test_retries_a_truncated_frame_without_exception(tmp_path, monkeypatch): + calls = _seq_http_get(monkeypatch, [_TRUNCATED, _TRUNCATED, _COMPLETE]) + out = tmp_path / "bed.jpg" + res = u1_camera.fetch_monitor("h", 7125, str(out)) + assert calls["n"] == 3 + assert out.read_bytes() == _COMPLETE + + +def test_raises_after_persistent_incomplete_read(tmp_path, monkeypatch): + _seq_http_get(monkeypatch, [IncompleteRead(b"x" * 10)]) # always raises + with pytest.raises(IncompleteRead): + u1_camera.fetch_monitor("h", 7125, str(tmp_path / "bed.jpg"), attempts=3) + + +def test_raises_on_persistent_truncation(tmp_path, monkeypatch): + _seq_http_get(monkeypatch, [_TRUNCATED]) # never a full frame + with pytest.raises(ValueError): + u1_camera.fetch_monitor("h", 7125, str(tmp_path / "bed.jpg"), attempts=2) diff --git a/tests/test_u1_kit_workflow.py b/tests/test_u1_kit_workflow.py index 0876341..13f378b 100644 --- a/tests/test_u1_kit_workflow.py +++ b/tests/test_u1_kit_workflow.py @@ -874,3 +874,44 @@ def test_configured_binding_does_not_warn(tmp_path, monkeypatch, capsys, fake_pr events = [json.loads(l) for l in out.splitlines() if l.strip().startswith("{")] assert not any(e.get("kind") == "operator_binding_unconfigured" for e in events) + + +def test_reslice_clears_stale_pending_when_bed_capture_fails( + tmp_path, monkeypatch, fake_profiles, fake_slice_upload): + """A re-slice must invalidate a bed-clear prompt armed for the PRIOR plan. + + Regression (live 2026-07-18): the printer camera returned a truncated frame + on a re-slice, so _capture_bed_and_issue_token failed and the start path + bailed to the upload-only fallback WITHOUT re-arming, stranding the previous + slice's pending. The operator's YES then redeemed that stale prompt against + the new plan and the Stage-2 gate refused. Clearing the pending at + plan-persist (before the capture) fixes it: after a failed re-slice there is + simply NO pending to confirm. + """ + import u1_request + zp = _kit_zip(tmp_path, 1) + rid = kw.run_kit_workflow(_args(zp))["request_id"] # create the request + # plant a bed-clear prompt as if a PRIOR slice had armed it + u1_request.write_request(rid, safety={"pending_bed_clear_start": { + "prompt_key": "bed_clear_start", "request_revision": 1, + "gcode_hash": "sha256:OLD_PLAN", "nonce": "stale-nonce", + "confirm_token": "stale-token"}}) + assert (u1_request.read_request(rid)["safety"] + .get("pending_bed_clear_start")), "planted pending should be present" + + # re-slice with action=start, but the bed camera fails (truncated frame) + monkeypatch.setattr(kw, "_capture_bed_and_issue_token", lambda out_dir: { + "ok": False, "snapshot_path": None, "token": None, + "approval_ttl_seconds": None, "approval_expires_at": None, + "captured_at_utc": None, + "reason": "IncompleteRead(94992 bytes read, 487 more expected)"}) + res = kw.run_kit_workflow(_args( + zp, request_id=rid, live_upload=True, + form_answers="all | T0 | PLA | profile 1 | no-supports | start")) + + # capture failed -> upload-only fallback, NOT a gated start + assert res["phase"] == "awaiting_confirm" + # ...and the stale pending is GONE, so a YES can't redeem the old plan + safety = u1_request.read_request(rid).get("safety") or {} + assert "pending_bed_clear_start" not in safety, ( + "a failed re-slice must not leave the prior slice's pending prompt")