Просмотр исходного кода

fix(printers): surface the printer's own "command verification failed"

    A P1S on firmware 01.10.00.00 rejected every control command and said so:
    HMS 0500-0500-0001-0007, "MQTT command verification failed". Bambuddy
    received that, dropped it, and reported a healthy printer instead.

    The frontend filtered it out. This code's meaning lives in attr's low half
    (0500) and code's high half (0001), both of which the MMMM_EEEE short form
    discards, so it collapsed to "0500_0007" — no catalog entry, no firmware
    actions, and filterKnownHMSErrors drops uncatalogued action-less errors.
    Catalog lookups now try full_code first, in both the description and the
    filter, and errors matched that way display the four-group code the
    printer's own screen shows. The remedy line is ours, not Bambu's: their
    wiki says to update Studio or Handy, which does not apply to a print sent
    from Bambuddy.

    The developer-mode probe made it worse. It read anything that was not an
    explicit refusal as confirmation, and this firmware answers the probe with
    an empty result while refusing everything else — so an inference drawn
    from a non-answer became "developer_mode: pass" in the support bundle of a
    printer that had not accepted a command all day. The probe now has three
    outcomes: explicit success enables, explicit verify-failure disables,
    anything else stays unknown and the diagnostic reports skip.

    The HMS is authoritative over that inference in both directions. It forces
    developer_mode False when present, and clears back to unknown when the
    printer stops reporting it, so enabling Developer Mode and restarting the
    printer is picked up without restarting Bambuddy.

    Dispatch no longer treats a refusal as a wedge. The watchdog latches the
    HMS across both phases and fails the item on the first attempt naming the
    code and the fix, rather than spending three uploads and 270s a lap to
    arrive at a message about SD cards. The check runs after the active-state
    exit in both phases, so a lingering HMS can never abort a print that is
    visibly running.

    Also: the "wrong or mis-cased serial number" hint no longer fires in the
    moment after a reconnect. _report_messages_since_connect is reset by
    _on_connect, so a reconnect landing microseconds before the staleness
    check leaves it at 0 for reasons that have nothing to do with the serial —
    this reporter's healthy printer was told to go check its serial 1 ms after
    reconnecting.
maziggy 1 месяц назад
Родитель
Сommit
6184dbb980
31 измененных файлов с 1103 добавлено и 58 удалено
  1. 3 0
      CHANGELOG.md
  2. 10 3
      backend/app/api/routes/obico.py
  3. 39 20
      backend/app/api/routes/support.py
  4. 8 0
      backend/app/schemas/settings.py
  5. 104 3
      backend/app/services/bambu_mqtt.py
  6. 87 12
      backend/app/services/obico_detection.py
  7. 83 2
      backend/app/services/print_scheduler.py
  8. 140 0
      backend/tests/unit/services/test_bambu_mqtt.py
  9. 199 0
      backend/tests/unit/test_obico_detection.py
  10. 115 0
      backend/tests/unit/test_scheduler_watchdog.py
  11. 27 6
      backend/tests/unit/test_support_helpers.py
  12. 97 0
      frontend/src/__tests__/components/FailureDetectionSettings.test.tsx
  13. 45 1
      frontend/src/__tests__/components/HMSErrorModal.test.tsx
  14. 8 2
      frontend/src/api/client.ts
  15. 29 3
      frontend/src/components/FailureDetectionSettings.tsx
  16. 43 5
      frontend/src/components/HMSErrorModal.tsx
  17. 5 0
      frontend/src/i18n/locales/de.ts
  18. 5 0
      frontend/src/i18n/locales/en.ts
  19. 5 0
      frontend/src/i18n/locales/es.ts
  20. 5 0
      frontend/src/i18n/locales/fr.ts
  21. 5 0
      frontend/src/i18n/locales/it.ts
  22. 5 0
      frontend/src/i18n/locales/ja.ts
  23. 5 0
      frontend/src/i18n/locales/ko.ts
  24. 5 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 5 0
      frontend/src/i18n/locales/ru.ts
  26. 5 0
      frontend/src/i18n/locales/tr.ts
  27. 5 0
      frontend/src/i18n/locales/uk.ts
  28. 5 0
      frontend/src/i18n/locales/zh-CN.ts
  29. 5 0
      frontend/src/i18n/locales/zh-TW.ts
  30. 0 0
      static/assets/index-I83QBJfM.js
  31. 1 1
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 3 - 0
CHANGELOG.md


+ 10 - 3
backend/app/api/routes/obico.py

@@ -17,6 +17,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
 
 class TestConnectionRequest(BaseModel):
     url: str
+    # Omitted entirely = test with the saved token; "" = test with no token.
+    token: str | None = None
 
 
 @router.get("/status")
@@ -65,10 +67,15 @@ async def test_connection(
     req: TestConnectionRequest,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
+    """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
     if not req.url:
-        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
-    return await obico_detection_service.test_connection(req.url)
+        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
+    token = req.token
+    if token is None:
+        # Field omitted entirely — test what the service actually uses.
+        settings = await obico_detection_service._load_settings()
+        token = settings.get("ml_token") or ""
+    return await obico_detection_service.test_connection(req.url, token)
 
 
 @router.get("/cached-frame/{nonce}")

+ 39 - 20
backend/app/api/routes/support.py

@@ -647,20 +647,29 @@ async def _collect_slicer_api_info() -> dict:
     return info
 
 
-def _parse_obico_enabled_printers(raw: str) -> set[int]:
-    """Parse the comma-separated `obico_enabled_printers` setting. Same shape as
-    obico_detection.py uses but tolerant of legacy formats."""
+def _parse_obico_enabled_printers(raw: str | None) -> set[int] | None:
+    """Parse the `obico_enabled_printers` setting the way the detection service does.
+
+    The setting is a JSON array of printer IDs and an empty value means *all*
+    printers — see ``ObicoDetectionService._load_settings``. This used to split
+    on commas and treat empty as *none*, so a bundle from a default Obico setup
+    reported every printer as unmonitored while the service was in fact polling
+    all of them. Returns ``None`` for "all printers"; a comma-separated fallback
+    is kept in case an install ever stored the legacy shape.
+    """
     if not raw or not raw.strip():
-        return set()
+        return None
+    try:
+        parsed = json.loads(raw)
+    except (json.JSONDecodeError, TypeError):
+        parsed = None
+    if isinstance(parsed, list):
+        return {int(item) for item in parsed if isinstance(item, (int, str)) and str(item).strip().isdigit()}
     result: set[int] = set()
     for token in raw.split(","):
         token = token.strip()
-        if not token:
-            continue
-        try:
+        if token.isdigit():
             result.add(int(token))
-        except ValueError:
-            continue
     return result
 
 
@@ -729,18 +738,27 @@ async def _collect_support_info() -> dict:
         printers = result.scalars().all()
         statuses = printer_manager.get_all_statuses()
 
-        # Pre-load the obico per-printer enabled-list. Settings are loaded later
-        # in this function (and would overwrite this key in info["settings"]),
-        # so do a targeted query here for the per-printer flag below.
-        obico_enabled_set: set[int] = set()
+        # Pre-load the obico settings that decide which printers are monitored.
+        # Settings are loaded later in this function (and would overwrite these
+        # keys in info["settings"]), so do a targeted query here for the
+        # per-printer flag below. ``None`` means every printer is monitored.
+        obico_enabled_set: set[int] | None = None
+        obico_globally_enabled = False
         try:
-            obico_row = (
-                await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
-            ).scalar_one_or_none()
-            if obico_row is not None:
-                obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
+            obico_rows = {
+                row.key: row.value
+                for row in (
+                    await db.execute(
+                        select(Settings).where(Settings.key.in_(["obico_enabled_printers", "obico_enabled"]))
+                    )
+                )
+                .scalars()
+                .all()
+            }
+            obico_enabled_set = _parse_obico_enabled_printers(obico_rows.get("obico_enabled_printers"))
+            obico_globally_enabled = (obico_rows.get("obico_enabled") or "false").lower() == "true"
         except Exception:
-            logger.debug("Failed to load obico_enabled_printers", exc_info=True)
+            logger.debug("Failed to load obico settings", exc_info=True)
 
         # Check reachability in parallel
         reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
@@ -784,7 +802,8 @@ async def _collect_support_info() -> dict:
                     "has_vt_tray": has_vt_tray,
                     "external_camera_configured": bool(printer.external_camera_url),
                     "plate_detection_enabled": printer.plate_detection_enabled,
-                    "obico_enabled": printer.id in obico_enabled_set,
+                    "obico_enabled": obico_globally_enabled
+                    and (obico_enabled_set is None or printer.id in obico_enabled_set),
                     "hms_error_count": len(state.hms_errors) if state else 0,
                     "developer_mode": state.developer_mode if state else None,
                     "nozzle_rack_count": len(state.nozzle_rack) if state else 0,

+ 8 - 0
backend/app/schemas/settings.py

@@ -467,6 +467,13 @@ class AppSettings(BaseModel):
         default="",
         description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
     )
+    obico_ml_token: str = Field(
+        default="",
+        description=(
+            "Bearer token for the Obico ML API, matching the server's ML_API_TOKEN "
+            "environment variable. Empty when the server runs without one."
+        ),
+    )
     obico_sensitivity: str = Field(
         default="medium",
         description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
@@ -618,6 +625,7 @@ class AppSettingsUpdate(BaseModel):
     ldap_default_group: str | None = None
     obico_enabled: bool | None = None
     obico_ml_url: str | None = None
+    obico_ml_token: str | None = None
     obico_sensitivity: str | None = None
     obico_action: str | None = None
     obico_poll_interval: int | None = Field(default=None, ge=5, le=120)

+ 104 - 3
backend/app/services/bambu_mqtt.py

@@ -307,6 +307,19 @@ _HMS_USER_ACTION_CODES: frozenset[str] = frozenset(
     }
 )
 
+# "MQTT command verification failed" — the printer's authorization/authentication
+# protection (firmware >= 01.08.03.00beta / 01.08.05.00) rejecting a control
+# command it could not verify. Queries (get_version, extrusion_cali_get,
+# pushall) still answer, so the connection looks perfectly healthy while
+# project_file, gcode_line and ams_change_filament are all silently dropped —
+# which is exactly how it presents: uploads succeed, the printer echoes our
+# subtask_id, then sits at IDLE forever (#2732).
+#
+# The 16-char form is load-bearing. This code's meaning lives in attr's low half
+# (0500) and code's high half (0001); the MMMM_EEEE short code collapses it to
+# "0500_0007", which matches nothing in any catalog.
+HMS_MQTT_VERIFY_FAILED: str = "0500050000010007"
+
 
 @dataclass
 class KProfile:
@@ -852,6 +865,13 @@ class BambuMQTTClient:
         self._dev_mode_probe_seq: str | None = None
         self._dev_mode_probe_time: float = 0.0  # monotonic timestamp when probe was sent
         self._dev_mode_probe_failures: int = 0  # consecutive unanswered probes
+        # True while developer_mode=False came from HMS_MQTT_VERIFY_FAILED rather
+        # than from the probe or the "fun" bit. The HMS is a latch, not a level:
+        # the printer reports it until the fault clears, so when a later hms[]
+        # arrives without it (user enabled Developer Mode and restarted the
+        # printer) we drop back to "unknown" and let the probe re-run instead of
+        # leaving a permanently-wrong False behind (#2732).
+        self._dev_mode_from_hms: bool = False
         self._connect_time: float = 0.0  # monotonic timestamp of last _on_connect
 
         # Set when check_staleness() force-closes the socket to trigger reconnect.
@@ -970,7 +990,19 @@ class BambuMQTTClient:
             # regardless, but the printer publishes to device/<real-serial>/
             # report, which is case-sensitive. Surface that once so the user
             # has something actionable instead of an endless reconnect loop.
-            if self._report_messages_since_connect == 0 and not self._zero_report_hint_logged:
+            # Only meaningful once the *current* session has had time to receive
+            # something. _report_messages_since_connect is reset by _on_connect,
+            # so a reconnect that lands microseconds before this check leaves it
+            # at 0 for reasons that have nothing to do with the serial — which is
+            # how a healthy P1S ended up being told to go check its serial number
+            # 1 ms after reconnecting (#2732). Requiring STALE_TIMEOUT of silence
+            # on this session means the hint only fires when the printer really
+            # has published nothing to the topic we subscribed to.
+            # _connect_time of 0 means we have no timestamp to judge by (never went
+            # through _on_connect); fall back to the old unconditional behaviour
+            # rather than silently swallowing the hint.
+            session_too_young = self._connect_time > 0 and (time.monotonic() - self._connect_time) < self.STALE_TIMEOUT
+            if self._report_messages_since_connect == 0 and not session_too_young and not self._zero_report_hint_logged:
                 self._zero_report_hint_logged = True
                 logger.warning(
                     "[%s] Connected and subscribed, but the printer has sent zero "
@@ -3747,6 +3779,7 @@ class BambuMQTTClient:
             hms_list = data["hms"]
             logger.debug("[%s] HMS data received: %s", self.serial_number, hms_list)
             self.state.hms_errors = []
+            verify_failed = False
             if isinstance(hms_list, list):
                 for hms in hms_list:
                     if isinstance(hms, dict):
@@ -3782,6 +3815,8 @@ class BambuMQTTClient:
                         # discards — that's the firmware's matching key, so try it
                         # first and fall back to the short form.
                         full_code = f"{attr:08X}{code:08X}"
+                        if full_code == HMS_MQTT_VERIFY_FAILED:
+                            verify_failed = True
                         actions = get_actions_for_error_code(self.serial_number[:3], full_code)
                         if not actions:
                             actions = get_actions_for_error_code(self.serial_number[:3], short_code.replace("_", ""))
@@ -3796,6 +3831,7 @@ class BambuMQTTClient:
                                 full_code=full_code,
                             )
                         )
+            self._apply_mqtt_verify_state(verify_failed)
 
         # Parse print_error - this is a different error format than HMS
         # print_error is a 32-bit integer where:
@@ -4496,10 +4532,64 @@ class BambuMQTTClient:
         logger.info("[%s] Probing developer mode via ams_filament_setting (seq=%s)", self.serial_number, seq)
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
+    def _apply_mqtt_verify_state(self, verify_failed: bool) -> None:
+        """Reconcile developer_mode with the printer's own command-verification verdict.
+
+        ``HMS_MQTT_VERIFY_FAILED`` is the only *direct* evidence we ever get that
+        control commands are being refused, so it outranks the probe in both
+        directions:
+
+        * present  → developer_mode is definitively False, whatever the probe
+          concluded. The probe can only read the response to its own
+          ``ams_filament_setting``; on P1 firmware a refusal is reported here
+          instead, so the probe answers ENABLED while every print silently dies
+          (#2732).
+        * gone again → drop the HMS-derived False back to unknown and re-arm the
+          probe, so a user who enables Developer Mode and restarts the printer
+          isn't stuck behind a verdict nothing would ever revisit.
+
+        A False that came from the probe or the ``fun`` bit is left alone — this
+        only ever unwinds its own latch.
+        """
+        if verify_failed:
+            if not self._dev_mode_from_hms:
+                logger.warning(
+                    "[%s] Printer reported HMS %s (MQTT command verification failed): it is "
+                    "rejecting control commands, so prints, temperature changes and filament "
+                    "loads will be ignored. Enable Developer Mode on the printer and restart it.",
+                    self.serial_number,
+                    HMS_MQTT_VERIFY_FAILED,
+                )
+            self._dev_mode_from_hms = True
+            self.state.developer_mode = False
+            return
+
+        if not self._dev_mode_from_hms:
+            return
+        logger.info(
+            "[%s] HMS %s cleared — re-probing developer mode",
+            self.serial_number,
+            HMS_MQTT_VERIFY_FAILED,
+        )
+        self._dev_mode_from_hms = False
+        self.state.developer_mode = None
+        self._dev_mode_probed = False
+        self._dev_mode_needs_probe = False
+
     def _handle_dev_mode_probe_response(self, data: dict):
         """Handle response to the developer mode probe command.
 
         Sets developer_mode based on whether the printer accepted or rejected the command.
+
+        Three outcomes, not two. An explicit ``success`` proves commands are
+        accepted and an explicit verify-failure proves they are not, but anything
+        else proves nothing — P1S firmware 01.10.00.00 answers this probe with a
+        bare ``{"command": "ams_filament_setting", "sequence_id": "3"}`` and no
+        ``result`` at all, while refusing every control command and reporting
+        ``HMS_MQTT_VERIFY_FAILED`` instead. Reading that empty response as ENABLED
+        is what put ``developer_mode: pass`` in the support bundle of a printer
+        that had not accepted a command all day (#2732). Leaving it unknown makes
+        the connection diagnostic report ``skip``, which is the honest answer.
         """
         self._dev_mode_probe_seq = None  # One-shot: don't match future responses
         self._dev_mode_probe_failures = 0  # Reset on any response
@@ -4509,10 +4599,21 @@ class BambuMQTTClient:
         if result == "failed" and "verify failed" in reason:
             self.state.developer_mode = False
             logger.info("[%s] Developer mode probe: DISABLED (reason=%r)", self.serial_number, reason)
-        else:
-            # Success or any other response — commands are accepted
+        elif str(result).lower() == "success":
             self.state.developer_mode = True
             logger.info("[%s] Developer mode probe: ENABLED (result=%r)", self.serial_number, result)
+        else:
+            # An HMS verdict already recorded here is real evidence; don't let an
+            # inconclusive probe response wipe it back to unknown.
+            if not self._dev_mode_from_hms:
+                self.state.developer_mode = None
+            logger.info(
+                "[%s] Developer mode probe: INCONCLUSIVE (result=%r, reason=%r) — "
+                "the printer neither confirmed nor refused the command",
+                self.serial_number,
+                result,
+                reason,
+            )
 
         if self.on_state_change:
             self.on_state_change(self.state)

+ 87 - 12
backend/app/services/obico_detection.py

@@ -44,6 +44,19 @@ _frame_cache: dict[str, tuple[bytes, float]] = {}
 _frame_cache_lock = asyncio.Lock()
 
 
+def auth_headers(token: str | None) -> dict[str, str]:
+    """Bearer header for the ML API, or nothing when no token is configured.
+
+    Obico's ML API gates ``/p/`` behind ``ML_API_TOKEN`` (``ml_api/auth.py``):
+    with the variable set it answers a bare 401 to any request whose
+    ``Authorization`` header isn't ``Bearer <token>``, and with it unset it
+    ignores the header entirely. Sending nothing when unconfigured keeps the
+    request byte-identical to what shipped before the setting existed.
+    """
+    token = (token or "").strip()
+    return {"Authorization": f"Bearer {token}"} if token else {}
+
+
 def _prune_frame_cache() -> None:
     """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
     now = time.monotonic()
@@ -111,6 +124,7 @@ class ObicoDetectionService:
         keys = [
             "obico_enabled",
             "obico_ml_url",
+            "obico_ml_token",
             "obico_sensitivity",
             "obico_action",
             "obico_poll_interval",
@@ -133,6 +147,7 @@ class ObicoDetectionService:
         return {
             "enabled": rows.get("obico_enabled", "false").lower() == "true",
             "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
+            "ml_token": (rows.get("obico_ml_token") or "").strip(),
             "sensitivity": rows.get("obico_sensitivity", "medium"),
             "action": rows.get("obico_action", "notify"),
             "poll_interval": int(rows.get("obico_poll_interval", "10")),
@@ -279,7 +294,23 @@ class ObicoDetectionService:
 
         try:
             async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
-                resp = await client.get(ml_url, params={"img": snapshot_url})
+                resp = await client.get(
+                    ml_url,
+                    params={"img": snapshot_url},
+                    headers=auth_headers(settings.get("ml_token")),
+                )
+                if resp.status_code == 401:
+                    # The server runs with ML_API_TOKEN set and rejected ours.
+                    # Say so plainly: the health endpoint is ungated, so "Test
+                    # Connection" passes against exactly this configuration and
+                    # a raw 401 gives the user nothing to act on (#2733).
+                    self._last_error = (
+                        "Obico ML API rejected the token (401). Set Settings → Failure Detection → "
+                        "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
+                        "on the server."
+                    )
+                    logger.warning("%s (printer %s)", self._last_error, printer_id)
+                    return
                 resp.raise_for_status()
                 payload = resp.json()
         except Exception as e:
@@ -364,8 +395,8 @@ class ObicoDetectionService:
             "history": list(self._history),
         }
 
-    async def test_connection(self, url: str) -> dict:
-        """Ping the ML API health endpoint. Returns {ok, status_code, body, error}.
+    async def test_connection(self, url: str, token: str = "") -> dict:
+        """Ping the ML API and check the token. Returns {ok, status_code, body, error, auth_ok}.
 
         The stored ``obico_ml_url`` setting is validated at the schema layer,
         but this route takes its URL from the request body, so the same
@@ -374,27 +405,71 @@ class ObicoDetectionService:
         is returned to the caller (it is the health signal — the endpoint
         answers "ok"), which is exactly why the destination must be inside
         policy before the request is made.
+
+        ``token`` is used verbatim — resolving "not supplied" to the saved
+        setting is the route's job, so this stays a pure outbound call.
+
+        Health alone cannot answer whether the token works, because Obico
+        gates ``/p/`` but leaves ``/hc/`` open — which is how a token-protected
+        server passed this test while every detection call came back 401
+        (#2733). So a second, side-effect-free probe follows: ``/p/`` with no
+        ``img`` parameter. The auth decorator runs before the handler, so 401
+        means the token was rejected and 422 ("Invalid request params") means
+        it was accepted. No inference work is done either way.
         """
         from backend.app.api.routes._url_safety import assert_safe_lan_service_url
 
         try:
             assert_safe_lan_service_url(url, label="Obico ML URL")
         except ValueError as exc:
-            return {"ok": False, "status_code": None, "body": None, "error": str(exc)}
+            return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None}
 
-        target = f"{url.rstrip('/')}/hc/"
+        headers = auth_headers(token)
+
+        base = url.rstrip("/")
         try:
             async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
-                resp = await client.get(target)
-            body = resp.text.strip()
+                resp = await client.get(f"{base}/hc/", headers=headers)
+                body = resp.text.strip()
+                healthy = resp.status_code == 200 and body.lower() == "ok"
+                if not healthy:
+                    return {
+                        "ok": False,
+                        "status_code": resp.status_code,
+                        "body": body,
+                        "error": None,
+                        "auth_ok": None,
+                    }
+
+                auth_ok: bool | None
+                try:
+                    probe = await client.get(f"{base}/p/", headers=headers)
+                    auth_ok = probe.status_code != 401
+                except Exception:
+                    # The health check already succeeded, so don't fail the
+                    # whole test on the probe — report the token as unknown.
+                    auth_ok = None
+        except Exception as e:
+            return {
+                "ok": False,
+                "status_code": None,
+                "body": None,
+                "error": str(e) or type(e).__name__,
+                "auth_ok": None,
+            }
+
+        if auth_ok is False:
             return {
-                "ok": resp.status_code == 200 and body.lower() == "ok",
-                "status_code": resp.status_code,
+                "ok": False,
+                "status_code": 401,
                 "body": body,
-                "error": None,
+                "error": (
+                    "The ML API is reachable but rejected the token. It runs with ML_API_TOKEN set — "
+                    "enter that value as the ML API Token, or clear ML_API_TOKEN on the server."
+                ),
+                "auth_ok": False,
             }
-        except Exception as e:
-            return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
+        return {"ok": True, "status_code": resp.status_code, "body": body, "error": None, "auth_ok": auth_ok}
 
 
 obico_detection_service = ObicoDetectionService()

+ 83 - 2
backend/app/services/print_scheduler.py

@@ -32,6 +32,7 @@ from backend.app.services.bambu_ftp import (
     upload_file_async,
     with_ftp_retry,
 )
+from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -174,6 +175,25 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
     return all(t is None or (isinstance(t, int) and t < 0) for t in mapping)
 
 
+def _mqtt_commands_rejected(status) -> bool:
+    """True when the printer is currently reporting that it refused a command.
+
+    ``HMS_MQTT_VERIFY_FAILED`` means the firmware's authorization check rejected
+    a control command it could not verify. Queries still answer, so the printer
+    looks connected and idle while project_file, gcode_line and
+    ams_change_filament are all dropped — no amount of waiting or re-uploading
+    changes that (#2732).
+
+    Tolerates a missing status and errors without a ``full_code`` (the 8-char
+    ``print_error`` path builds HMSError differently), so this is safe to call on
+    every watchdog poll.
+    """
+    for err in getattr(status, "hms_errors", None) or []:
+        if getattr(err, "full_code", "") == HMS_MQTT_VERIFY_FAILED:
+            return True
+    return False
+
+
 def _installed_nozzle_diameters(status) -> list[float]:
     """Parse the installed nozzle diameters from a PrinterState (#1899).
 
@@ -2967,6 +2987,7 @@ class PrintScheduler:
         queue_item_id: int,
         printer_id: int,
         created_by_id: int | None,
+        reason: str = "Printer accepted the file but never started printing",
     ) -> None:
         """Tell the user the queue item was failed after exhausting its dispatch retries.
 
@@ -2974,6 +2995,10 @@ class PrintScheduler:
         its own — hence the fresh one here. Best-effort throughout: the row is
         already marked failed and that is the load-bearing part; a notification
         provider being down must not resurrect the retry loop we just stopped.
+
+        ``reason`` defaults to the exhausted-retries wording. The command-rejected
+        path passes its own, because "accepted the file but never started" is the
+        opposite of what happened there — the printer refused it outright (#2732).
         """
         try:
             async with async_session() as db:
@@ -2986,7 +3011,7 @@ class PrintScheduler:
                     job_name=job_name,
                     printer_id=printer_id,
                     printer_name=printer.name if printer else "Unknown",
-                    reason="Printer accepted the file but never started printing",
+                    reason=reason,
                     db=db,
                 )
         except Exception as e:
@@ -3857,9 +3882,20 @@ class PrintScheduler:
 
         Phase A timeout raised from 45 s → 90 s as belt-and-braces for slow
         transitions that also don't emit an early subtask_id tick.
+
+        Both phases also watch for ``HMS_MQTT_VERIFY_FAILED``. A printer that
+        refuses to verify our commands will never start this job or any other,
+        so waiting out the full 270 s and re-uploading the 3MF twice more only
+        burns an upload slot the rest of the farm is queued behind — that path
+        is for a printer that might still come good, which this one cannot
+        (#2732). It fails the item on the spot with the actual reason instead.
         """
         last_status = None
         landed_on_subtask = False
+        # Latched, not level-tested: state.hms_errors is rebuilt from scratch on
+        # every push carrying an `hms` key, so the fault can come and go between
+        # 3-second polls. Seeing it once inside the dispatch window is enough.
+        command_rejected = False
         deadline = time.monotonic() + timeout
         while time.monotonic() < deadline:
             await asyncio.sleep(poll_interval)
@@ -3890,6 +3926,13 @@ class PrintScheduler:
                 except Exception:
                     pass
                 return
+            # Checked only after the active-state exit above: a stale HMS left
+            # over from an earlier job must never abort a print that is visibly
+            # running. An actually-refused command leaves the printer idle, so
+            # this ordering costs the detection nothing.
+            if _mqtt_commands_rejected(status):
+                command_rejected = True
+                break
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
                 # Phase A exit — printer accepted the file (subtask_id flipped
                 # to our submission id). Don't return yet: the printer may
@@ -3899,7 +3942,7 @@ class PrintScheduler:
                 landed_on_subtask = True
                 break
 
-        if landed_on_subtask:
+        if landed_on_subtask and not command_rejected:
             phase_b_deadline = time.monotonic() + phase_b_timeout
             while time.monotonic() < phase_b_deadline:
                 await asyncio.sleep(poll_interval)
@@ -3919,6 +3962,11 @@ class PrintScheduler:
                     except Exception:
                         pass
                     return
+                # Same ordering rule as Phase A: a running print wins over a
+                # lingering HMS.
+                if _mqtt_commands_rejected(status):
+                    command_rejected = True
+                    break
 
         # No active-state transition. Revert the item so the scheduler can retry.
         # Drop the in-memory hold so the retry isn't blocked by it.
@@ -3948,6 +3996,20 @@ class PrintScheduler:
                 return "already_moved_on"
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             item.started_at = None
+            if command_rejected:
+                # No retry budget for this one: the printer refused to verify the
+                # command, and re-uploading the same 3MF to the same printer will
+                # be refused the same way. Fail now with the fix rather than after
+                # three laps of a message about SD cards (#2732).
+                item.status = "failed"
+                item.error_message = (
+                    "The printer rejected the print command: MQTT command verification failed "
+                    "(HMS 0500-0500-0001-0007). Enable Developer Mode on the printer, restart it, "
+                    "then start the job again."
+                )
+                item.completed_at = datetime.now(timezone.utc)
+                await db.commit()
+                return "command_rejected"
             if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
                 item.status = "failed"
                 item.error_message = (
@@ -3982,6 +4044,25 @@ class PrintScheduler:
             return
 
         total_timeout = timeout + (phase_b_timeout if landed_on_subtask else 0.0)
+        if revert_outcome == "command_rejected":
+            logger.error(
+                "Queue item %s: printer %d reported HMS %s (MQTT command verification "
+                "failed) — the print command was rejected, not lost. Failing the item "
+                "without retrying; enable Developer Mode on the printer and restart it (#2732)",
+                queue_item_id,
+                printer_id,
+                HMS_MQTT_VERIFY_FAILED,
+            )
+            await scheduler._notify_dispatch_gave_up(
+                queue_item_id,
+                printer_id,
+                created_by_id,
+                reason="Printer rejected the print command (MQTT command verification failed)",
+            )
+            # Same reasoning as the landed_on_subtask path below: the file is on
+            # the printer and a forced reconnect would only add 0500_4003 to a
+            # problem that has nothing to do with the MQTT session (#1150).
+            return
         if revert_outcome == "gave_up":
             logger.error(
                 "Queue item %s: printer %d never started the print after %d dispatch "

+ 140 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3547,6 +3547,108 @@ class TestDeveloperModeDetection:
         assert mqtt_client.state.developer_mode is False
 
 
+class TestMqttCommandVerificationFailed:
+    """HMS 0500_0500_0001_0007 is the printer refusing to verify our commands (#2732).
+
+    A P1S on firmware 01.10.00.00 answers queries normally while dropping every
+    control command, so nothing else in the connection looks wrong. This HMS is
+    the only direct evidence, which makes it authoritative over the probe.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="01S00A000000000",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _hms_payload(*entries):
+        return {"print": {"gcode_state": "IDLE", "hms": list(entries)}}
+
+    # attr 0x05000500, code 0x00010007 — the values a real P1S sends.
+    VERIFY_FAILED = {"attr": 83887360, "code": 65543}
+    OTHER_FAULT = {"attr": 0x03000200, "code": 0x00018012}
+
+    def test_hms_forces_developer_mode_false(self, mqtt_client):
+        mqtt_client.state.developer_mode = True  # what the probe wrongly concluded
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        assert mqtt_client.state.developer_mode is False
+
+    def test_hms_is_surfaced_with_its_full_code(self, mqtt_client):
+        """The short code collapses to a useless 0500_0007 — full_code must survive."""
+        from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
+
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        assert [e.full_code for e in mqtt_client.state.hms_errors] == [HMS_MQTT_VERIFY_FAILED]
+
+    def test_unrelated_hms_does_not_touch_developer_mode(self, mqtt_client):
+        mqtt_client.state.developer_mode = True
+        mqtt_client._process_message(self._hms_payload(self.OTHER_FAULT))
+        assert mqtt_client.state.developer_mode is True
+
+    def test_clearing_the_hms_re_arms_the_probe(self, mqtt_client):
+        """Enabling Developer Mode and restarting must not leave a stuck False."""
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        assert mqtt_client.state.developer_mode is False
+        mqtt_client._dev_mode_probed = True
+
+        mqtt_client._process_message(self._hms_payload())
+        assert mqtt_client.state.developer_mode is None
+        assert mqtt_client._dev_mode_probed is False
+
+    def test_empty_hms_leaves_a_probe_verdict_alone(self, mqtt_client):
+        """Only the HMS-derived latch self-clears; a probe's False is not ours to undo."""
+        mqtt_client.state.developer_mode = False  # from an explicit probe refusal
+        mqtt_client._process_message(self._hms_payload())
+        assert mqtt_client.state.developer_mode is False
+
+    def test_inconclusive_probe_does_not_overwrite_the_hms_verdict(self, mqtt_client):
+        mqtt_client._process_message(self._hms_payload(self.VERIFY_FAILED))
+        mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
+        assert mqtt_client.state.developer_mode is False
+
+
+class TestDeveloperModeProbeInconclusive:
+    """An empty probe response proves nothing and must not read as ENABLED (#2732)."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def test_empty_result_stays_unknown(self, mqtt_client):
+        """P1S 01.10.00.00 echoes the command back with no `result` field at all."""
+        mqtt_client._handle_dev_mode_probe_response({"command": "ams_filament_setting", "sequence_id": "3"})
+        assert mqtt_client.state.developer_mode is None
+
+    def test_explicit_success_still_enables(self, mqtt_client):
+        mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": "success"})
+        assert mqtt_client.state.developer_mode is True
+
+    def test_verify_failure_still_disables(self, mqtt_client):
+        mqtt_client._handle_dev_mode_probe_response(
+            {"sequence_id": "3", "result": "failed", "reason": "mqtt message verify failed"}
+        )
+        assert mqtt_client.state.developer_mode is False
+
+    def test_inconclusive_response_still_clears_probe_bookkeeping(self, mqtt_client):
+        """Whatever the verdict, the response ends the probe (no retry storm)."""
+        mqtt_client._dev_mode_probe_seq = "3"
+        mqtt_client._dev_mode_probe_failures = 1
+        mqtt_client._handle_dev_mode_probe_response({"sequence_id": "3", "result": ""})
+        assert mqtt_client._dev_mode_probe_seq is None
+        assert mqtt_client._dev_mode_probe_failures == 0
+
+
 class TestDeveloperModeProbeTimeout:
     """Tests for developer mode probe timeout, retry, and forced reconnect (#887).
 
@@ -4670,6 +4772,44 @@ class TestStaleReconnect:
             mqtt_client.check_staleness()
         assert not any("zero status reports" in r.getMessage() for r in caplog.records)
 
+    def test_check_staleness_no_serial_hint_right_after_reconnect(self, mqtt_client, caplog):
+        """#2732 — _report_messages_since_connect is reset by _on_connect, so a
+        reconnect landing just before the staleness check leaves it at 0 for
+        reasons that have nothing to do with the serial. A healthy P1S was being
+        told to check its serial number 1 ms after reconnecting."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 0
+        mqtt_client._connect_time = time.monotonic()  # fresh session
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is False
+        assert not any("zero status reports" in r.getMessage() for r in caplog.records)
+        # The stale reconnect itself still happens — only the hint is suppressed.
+        assert mqtt_client._stale_reconnecting is True
+
+    def test_check_staleness_serial_hint_when_session_old_enough(self, mqtt_client, caplog):
+        """A session that has been up past the stale window and still received
+        nothing is the case the hint was written for."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 0
+        mqtt_client._connect_time = time.monotonic() - 120
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is True
+        assert any("zero status reports" in r.getMessage() for r in caplog.records)
+
     def test_check_staleness_no_serial_hint_when_reports_received(self, mqtt_client, caplog):
         """A stale connection that DID receive reports (a normal mid-session
         quiet gap) must not log the serial-number hint."""

+ 199 - 0
backend/tests/unit/test_obico_detection.py

@@ -132,6 +132,205 @@ class TestTestConnection:
         assert result["body"] == "something else"
 
 
+class TestMlApiToken:
+    """Obico's ML API gates /p/ behind ML_API_TOKEN (#2733)."""
+
+    def test_auth_headers_only_when_configured(self):
+        from backend.app.services.obico_detection import auth_headers
+
+        assert auth_headers("s3cret") == {"Authorization": "Bearer s3cret"}
+        # Unconfigured must stay byte-identical to the pre-setting request.
+        assert auth_headers("") == {}
+        assert auth_headers(None) == {}
+        assert auth_headers("   ") == {}
+        # Whitespace around a real token is a paste artefact, not part of it.
+        assert auth_headers("  s3cret  ") == {"Authorization": "Bearer s3cret"}
+
+    def test_settings_schema_accepts_a_token(self):
+        assert AppSettingsUpdate(obico_ml_token="s3cret").obico_ml_token == "s3cret"
+        assert AppSettingsUpdate(obico_ml_token="").obico_ml_token == ""
+        assert AppSettingsUpdate().obico_ml_token is None
+
+    @staticmethod
+    def _settings(**overrides):
+        base = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "ml_token": "",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        base.update(overrides)
+        return base
+
+    @staticmethod
+    def _client(response):
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_detection_call_carries_the_bearer_header(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=200)
+        response.json.return_value = {"detections": []}
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings(ml_token="s3cret"))
+
+        assert mock_client.get.await_args.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
+
+    @pytest.mark.asyncio
+    async def test_detection_call_sends_no_header_without_a_token(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=200)
+        response.json.return_value = {"detections": []}
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings())
+
+        assert mock_client.get.await_args.kwargs["headers"] == {}
+
+    @pytest.mark.asyncio
+    async def test_401_reports_the_token_rather_than_a_bare_http_error(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=401)
+        # raise_for_status would also raise here; the status check must come first
+        # so the user gets an actionable message instead of "401 Unauthorized".
+        response.raise_for_status = MagicMock(side_effect=AssertionError("must not reach raise_for_status"))
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings(ml_token="wrong"))
+
+        assert "401" in svc._last_error
+        assert "ML_API_TOKEN" in svc._last_error
+        # A rejected call must not be scored as a clean frame.
+        assert 1 not in svc._states or svc._states[1].frame_count == 0
+
+    @pytest.mark.asyncio
+    async def test_401_message_does_not_leak_the_token(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=401)
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, status, self._settings(ml_token="sup3rs3cret"))
+
+        assert "sup3rs3cret" not in svc._last_error
+
+
+class TestTestConnectionTokenProbe:
+    """/hc/ is ungated, so health alone cannot validate the token (#2733)."""
+
+    @staticmethod
+    def _client(responses):
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(side_effect=responses)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_healthy_but_rejected_token_is_not_ok(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=401)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "wrong")
+
+        assert result["ok"] is False
+        assert result["auth_ok"] is False
+        assert result["status_code"] == 401
+        assert "ML_API_TOKEN" in result["error"]
+
+    @pytest.mark.asyncio
+    async def test_accepted_token_is_ok(self):
+        svc = ObicoDetectionService()
+        # 422 = "Invalid request params": auth passed, then the handler rejected
+        # the img-less probe. That is the success signal.
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "right")
+
+        assert result["ok"] is True
+        assert result["auth_ok"] is True
+        assert result["error"] is None
+
+    @pytest.mark.asyncio
+    async def test_probe_failure_leaves_the_token_unknown_but_keeps_the_test_ok(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), RuntimeError("read timeout")])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "maybe")
+
+        assert result["ok"] is True
+        assert result["auth_ok"] is None
+
+    @pytest.mark.asyncio
+    async def test_unhealthy_server_is_not_probed(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="error")])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "any")
+
+        assert result["ok"] is False
+        assert result["auth_ok"] is None
+        assert mock_client.get.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_both_requests_carry_the_header(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            await svc.test_connection("http://obico:3333", "s3cret")
+
+        assert [call.args[0] for call in mock_client.get.await_args_list] == [
+            "http://obico:3333/hc/",
+            "http://obico:3333/p/",
+        ]
+        for call in mock_client.get.await_args_list:
+            assert call.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
+
+    @pytest.mark.asyncio
+    async def test_url_policy_still_applies_before_any_request(self):
+        svc = ObicoDetectionService()
+        result = await svc.test_connection("http://169.254.169.254/latest/meta-data/", "s3cret")
+        assert result["ok"] is False
+        assert result["auth_ok"] is None
+        assert result["error"]
+
+
 class TestPollOneStateLifecycle:
     """Confirms per-printer state is reset when a new print starts."""
 

+ 115 - 0
backend/tests/unit/test_scheduler_watchdog.py

@@ -607,3 +607,118 @@ class TestWatchdogRetryBudget:
             item = await db.get(PrintQueueItem, 1)
             assert item.status == "printing"
             assert item.dispatch_attempts == 0
+
+
+class TestWatchdogCommandRejected:
+    """A printer reporting HMS 0500_0500_0001_0007 refused the command outright.
+
+    It is not wedged and it is not slow: its authorization check rejected a
+    command it could not verify, and it will reject the next two identically.
+    Spending the full 270 s and two more full 3MF uploads on that is 15 minutes
+    of a farm's upload capacity buying nothing, and it ends with a message about
+    SD cards (#2732).
+    """
+
+    @staticmethod
+    def _rejected_status(state: str = "IDLE", subtask_id: str | None = "NEW_SUBTASK"):
+        from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
+
+        return SimpleNamespace(
+            state=state,
+            subtask_id=subtask_id,
+            gcode_file="/new.3mf",
+            hms_errors=[SimpleNamespace(full_code=HMS_MQTT_VERIFY_FAILED)],
+        )
+
+    @staticmethod
+    async def _run(db_session, status):
+        get_status = MagicMock(return_value=status)
+        get_client = MagicMock(return_value=MagicMock())
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+            patch("backend.app.core.database.async_session", db_session),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_failed",
+                AsyncMock(),
+            ) as notify,
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="IDLE",
+                pre_subtask_id="OLD_SUBTASK",
+                pre_gcode_file="/old.3mf",
+                timeout=0.2,
+                phase_b_timeout=0.2,
+                poll_interval=0.05,
+            )
+        return get_client, notify
+
+    @pytest.mark.asyncio
+    async def test_fails_on_the_first_attempt(self, db_session):
+        await self._run(db_session, self._rejected_status())
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "failed", "a refused command must not be retried"
+            assert item.dispatch_attempts == 1, "it must not burn the whole budget"
+            assert item.completed_at is not None
+
+    @pytest.mark.asyncio
+    async def test_error_message_names_the_fix(self, db_session):
+        """The old wording sent this user to check their SD card."""
+        await self._run(db_session, self._rejected_status())
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert "0500-0500-0001-0007" in item.error_message
+            assert "Developer Mode" in item.error_message
+            assert "SD card" not in item.error_message
+
+    @pytest.mark.asyncio
+    async def test_detected_in_phase_a_before_any_subtask_advance(self, db_session):
+        """The printer can refuse without ever echoing a subtask_id."""
+        await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "failed"
+            assert item.dispatch_attempts == 1
+
+    @pytest.mark.asyncio
+    async def test_skips_the_forced_reconnect(self, db_session):
+        """The MQTT session is fine — reconnecting would only add 0500_4003 (#1150)."""
+        get_client, _ = await self._run(db_session, self._rejected_status(subtask_id="OLD_SUBTASK"))
+        get_client.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_notifies_with_the_rejection_reason(self, db_session):
+        _, notify = await self._run(db_session, self._rejected_status())
+
+        notify.assert_awaited_once()
+        assert "rejected" in notify.await_args.kwargs["reason"]
+
+    @pytest.mark.asyncio
+    async def test_unrelated_hms_still_takes_the_retry_path(self, db_session):
+        """Only this code short-circuits; every other fault keeps its retries."""
+        status = self._rejected_status()
+        status.hms_errors = [SimpleNamespace(full_code="0300020000018012")]
+
+        await self._run(db_session, status)
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "pending"
+            assert item.dispatch_attempts == 1
+
+    @pytest.mark.asyncio
+    async def test_a_printer_that_actually_starts_is_unaffected(self, db_session):
+        """A stale HMS from a previous job must not kill a print that is running."""
+        await self._run(db_session, self._rejected_status(state="RUNNING"))
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "printing"
+            assert item.dispatch_attempts == 0

+ 27 - 6
backend/tests/unit/test_support_helpers.py

@@ -701,19 +701,33 @@ class TestCollectSupportInfo:
 
 
 class TestParseObicoEnabledPrinters:
-    """Tests for the per-printer obico flag parser used by the bundle."""
+    """Tests for the per-printer obico flag parser used by the bundle.
 
-    def test_empty_string_returns_empty_set(self):
+    The setting is written by the settings UI as a JSON array and read by
+    ObicoDetectionService._load_settings as one; the bundle used to split it on
+    commas and call empty "no printers", so a default Obico setup was reported
+    as monitoring nothing while it was in fact monitoring everything (#2733).
+    """
+
+    def test_empty_means_all_printers(self):
+        from backend.app.api.routes.support import _parse_obico_enabled_printers
+
+        # None (not "no printers") — the same convention _load_settings uses.
+        assert _parse_obico_enabled_printers("") is None
+        assert _parse_obico_enabled_printers("   ") is None
+        assert _parse_obico_enabled_printers(None) is None
+
+    def test_json_array_is_the_stored_shape(self):
         from backend.app.api.routes.support import _parse_obico_enabled_printers
 
-        assert _parse_obico_enabled_printers("") == set()
-        assert _parse_obico_enabled_printers("   ") == set()
+        assert _parse_obico_enabled_printers("[1, 2, 3]") == {1, 2, 3}
+        assert _parse_obico_enabled_printers("[]") == set()
 
-    def test_comma_separated_ids(self):
+    def test_comma_separated_ids_still_parse(self):
+        # Legacy fallback for any install that stored the old shape.
         from backend.app.api.routes.support import _parse_obico_enabled_printers
 
         assert _parse_obico_enabled_printers("1,2,3") == {1, 2, 3}
-        # Whitespace around tokens is forgiven (matches obico_detection's parser).
         assert _parse_obico_enabled_printers("1, 2 ,3") == {1, 2, 3}
 
     def test_non_integer_tokens_are_skipped(self):
@@ -722,6 +736,13 @@ class TestParseObicoEnabledPrinters:
 
         assert _parse_obico_enabled_printers("1,abc,2") == {1, 2}
         assert _parse_obico_enabled_printers(",,1,") == {1}
+        assert _parse_obico_enabled_printers('[1, "two", 3]') == {1, 3}
+
+    def test_json_object_is_not_a_printer_list(self):
+        from backend.app.api.routes.support import _parse_obico_enabled_printers
+
+        # Falls through to the comma parser, which finds no integers.
+        assert _parse_obico_enabled_printers('{"1": true}') == set()
 
 
 class TestCheckUrlReachable:

+ 97 - 0
frontend/src/__tests__/components/FailureDetectionSettings.test.tsx

@@ -23,6 +23,7 @@ const baseSettings = {
   include_beta_updates: false,
   obico_enabled: false,
   obico_ml_url: '',
+  obico_ml_token: '',
   obico_sensitivity: 'medium',
   obico_action: 'notify',
   obico_poll_interval: 10,
@@ -83,6 +84,102 @@ describe('FailureDetectionSettings', () => {
     expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
   });
 
+  describe('ML API token (#2733)', () => {
+    const enabledWithToken = {
+      ...baseSettings,
+      obico_enabled: true,
+      obico_ml_url: 'http://obico:3333',
+      obico_ml_token: 's3cret',
+    };
+
+    it('renders the token as a masked field populated from settings', async () => {
+      server.use(http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)));
+      render(<FailureDetectionSettings />);
+
+      const input = await screen.findByDisplayValue('s3cret');
+      expect(input).toHaveAttribute('type', 'password');
+      expect(screen.getByText(/ML API Token/i)).toBeInTheDocument();
+    });
+
+    it('sends the token with the test-connection request', async () => {
+      let sent: { url: string; token?: string } | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', async ({ request }) => {
+          sent = (await request.json()) as { url: string; token?: string };
+          return HttpResponse.json({
+            ok: true,
+            status_code: 200,
+            body: 'ok',
+            error: null,
+            auth_ok: true,
+          });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      await waitFor(() => expect(sent).not.toBeNull());
+      // The value in the box, not the saved one — so a token can be checked
+      // before it is committed.
+      expect(sent!.token).toBe('s3cret');
+    });
+
+    it('reports a rejected token instead of a bare success', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', () =>
+          HttpResponse.json({
+            ok: false,
+            status_code: 401,
+            body: 'ok',
+            error: 'The ML API is reachable but rejected the token.',
+            auth_ok: false,
+          }),
+        ),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      expect(await screen.findByText(/rejected the token/i)).toBeInTheDocument();
+    });
+
+    it('does not claim the token works when it could not be checked', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', () =>
+          HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null, auth_ok: null }),
+        ),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      expect(await screen.findByText(/token could not be checked/i)).toBeInTheDocument();
+    });
+
+    it('auto-saves the token', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ ...enabledWithToken, obico_ml_token: '' })),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...enabledWithToken, obico_ml_token: 'typed' });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
+      // Every field stays disabled until the settings query lands.
+      await waitFor(() => expect(input).not.toBeDisabled());
+      await userEvent.type(input, 'typed');
+
+      await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
+      expect(saved!.obico_ml_token).toBe('typed');
+    });
+  });
+
   it('shows failure class history entries with red styling', async () => {
     server.use(
       http.get('/api/v1/obico/status', () =>

+ 45 - 1
frontend/src/__tests__/components/HMSErrorModal.test.tsx

@@ -6,7 +6,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
 import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
-import { HMSErrorModal } from '../../components/HMSErrorModal';
+import { HMSErrorModal, filterKnownHMSErrors } from '../../components/HMSErrorModal';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import type { HMSError } from '../../api/client';
@@ -189,6 +189,50 @@ describe('HMSErrorModal', () => {
     });
   });
 
+  describe('MQTT command verification failed (#2732)', () => {
+    // attr 0x05000500, code 0x00010007 — a real P1S on firmware 01.10.00.00.
+    // getShortCode() collapses this to "0500_0007", which matches nothing, so
+    // before #2732 filterKnownHMSErrors dropped the one error that explained
+    // why the printer accepted every job and started none of them.
+    const verifyFailedError: HMSError = {
+      attr: 0x05000500,
+      code: '0x10007',
+      severity: 1,
+      full_code: '0500050000010007',
+    };
+
+    it('surfaces the error instead of filtering it out', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
+      expect(screen.queryByText('No errors')).not.toBeInTheDocument();
+      expect(screen.getByText(/could not verify it/i)).toBeInTheDocument();
+    });
+
+    it('counts towards the known-error filter', () => {
+      expect(filterKnownHMSErrors([verifyFailedError])).toHaveLength(1);
+    });
+
+    it('shows the remedy, not just the fault', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
+      expect(screen.getByText(/Enable Developer Mode on the printer/i)).toBeInTheDocument();
+    });
+
+    it('displays the code the printer screen shows, not the truncated form', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[verifyFailedError]} />);
+      expect(screen.getByText('[0500-0500-0001-0007]')).toBeInTheDocument();
+      expect(screen.queryByText('[0500-0007]')).not.toBeInTheDocument();
+    });
+
+    it('leaves short-code errors on the two-group display', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
+      expect(screen.getByText('[0300-400C]')).toBeInTheDocument();
+    });
+
+    it('does not add the remedy line to other errors', () => {
+      render(<HMSErrorModal {...defaultProps} errors={[knownError]} />);
+      expect(screen.queryByText(/Enable Developer Mode/i)).not.toBeInTheDocument();
+    });
+  });
+
   describe('interactions', () => {
     it('calls onClose when X button is clicked', async () => {
       const user = userEvent.setup();

+ 8 - 2
frontend/src/api/client.ts

@@ -1337,6 +1337,7 @@ export interface AppSettings {
   ldap_default_group: string;
   obico_enabled: boolean;
   obico_ml_url: string;
+  obico_ml_token: string;
   obico_sensitivity: 'low' | 'medium' | 'high';
   obico_action: 'notify' | 'pause' | 'pause_and_off';
   obico_poll_interval: number;
@@ -2784,6 +2785,9 @@ export interface ObicoTestConnection {
   status_code: number | null;
   body: string | null;
   error: string | null;
+  // Whether the ML API accepted the token. null = not determined (the health
+  // check failed first, or the token probe itself errored).
+  auth_ok: boolean | null;
 }
 
 export interface GitHubTestConnectionResponse {
@@ -6527,10 +6531,12 @@ export const api = {
   getObicoPrinterStatus: () =>
     request<ObicoPrinterStatus>('/obico/printer-status'),
 
-  testObicoConnection: (url: string) =>
+  // `token` is sent as-is, so an empty string tests with no token at all.
+  // Omitting the argument makes the backend fall back to the saved token.
+  testObicoConnection: (url: string, token?: string) =>
     request<ObicoTestConnection>('/obico/test-connection', {
       method: 'POST',
-      body: JSON.stringify({ url }),
+      body: JSON.stringify(token === undefined ? { url } : { url, token }),
     }),
 
   // Slicer API — slice in the background. Both endpoints return 202 + a

+ 29 - 3
frontend/src/components/FailureDetectionSettings.tsx

@@ -17,6 +17,7 @@ export function FailureDetectionSettings() {
 
   const [enabled, setEnabled] = useState(false);
   const [mlUrl, setMlUrl] = useState('');
+  const [mlToken, setMlToken] = useState('');
   const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
   const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
   const [pollInterval, setPollInterval] = useState(10);
@@ -44,6 +45,7 @@ export function FailureDetectionSettings() {
     if (!settings) return;
     setEnabled(settings.obico_enabled ?? false);
     setMlUrl(settings.obico_ml_url ?? '');
+    setMlToken(settings.obico_ml_token ?? '');
     setSensitivity(settings.obico_sensitivity ?? 'medium');
     setAction(settings.obico_action ?? 'notify');
     setPollInterval(settings.obico_poll_interval ?? 10);
@@ -63,6 +65,7 @@ export function FailureDetectionSettings() {
       api.updateSettings({
         obico_enabled: enabled,
         obico_ml_url: mlUrl,
+        obico_ml_token: mlToken,
         obico_sensitivity: sensitivity,
         obico_action: action,
         obico_poll_interval: pollInterval,
@@ -84,6 +87,7 @@ export function FailureDetectionSettings() {
     const changed =
       settings.obico_enabled !== enabled ||
       settings.obico_ml_url !== mlUrl ||
+      (settings.obico_ml_token ?? '') !== mlToken ||
       settings.obico_sensitivity !== sensitivity ||
       settings.obico_action !== action ||
       settings.obico_poll_interval !== pollInterval ||
@@ -92,14 +96,23 @@ export function FailureDetectionSettings() {
     const id = setTimeout(() => saveMutation.mutate(), 500);
     return () => clearTimeout(id);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
+  }, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]);
 
   const handleTest = async () => {
     setTestResult(null);
     try {
-      const res = await api.testObicoConnection(mlUrl);
+      const res = await api.testObicoConnection(mlUrl, mlToken);
       if (res.ok) {
-        setTestResult({ ok: true, message: t('failureDetection.testSuccess') });
+        // auth_ok is null when the token could not be checked — don't claim it
+        // works. It is true both for an accepted token and for a server that
+        // requires none, which is the same outcome for the user.
+        setTestResult({
+          ok: true,
+          message:
+            res.auth_ok === null
+              ? t('failureDetection.testSuccessTokenUnknown')
+              : t('failureDetection.testSuccess'),
+        });
       } else {
         setTestResult({
           ok: false,
@@ -163,6 +176,19 @@ export function FailureDetectionSettings() {
                 </Button>
               </div>
               <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
+              <label className="block text-sm text-bambu-gray mb-1 mt-3">
+                {t('failureDetection.mlToken')}
+              </label>
+              <input
+                type="password"
+                value={mlToken}
+                onChange={(e) => setMlToken(e.target.value)}
+                autoComplete="off"
+                placeholder={t('failureDetection.mlTokenPlaceholder')}
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+                disabled={!enabled}
+              />
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
               {testResult && (
                 <div
                   className={`flex items-start gap-2 mt-2 text-sm ${

+ 43 - 5
frontend/src/components/HMSErrorModal.tsx

@@ -33,9 +33,20 @@ const AMS_RUNOUT_SHORT_CODES = new Set([
   '0705_8011', '0706_8011', '0707_8011', '07FF_8011',
 ]);
 
-// Comprehensive error code database (short format: XXXX_YYYY)
-// Auto-generated from ha-bambulab - 853 codes
+// "MQTT command verification failed" — the firmware's authorization check
+// refusing a control command. Keyed by its full 16-char code on purpose: this
+// error's meaning lives in attr's low half (0500) and code's high half (0001),
+// both of which getShortCode() discards, so the short form is a useless
+// "0500_0007". Before #2732 that meant filterKnownHMSErrors dropped it and the
+// user was never shown the one message that explained why nothing printed.
+export const HMS_MQTT_VERIFY_FAILED = '0500050000010007';
+
+// Comprehensive error code database, keyed by short code (XXXX_YYYY) or, where
+// the short code cannot express the error, by full code (16 hex chars).
+// Short-code entries auto-generated from ha-bambulab - 853 codes
 const ERROR_DESCRIPTIONS: Record<string, string> = {
+  [HMS_MQTT_VERIFY_FAILED]:
+    'The printer rejected a command because it could not verify it. Prints, temperature changes and filament loads sent from Bambuddy will be ignored until this is fixed.',
   '0300_4000': 'Z axis homing failed; the task has been stopped.',
   '0300_4001': 'The printer timed out waiting for the nozzle to cool down before homing.',
   '0300_4002': 'Auto Bed Leveling failed; the task has been stopped.',
@@ -913,6 +924,15 @@ function getShortCode(attr: number, code: number): string {
   return `${module.toString(16).padStart(4, '0').toUpperCase()}_${codeNum.toString(16).padStart(4, '0').toUpperCase()}`;
 }
 
+// Catalog lookup. full_code (16 hex chars for hms[]-sourced faults) is tried
+// first because it is lossless; shortCode is the fallback that the bulk of the
+// catalog is keyed by. Returns undefined for an uncataloged error so callers can
+// tell "no description" from "empty description".
+function lookupDescription(fullCode: string | undefined, shortCode: string): string | undefined {
+  if (fullCode && ERROR_DESCRIPTIONS[fullCode] !== undefined) return ERROR_DESCRIPTIONS[fullCode];
+  return ERROR_DESCRIPTIONS[shortCode];
+}
+
 // Helper to filter HMS errors the UI should surface (exported for use in badge counts).
 // Keeps an error if EITHER:
 //   - it's in the bundled ERROR_DESCRIPTIONS catalog (known, has a description), OR
@@ -925,7 +945,7 @@ export function filterKnownHMSErrors(errors: HMSError[]): HMSError[] {
   return errors.filter((error) => {
     const codeNum = parseInt(error.code.replace('0x', ''), 16) || 0;
     const shortCode = getShortCode(error.attr, codeNum);
-    if (ERROR_DESCRIPTIONS[shortCode] !== undefined) return true;
+    if (lookupDescription(error.full_code, shortCode) !== undefined) return true;
     return (error.actions?.length ?? 0) > 0;
   });
 }
@@ -1023,7 +1043,17 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                 // Runout guidance (#2587): for an AMS per-slot runout on a paused
                 // print, name the slot the firmware now expects rather than the
                 // misleading generic "insert into the same slot" text.
-                let description = ERROR_DESCRIPTIONS[shortCode] ?? t('hmsErrors.unknownCode');
+                const matchedFullCode =
+                  !!error.full_code && ERROR_DESCRIPTIONS[error.full_code] !== undefined;
+                let description =
+                  lookupDescription(error.full_code, shortCode) ?? t('hmsErrors.unknownCode');
+                // The remedy is Bambuddy's, not Bambu's — their wiki says "update
+                // Studio or Handy", which is no help to someone printing from
+                // Bambuddy. Same override shape as the runout guidance below.
+                const remedy =
+                  error.full_code === HMS_MQTT_VERIFY_FAILED
+                    ? t('hmsErrors.mqttVerifyFailedRemedy')
+                    : null;
                 if (runoutGuidance && AMS_RUNOUT_SHORT_CODES.has(shortCode)) {
                   if (runoutGuidance.expectedSlotLabel && runoutGuidance.ranOutSlotLabel) {
                     description = t('hmsErrors.runoutExpectedSlot', {
@@ -1039,7 +1069,14 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                   }
                 }
                 const hmsHomeUrl = getHMSHomeUrl();
-                const displayCode = shortCode.replace('_', '-');
+                // Show the printer's own four-group notation when the short code
+                // could not identify the error — for those, "0500-0007" matches
+                // nothing the user can look up, while "0500-0500-0001-0007" is
+                // exactly what the printer screen and the Bambu wiki show.
+                const displayCode =
+                  matchedFullCode && error.full_code!.length === 16
+                    ? error.full_code!.match(/.{4}/g)!.join('-')
+                    : shortCode.replace('_', '-');
 
                 return (
                   <div
@@ -1056,6 +1093,7 @@ export function HMSErrorModal({ printerName, errors, onClose, printerId, hasPerm
                           </span>
                         </div>
                         <p className="text-sm text-bambu-gray mb-2">{description}</p>
+                        {remedy && <p className="text-sm text-bambu-gray mb-2">{remedy}</p>}
                         {error.actions && error.actions.length > 0 && (
                           <div className="flex flex-wrap gap-2 my-2">
                             {error.actions.map((action) => {

+ 5 - 0
frontend/src/i18n/locales/de.ts

@@ -2843,6 +2843,7 @@ export default {
     title: 'Fehler - {{name}}',
     noErrors: 'Keine Fehler',
     viewOnWiki: 'Im Bambu Lab Wiki ansehen',
+    mqttVerifyFailedRemedy: 'Aktiviere den Entwicklermodus am Drucker (Einstellungen > Allgemein), starte den Drucker neu und starte den Auftrag dann erneut.',
     unknownCode: 'Unbekannter HMS-Code — Details siehe Bambu Lab Wiki.',
     clearInstructions: 'Löschen Sie die Fehler am Drucker, um sie hier zu entfernen.',
     clearErrors: 'Fehler löschen',
@@ -6544,8 +6545,12 @@ export default {
     description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',
     mlUrl: 'Obico-ML-API-URL',
     mlUrlHint: 'Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).',
+    mlToken: 'ML-API-Token (optional)',
+    mlTokenPlaceholder: 'Leer lassen, wenn der Server ohne ML_API_TOKEN läuft',
+    mlTokenHint: 'Muss mit der Umgebungsvariable ML_API_TOKEN deines Obico-ml_api-Containers übereinstimmen. Leer lassen, wenn der Container ohne Token läuft.',
     test: 'Testen',
     testSuccess: 'ML-API erreichbar und funktionsfähig.',
+    testSuccessTokenUnknown: 'ML-API erreichbar und funktionsfähig. Das Token konnte nicht geprüft werden.',
     testFailed: 'ML-API konnte nicht erreicht werden.',
     sensitivity: 'Empfindlichkeit',
     sensitivityLow: 'Niedrig (weniger Fehlalarme)',

+ 5 - 0
frontend/src/i18n/locales/en.ts

@@ -2872,6 +2872,7 @@ export default {
     title: 'Errors - {{name}}',
     noErrors: 'No errors',
     viewOnWiki: 'View on Bambu Lab Wiki',
+    mqttVerifyFailedRemedy: 'Enable Developer Mode on the printer (Settings > General), restart the printer, then start the job again.',
     unknownCode: 'Unknown HMS code — see the Bambu Lab wiki for details.',
     clearInstructions: 'Clear errors on the printer to dismiss them here.',
     clearErrors: 'Clear Errors',
@@ -6588,8 +6589,12 @@ export default {
     description: 'Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.',
     mlUrl: 'Obico ML API URL',
     mlUrlHint: 'Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).',
+    mlToken: 'ML API Token (optional)',
+    mlTokenPlaceholder: 'Leave empty if the server runs without ML_API_TOKEN',
+    mlTokenHint: 'Must match the ML_API_TOKEN environment variable of your Obico ml_api container. Leave empty when the container runs without one.',
     test: 'Test',
     testSuccess: 'ML API reachable and healthy.',
+    testSuccessTokenUnknown: 'ML API reachable and healthy. The token could not be checked.',
     testFailed: 'Could not reach the ML API.',
     sensitivity: 'Sensitivity',
     sensitivityLow: 'Low (fewer false positives)',

+ 5 - 0
frontend/src/i18n/locales/es.ts

@@ -2846,6 +2846,7 @@ export default {
     title: 'Errores - {{name}}',
     noErrors: 'No hay errores',
     viewOnWiki: 'Ver en la wiki de Bambu Lab',
+    mqttVerifyFailedRemedy: 'Activa el modo desarrollador en la impresora (Ajustes > General), reinicia la impresora y vuelve a iniciar el trabajo.',
     unknownCode: 'Código HMS desconocido — consulta la wiki de Bambu Lab para más detalles.',
     clearInstructions: 'Borre los errores en la impresora para descartarlos aquí.',
     clearErrors: 'Borrar errores',
@@ -6553,8 +6554,12 @@ export default {
     description: 'Supervise las impresiones con una API de ML de Obico autoalojada y actúe automáticamente ante los fallos detectados.',
     mlUrl: 'URL de la API de ML de Obico',
     mlUrlHint: 'URL base de su contenedor ml_api de Obico autoalojado (p. ej. http://192.168.1.10:3333).',
+    mlToken: 'Token de la API de ML (opcional)',
+    mlTokenPlaceholder: 'Déjelo vacío si el servidor funciona sin ML_API_TOKEN',
+    mlTokenHint: 'Debe coincidir con la variable de entorno ML_API_TOKEN de su contenedor ml_api de Obico. Déjelo vacío si el contenedor funciona sin token.',
     test: 'Probar',
     testSuccess: 'API de ML accesible y correcta.',
+    testSuccessTokenUnknown: 'API de ML accesible y correcta. No se pudo comprobar el token.',
     testFailed: 'No se pudo alcanzar la API de ML.',
     sensitivity: 'Sensibilidad',
     sensitivityLow: 'Baja (menos falsos positivos)',

+ 5 - 0
frontend/src/i18n/locales/fr.ts

@@ -2832,6 +2832,7 @@ export default {
     title: 'Erreurs - {{name}}',
     noErrors: 'Aucune erreur',
     viewOnWiki: 'Voir sur le Wiki Bambu Lab',
+    mqttVerifyFailedRemedy: "Activez le mode developpeur sur l'imprimante (Parametres > General), redemarrez l'imprimante, puis relancez la tache.",
     unknownCode: 'Code HMS inconnu — consultez le wiki Bambu Lab pour plus de détails.',
     clearInstructions: 'Effacez les erreurs sur l\'imprimante pour les retirer ici.',
     clearErrors: 'Effacer les erreurs',
@@ -6534,8 +6535,12 @@ export default {
     description: 'Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.',
     mlUrl: 'URL de l\'API ML Obico',
     mlUrlHint: 'URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).',
+    mlToken: 'Jeton de l\'API ML (facultatif)',
+    mlTokenPlaceholder: 'Laissez vide si le serveur fonctionne sans ML_API_TOKEN',
+    mlTokenHint: 'Doit correspondre à la variable d\'environnement ML_API_TOKEN de votre conteneur Obico ml_api. Laissez vide si le conteneur fonctionne sans jeton.',
     test: 'Tester',
     testSuccess: 'API ML accessible et fonctionnelle.',
+    testSuccessTokenUnknown: 'API ML accessible et fonctionnelle. Le jeton n\'a pas pu être vérifié.',
     testFailed: 'Impossible d\'atteindre l\'API ML.',
     sensitivity: 'Sensibilité',
     sensitivityLow: 'Basse (moins de faux positifs)',

+ 5 - 0
frontend/src/i18n/locales/it.ts

@@ -2831,6 +2831,7 @@ export default {
     title: 'Errori - {{name}}',
     noErrors: 'Nessun errore',
     viewOnWiki: 'Vedi su Bambu Lab Wiki',
+    mqttVerifyFailedRemedy: 'Attiva la modalita sviluppatore sulla stampante (Impostazioni > Generale), riavvia la stampante e avvia di nuovo il lavoro.',
     unknownCode: 'Codice HMS sconosciuto — consulta la wiki di Bambu Lab per i dettagli.',
     clearInstructions: 'Cancella gli errori sulla stampante per rimuoverli qui.',
     clearErrors: 'Cancella errori',
@@ -6533,8 +6534,12 @@ export default {
     description: 'Monitora le stampe tramite un\'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.',
     mlUrl: 'URL API ML Obico',
     mlUrlHint: 'URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).',
+    mlToken: 'Token API ML (facoltativo)',
+    mlTokenPlaceholder: 'Lascia vuoto se il server funziona senza ML_API_TOKEN',
+    mlTokenHint: 'Deve corrispondere alla variabile di ambiente ML_API_TOKEN del tuo container Obico ml_api. Lascia vuoto se il container funziona senza token.',
     test: 'Prova',
     testSuccess: 'API ML raggiungibile e funzionante.',
+    testSuccessTokenUnknown: 'API ML raggiungibile e funzionante. Non è stato possibile verificare il token.',
     testFailed: 'Impossibile raggiungere l\'API ML.',
     sensitivity: 'Sensibilità',
     sensitivityLow: 'Bassa (meno falsi positivi)',

+ 5 - 0
frontend/src/i18n/locales/ja.ts

@@ -2843,6 +2843,7 @@ export default {
     title: 'エラー - {{name}}',
     noErrors: 'エラーなし',
     viewOnWiki: 'Bambu Lab Wikiで表示',
+    mqttVerifyFailedRemedy: 'プリンターで開発者モードを有効にし(設定 > 一般)、プリンターを再起動してから、ジョブをもう一度開始してください。',
     unknownCode: '不明なHMSコード — 詳細はBambu Lab Wikiを参照してください。',
     clearInstructions: 'プリンターでエラーをクリアするとここからも消えます。',
     clearErrors: 'エラーをクリア',
@@ -6545,8 +6546,12 @@ export default {
     description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
     mlUrl: 'Obico ML API の URL',
     mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',
+    mlToken: 'ML API トークン(任意)',
+    mlTokenPlaceholder: 'サーバーが ML_API_TOKEN なしで動作している場合は空のままにします',
+    mlTokenHint: 'Obico ml_api コンテナの環境変数 ML_API_TOKEN と一致させる必要があります。コンテナがトークンなしで動作している場合は空のままにしてください。',
     test: 'テスト',
     testSuccess: 'ML API に接続でき、正常です。',
+    testSuccessTokenUnknown: 'ML API に接続でき、正常です。トークンは確認できませんでした。',
     testFailed: 'ML API に接続できませんでした。',
     sensitivity: '感度',
     sensitivityLow: '低(誤検出が少ない)',

+ 5 - 0
frontend/src/i18n/locales/ko.ts

@@ -2693,6 +2693,7 @@ export default {
     title: '오류 - {{name}}',
     noErrors: '오류 없음',
     viewOnWiki: 'Bambu Lab 위키에서 보기',
+    mqttVerifyFailedRemedy: '프린터에서 개발자 모드를 활성화하고(설정 > 일반) 프린터를 재시작한 다음 작업을 다시 시작하세요.',
     unknownCode: '알 수 없는 HMS 코드 — 자세한 내용은 Bambu Lab 위키를 참조하세요.',
     clearInstructions: '오류를 해제하려면 프린터에서 오류를 지우세요.',
     clearErrors: '오류 지우기',
@@ -6013,8 +6014,12 @@ export default {
     description: '자체 호스팅 Obico ML API로 인쇄를 모니터링하고 감지된 실패에 자동으로 조치합니다.',
     mlUrl: 'Obico ML API URL',
     mlUrlHint: '자체 호스팅 Obico ml_api 컨테이너의 기본 URL (예: http://192.168.1.10:3333).',
+    mlToken: 'ML API 토큰 (선택 사항)',
+    mlTokenPlaceholder: '서버가 ML_API_TOKEN 없이 실행 중이면 비워 두세요',
+    mlTokenHint: 'Obico ml_api 컨테이너의 ML_API_TOKEN 환경 변수와 일치해야 합니다. 컨테이너가 토큰 없이 실행 중이면 비워 두세요.',
     test: '테스트',
     testSuccess: 'ML API에 도달 가능하며 정상입니다.',
+    testSuccessTokenUnknown: 'ML API에 도달 가능하며 정상입니다. 토큰은 확인할 수 없었습니다.',
     testFailed: 'ML API에 도달할 수 없습니다.',
     sensitivity: '민감도',
     sensitivityLow: '낮음 (오탐 적음)',

+ 5 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2831,6 +2831,7 @@ export default {
     title: 'Erros - {{name}}',
     noErrors: 'Nenhum erro',
     viewOnWiki: 'Ver no Bambu Lab Wiki',
+    mqttVerifyFailedRemedy: 'Ative o Modo Desenvolvedor na impressora (Configuracoes > Geral), reinicie a impressora e inicie o trabalho novamente.',
     unknownCode: 'Código HMS desconhecido — consulte o wiki da Bambu Lab para mais detalhes.',
     clearInstructions: 'Limpe os erros na impressora para descartá-los aqui.',
     clearErrors: 'Limpar Erros',
@@ -6533,8 +6534,12 @@ export default {
     description: 'Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.',
     mlUrl: 'URL da API ML do Obico',
     mlUrlHint: 'URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).',
+    mlToken: 'Token da API ML (opcional)',
+    mlTokenPlaceholder: 'Deixe vazio se o servidor for executado sem ML_API_TOKEN',
+    mlTokenHint: 'Deve corresponder à variável de ambiente ML_API_TOKEN do seu contêiner Obico ml_api. Deixe vazio se o contêiner for executado sem token.',
     test: 'Testar',
     testSuccess: 'API ML acessível e operacional.',
+    testSuccessTokenUnknown: 'API ML acessível e operacional. Não foi possível verificar o token.',
     testFailed: 'Não foi possível acessar a API ML.',
     sensitivity: 'Sensibilidade',
     sensitivityLow: 'Baixa (menos falsos positivos)',

+ 5 - 0
frontend/src/i18n/locales/ru.ts

@@ -2685,6 +2685,7 @@ export default {
     title: "Ошибки — {{name}}",
     noErrors: "Ошибок нет",
     viewOnWiki: "Открыть в Bambu Lab Wiki",
+    mqttVerifyFailedRemedy: "Включите режим разработчика на принтере (Настройки > Общие), перезагрузите принтер и запустите задание снова.",
     unknownCode: "Неизвестный код HMS — подробности см. в Bambu Lab Wiki.",
     clearInstructions: "Устраните ошибки на принтере, чтобы они исчезли из этого списка.",
     clearErrors: "Очистить ошибки",
@@ -6172,8 +6173,12 @@ export default {
     description: "Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "Базовый URL собственного контейнера Obico ml_api, например http://192.168.1.10:3333.",
+    mlToken: "Токен ML API (необязательно)",
+    mlTokenPlaceholder: "Оставьте пустым, если сервер работает без ML_API_TOKEN",
+    mlTokenHint: "Должен совпадать с переменной окружения ML_API_TOKEN вашего контейнера Obico ml_api. Оставьте пустым, если контейнер работает без токена.",
     test: "Проверить",
     testSuccess: "ML API доступен и работает.",
+    testSuccessTokenUnknown: "ML API доступен и работает. Проверить токен не удалось.",
     testFailed: "Не удалось подключиться к ML API.",
     sensitivity: "Чувствительность",
     sensitivityLow: "Низкая (меньше ложных срабатываний)",

+ 5 - 0
frontend/src/i18n/locales/tr.ts

@@ -2847,6 +2847,7 @@ export default {
     title: 'Hatalar - {{name}}',
     noErrors: 'Hata yok',
     viewOnWiki: 'Bambu Lab Wiki\'de görüntüle',
+    mqttVerifyFailedRemedy: 'Yazicida Gelistirici Modunu etkinlestirin (Ayarlar > Genel), yaziciyi yeniden baslatin ve isi tekrar baslatin.',
     unknownCode: 'Bilinmeyen HMS kodu — ayrıntılar için Bambu Lab wiki sayfasına bakın.',
     clearInstructions: 'Buradan kapatmak için yazıcıdaki hataları temizleyin.',
     clearErrors: 'Hataları Temizle',
@@ -6484,8 +6485,12 @@ export default {
     description: 'Baskıları kendi barındırılan bir Obico ML API ile izle ve algılanan başarısızlıklara otomatik olarak yanıt ver.',
     mlUrl: 'Obico ML API URL\'si',
     mlUrlHint: 'Kendi barındırılan Obico ml_api konteynerinizin temel URL\'si (örn. http://192.168.1.10:3333).',
+    mlToken: 'ML API Belirteci (isteğe bağlı)',
+    mlTokenPlaceholder: 'Sunucu ML_API_TOKEN olmadan çalışıyorsa boş bırakın',
+    mlTokenHint: 'Obico ml_api konteynerinizin ML_API_TOKEN ortam değişkeniyle eşleşmelidir. Konteyner belirteç olmadan çalışıyorsa boş bırakın.',
     test: 'Test',
     testSuccess: 'ML API erişilebilir ve sağlıklı.',
+    testSuccessTokenUnknown: 'ML API erişilebilir ve sağlıklı. Belirteç doğrulanamadı.',
     testFailed: 'ML API\'ye erişilemedi.',
     sensitivity: 'Hassasiyet',
     sensitivityLow: 'Düşük (daha az yanlış pozitif)',

+ 5 - 0
frontend/src/i18n/locales/uk.ts

@@ -2872,6 +2872,7 @@ export default {
     title: "Помилки - {{name}}",
     noErrors: "Помилок немає",
     viewOnWiki: "Переглянути на Bambu Lab Wiki",
+    mqttVerifyFailedRemedy: "Увімкніть режим розробника на принтері (Налаштування > Загальні), перезавантажте принтер і запустіть завдання знову.",
     unknownCode: "Невідомий код HMS — подробиці дивіться у вікі Bambu Lab.",
     clearInstructions: "Усуньте помилки на принтері, щоб вони зникли тут.",
     clearErrors: "Очистити помилки",
@@ -6588,8 +6589,12 @@ export default {
     description: "Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).",
+    mlToken: "Токен ML API (необов'язково)",
+    mlTokenPlaceholder: "Залиште порожнім, якщо сервер працює без ML_API_TOKEN",
+    mlTokenHint: "Має збігатися зі змінною середовища ML_API_TOKEN вашого контейнера Obico ml_api. Залиште порожнім, якщо контейнер працює без токена.",
     test: "Тест",
     testSuccess: "ML API доступний і працює належним чином.",
+    testSuccessTokenUnknown: "ML API доступний і працює належним чином. Не вдалося перевірити токен.",
     testFailed: "Не вдалося підключитися до ML API.",
     sensitivity: "Чутливість",
     sensitivityLow: "Низький (менше помилкових спрацьовувань)",

+ 5 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2831,6 +2831,7 @@ export default {
     title: '错误 - {{name}}',
     noErrors: '无错误',
     viewOnWiki: '在拓竹 Wiki 上查看',
+    mqttVerifyFailedRemedy: '在打印机上启用开发者模式(设置 > 通用),重启打印机,然后重新开始该任务。',
     unknownCode: '未知 HMS 代码 — 详情请参阅拓竹 Wiki。',
     clearInstructions: '在打印机上清除错误以在此处消除它们。',
     clearErrors: '清除错误',
@@ -6532,8 +6533,12 @@ export default {
     description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',
+    mlToken: 'ML API 令牌(可选)',
+    mlTokenPlaceholder: '如果服务器未设置 ML_API_TOKEN,请留空',
+    mlTokenHint: '必须与您的 Obico ml_api 容器的 ML_API_TOKEN 环境变量一致。如果容器未使用令牌,请留空。',
     test: '测试',
     testSuccess: 'ML API 可访问且正常。',
+    testSuccessTokenUnknown: 'ML API 可访问且正常。无法验证令牌。',
     testFailed: '无法访问 ML API。',
     sensitivity: '灵敏度',
     sensitivityLow: '低(减少误报)',

+ 5 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2831,6 +2831,7 @@ export default {
     title: '錯誤 - {{name}}',
     noErrors: '無錯誤',
     viewOnWiki: '在拓竹 Wiki 上檢視',
+    mqttVerifyFailedRemedy: '在印表機上啟用開發者模式(設定 > 一般),重新啟動印表機,然後重新開始該工作。',
     unknownCode: '未知 HMS 代碼 — 詳情請參閱拓竹 Wiki。',
     clearInstructions: '在印表機上清除錯誤以在此處消除它們。',
     clearErrors: '清除錯誤',
@@ -6532,8 +6533,12 @@ export default {
     description: '透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。',
+    mlToken: 'ML API 權杖(選填)',
+    mlTokenPlaceholder: '如果伺服器未設定 ML_API_TOKEN,請留空',
+    mlTokenHint: '必須與您的 Obico ml_api 容器的 ML_API_TOKEN 環境變數一致。如果容器未使用權杖,請留空。',
     test: '測試',
     testSuccess: 'ML API 可存取且正常。',
+    testSuccessTokenUnknown: 'ML API 可存取且正常。無法驗證權杖。',
     testFailed: '無法存取 ML API。',
     sensitivity: '靈敏度',
     sensitivityLow: '低(減少誤報)',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-I83QBJfM.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Bx2Rwvpi.js"></script>
+    <script type="module" crossorigin src="/assets/index-I83QBJfM.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов