Browse Source

fix(obico): revert POST-bytes approach — Obico /p/ is GET-only

  The 0.2.3b4 #1003 "fix" POSTed JPEG bytes as multipart form data,
  but Obico's /p/ endpoint is declared methods=['GET'] upstream and
  reads ?img=URL from the query string. Every POST was 405'd by
  Flask's router before any handler ran, which is why the Obico
  container logs were silent while Bambuddy kept reporting
  "ML API call failed for printer N:" with a blank suffix —
  raise_for_status() on the 405 produced an exception whose str()
  rendered empty.

  Restored the pre-#1003 nonce-URL approach (commit 3e434458):
  capture locally with a 20s timeout we control, stash the JPEG
  under a single-use 32-byte nonce, hand Obico a
  GET /api/v1/obico/cached-frame/{nonce} URL that resolves in
  <50ms so its hardcoded 5s read timeout never races RTSP.

  Also guards against future silent exceptions: the error format
  now falls back to type(exc).__name__ when str(exc) is empty.
  Detection also early-returns with an explicit error if
  external_url is unset instead of handing Obico a URL it can't
  resolve.

  The #1003 reverse-proxy scenario (Authelia/Authentik/CF Access
  in front of Bambuddy) is addressed by documenting that the
  /api/v1/obico/cached-frame/ path must be whitelisted from
  external auth at the proxy layer — it is already public on
  Bambuddy's side.

  Backend: services/obico_detection.py, api/routes/obico.py,
  main.py (PUBLIC_API_PATTERNS).
  Frontend: FailureDetectionSettings banner + client.ts type +
  all 7 locales restored.
  Tests: 15 unit + 5 integration tests pass.
maziggy 1 month ago
parent
commit
a2c7fd4542

+ 2 - 2
CHANGELOG.md

@@ -10,7 +10,7 @@ All notable changes to Bambuddy will be documented in this file.
   - **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).
   - **Enclosure Door badge** in the top status row (DoorOpen/DoorClosed icons, green when closed, yellow when open). Detection uses the right MQTT field per printer family — `home_flag` bit 23 on X1/X1C/X1E and the top-level `stat` hex string bit 23 on P1/P2/H2 — and falls through the existing WebSocket push (status-change dedup key now includes door state, so toggling the door alone triggers a live badge update without waiting for the 30 s REST poll).
   - **Airduct Mode badge** beside the print speed control (Snowflake/Flame icons, sky for Cooling and orange for Heating). One-click dropdown switches the printer between cooling and heating via the existing `set_airduct` MQTT command. Gated to P2S/H2D/H2C/H2S.
   - **Airduct Mode badge** beside the print speed control (Snowflake/Flame icons, sky for Cooling and orange for Heating). One-click dropdown switches the printer between cooling and heating via the existing `set_airduct` MQTT command. Gated to P2S/H2D/H2C/H2S.
   - **Force Refresh** menu entry in the printer card kebab menu (RotateCw icon) that re-requests a full `pushall` MQTT status report from the printer without forcing a reconnect.
   - **Force Refresh** menu entry in the printer card kebab menu (RotateCw icon) that re-requests a full `pushall` MQTT status report from the printer without forcing a reconnect.
-- **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Snapshots are captured locally and POSTed directly to the ML API as multipart form data, so no callback URL or `external_url` configuration is needed — works behind reverse proxies with external authentication.
+- **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Snapshots are captured locally with a 20 s timeout we control and stashed under a one-shot 32-byte nonce; the ML API fetches them via an unauthenticated `/api/v1/obico/cached-frame/{nonce}` URL that sidesteps Obico's hardcoded 5 s read timeout.
 
 
 ### Improved
 ### Improved
 - **Firmware Update Modal Shows All Announced Versions** ([#568](https://github.com/maziggy/bambuddy/issues/568)) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked **Usable** (green) can be installed, rows marked **Unavailable** (gray) are announced but have no downloadable package yet (common for hot-fix releases like `01.01.03.00` which Bambu only ships as OTA). The currently installed version is highlighted with a blue **Installed** badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. `id="h-01030000-20260303"`) so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request.
 - **Firmware Update Modal Shows All Announced Versions** ([#568](https://github.com/maziggy/bambuddy/issues/568)) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked **Usable** (green) can be installed, rows marked **Unavailable** (gray) are announced but have no downloadable package yet (common for hot-fix releases like `01.01.03.00` which Bambu only ships as OTA). The currently installed version is highlighted with a blue **Installed** badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. `id="h-01030000-20260303"`) so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request.
@@ -30,7 +30,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Print Speed Icon Not Updating Live When Changed on Printer** ([#993](https://github.com/maziggy/bambuddy/issues/993)) — Changing the print speed mode from the printer's own panel (instead of from Bambuddy) did not update the speed icon on the Printers page card; the new value only appeared after a full page reload. The MQTT parser was already tracking `spd_lvl` and updating `state.speed_level` correctly, but the WebSocket serializer (`printer_state_to_dict`) was missing the field — so live status pushes never carried `speed_level`, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST `/status` endpoint used on initial page load already included it, which is why reloads worked. Added `speed_level` to the WebSocket payload. Thanks to @chesterakl for reporting.
 - **Print Speed Icon Not Updating Live When Changed on Printer** ([#993](https://github.com/maziggy/bambuddy/issues/993)) — Changing the print speed mode from the printer's own panel (instead of from Bambuddy) did not update the speed icon on the Printers page card; the new value only appeared after a full page reload. The MQTT parser was already tracking `spd_lvl` and updating `state.speed_level` correctly, but the WebSocket serializer (`printer_state_to_dict`) was missing the field — so live status pushes never carried `speed_level`, and the frontend's merge-over-old-cache update left the icon stuck on its previous value. The REST `/status` endpoint used on initial page load already included it, which is why reloads worked. Added `speed_level` to the WebSocket payload. Thanks to @chesterakl for reporting.
 - **Camera Popup Shows "Valid camera stream token required" With Auth Enabled** ([#979](https://github.com/maziggy/bambuddy/issues/979)) — When Camera View Mode was set to "Window" and authentication was enabled, clicking the camera button opened a popup that immediately failed with `"Valid camera stream token required"`, while the embedded overlay kept working. Two root causes: (1) `window.open(...)` passed `noopener` in the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and the `POST /printers/camera/stream-token` fetch returned 401, leaving the `<img>` src without the required `?token=` query param; (2) even once the token arrived, `CameraPage` computed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in a `useEffect`, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by dropping `noopener` from the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribing `CameraPage` to the `camera-stream-token` React Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the `<img>` src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer.
 - **Camera Popup Shows "Valid camera stream token required" With Auth Enabled** ([#979](https://github.com/maziggy/bambuddy/issues/979)) — When Camera View Mode was set to "Window" and authentication was enabled, clicking the camera button opened a popup that immediately failed with `"Valid camera stream token required"`, while the embedded overlay kept working. Two root causes: (1) `window.open(...)` passed `noopener` in the popup features, which severed the opener link and prevented the browser from copying sessionStorage (where the auth token lives) into the popup — so the new window booted unauthenticated and the `POST /printers/camera/stream-token` fetch returned 401, leaving the `<img>` src without the required `?token=` query param; (2) even once the token arrived, `CameraPage` computed its URL from the module-level stream-token cache on render and never re-rendered when the cache was updated in a `useEffect`, so the first paint locked in a tokenless URL that the backend kept rejecting. Fixed by dropping `noopener` from the camera popup features (same-origin, trusted window) so sessionStorage is inherited, subscribing `CameraPage` to the `camera-stream-token` React Query so it re-renders the moment the token resolves, and appending the token directly from the reactive query value instead of the effect-synced module cache — the `<img>` src stays empty until the token is ready, so no tokenless request ever leaves the popup. Embedded-overlay mode was unaffected. Thanks to @VREmma for the reproducer.
 - **AMS Slot Changes Stop Reaching Printer After Long Idle** ([#887](https://github.com/maziggy/bambuddy/issues/887)) — After printers sat idle for several hours, spool changes published by Bambuddy silently stopped reaching the printer — the UI updated but the printer ignored the command, and only a manual reconnect restored functionality. Root cause: the MQTT connection degraded into a zombie state where the receive path still worked (push_status telemetry kept flowing, so Bambuddy considered the connection alive) but the publish path was dead. The existing zombie detector — the developer mode probe — only ran on first connect when `developer_mode` was unknown; after the initial probe cached the value, subsequent zombie states went undetected because neither the staleness timer nor the keepalive could distinguish a half-open connection from a healthy one. The MQTT client now tracks `ams_filament_setting` command/response pairs: when a published command receives no response within 10 seconds, it's counted as unanswered. After two consecutive unanswered commands, the session is force-reconnected using the same `force_reconnect_stale_session()` mechanism. This catches zombie sessions at the moment the user encounters them — on their second failed spool change — rather than requiring a manual reconnect. Thanks to @RosdasHH for the detailed support bundles that made the diagnosis possible.
 - **AMS Slot Changes Stop Reaching Printer After Long Idle** ([#887](https://github.com/maziggy/bambuddy/issues/887)) — After printers sat idle for several hours, spool changes published by Bambuddy silently stopped reaching the printer — the UI updated but the printer ignored the command, and only a manual reconnect restored functionality. Root cause: the MQTT connection degraded into a zombie state where the receive path still worked (push_status telemetry kept flowing, so Bambuddy considered the connection alive) but the publish path was dead. The existing zombie detector — the developer mode probe — only ran on first connect when `developer_mode` was unknown; after the initial probe cached the value, subsequent zombie states went undetected because neither the staleness timer nor the keepalive could distinguish a half-open connection from a healthy one. The MQTT client now tracks `ams_filament_setting` command/response pairs: when a published command receives no response within 10 seconds, it's counted as unanswered. After two consecutive unanswered commands, the session is force-reconnected using the same `force_reconnect_stale_session()` mechanism. This catches zombie sessions at the moment the user encounters them — on their second failed spool change — rather than requiring a manual reconnect. Thanks to @RosdasHH for the detailed support bundles that made the diagnosis possible.
-- **Obico Detection Fails Behind Reverse Proxy with External Auth** ([#1003](https://github.com/maziggy/bambuddy/issues/1003), [#172](https://github.com/maziggy/bambuddy/issues/172)) — When Bambuddy sits behind a reverse proxy with external authentication (Authelia, Authentik, Cloudflare Access, etc.), the Obico ML API container couldn't fetch snapshots because it had to go through the proxy to reach Bambuddy and couldn't perform the external auth handshake. Previous iterations of #172 solved Bambuddy's own auth (token in URL) and the ML API's 5 s read timeout (nonce-cached frames), but both still relied on the ML API calling back into Bambuddy via `external_url` — which meant external auth layers on the reverse proxy still blocked the request. Eliminated the callback architecture entirely: the detection loop now captures the JPEG locally (with a 20 s timeout we control), then **POSTs the image bytes directly** to the ML API as multipart form data. The ML API never needs to reach Bambuddy at all, so reverse proxies, Docker networking, auth layers, and `external_url` configuration are all irrelevant. Removed the `/obico/cached-frame/{nonce}` endpoint, the in-memory nonce cache, and the `external_url` dependency from the detection service. The "External URL is not set" warning in the Failure Detection UI is also removed since it's no longer needed. Thanks to @felixjen for reporting the reverse-proxy scenario.
+- **Obico Detection ML API Call Fails Silently With Empty Error** ([#172](https://github.com/maziggy/bambuddy/issues/172), [#1003](https://github.com/maziggy/bambuddy/issues/1003)) — The previous attempt at #1003 (0.2.3b4 dev) switched Bambuddy to **POST the JPEG bytes directly** to Obico's ML API as multipart form data, hoping to eliminate the callback-URL dependency for users behind reverse proxies with external auth. That approach cannot work: Obico's `/p/` endpoint is declared `methods=['GET']` upstream and only reads `?img=URL` as a query string (verified against `obico-server/ml_api/server.py`). Flask's router rejected every POST with 405 Method Not Allowed before any handler ran, which is why the Obico container logs showed zero activity while Bambuddy kept reporting `ML API call failed for printer N:` with a blank suffix — `raise_for_status()` on the 405 response produced an exception whose `str()` rendered empty in this path. Reverted to the pre-#1003 nonce-URL approach: the detection loop captures the JPEG locally with a 20 s timeout, stashes it under a 32-byte single-use nonce, and hands Obico a `GET /api/v1/obico/cached-frame/{nonce}` URL that resolves in <50 ms (so Obico's hardcoded 5 s read timeout never races our RTSP keyframe wait). The cached-frame route is un-authenticated at the Bambuddy layer — the unguessable 32-byte nonce with ~30 s TTL IS the credential. The warning log now also falls back to `type(exc).__name__` when `str(exc)` is empty, so future silent exceptions can never produce a blank error again. **For users behind reverse-proxy external auth (Authelia/Authentik/Cloudflare Access)**: the `/api/v1/obico/cached-frame/` path must be whitelisted from external auth — it's already public on Bambuddy's side. Thanks to @fblix for the ml-api-shows-zero-logs clue that pinpointed the 405 root cause.
 - **Obico Detection Snapshot Killed by Stream Cleanup** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — Third wave of #172 — once the cached-frame fix landed, `fblix` reported a permanent "Failed to capture snapshot" warning in the UI. The periodic camera stream cleanup task scans `/proc` for ffmpeg processes with Bambu RTSP URLs and kills any that aren't in the active-streams registry. The Obico detection service's `capture_camera_frame_bytes()` spawns its own short-lived ffmpeg process to grab a single JPEG frame, but that process was never registered with the stream cleanup — so when the 60-second cleanup cycle happened to run during the 5–10 s capture window, it killed the ffmpeg as "orphaned" (exit code -9). The detection service recovered on the next poll, but the kill produced unnecessary error logs and a missed detection frame. Fixed by tracking capture PIDs in a module-level set (`_active_capture_pids`) and excluding them from the `/proc`-scan kill list. Thanks to @fblix for the detailed timing analysis.
 - **Obico Detection Snapshot Killed by Stream Cleanup** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — Third wave of #172 — once the cached-frame fix landed, `fblix` reported a permanent "Failed to capture snapshot" warning in the UI. The periodic camera stream cleanup task scans `/proc` for ffmpeg processes with Bambu RTSP URLs and kills any that aren't in the active-streams registry. The Obico detection service's `capture_camera_frame_bytes()` spawns its own short-lived ffmpeg process to grab a single JPEG frame, but that process was never registered with the stream cleanup — so when the 60-second cleanup cycle happened to run during the 5–10 s capture window, it killed the ffmpeg as "orphaned" (exit code -9). The detection service recovered on the next poll, but the kill produced unnecessary error logs and a missed detection frame. Fixed by tracking capture PIDs in a module-level set (`_active_capture_pids`) and excluding them from the `/proc`-scan kill list. Thanks to @fblix for the detailed timing analysis.
 - **Direct Print from Library Not Attributed to User** — Clicking the Print button on a library file dispatched the job with no `created_by_id`, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the library `POST /files/{file_id}/print` endpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones.
 - **Direct Print from Library Not Attributed to User** — Clicking the Print button on a library file dispatched the job with no `created_by_id`, so the resulting archive had no owner and the print didn't show up in per-user statistics. The Queue and Reprint paths already forwarded the authenticated user; the library `POST /files/{file_id}/print` endpoint now does the same, reading the user from the JWT and passing it through to the dispatcher so direct prints are attributed like queued and reprinted ones.
 - **Add/Edit Printer Modal Clipped on Short Viewports** ([#964](https://github.com/maziggy/bambuddy/issues/964)) — On short or zoomed-in browser windows, the Add Printer and Edit Printer dialogs exceeded the viewport height with no scroll, hiding the lower fields (Access Code, Model, Location) and the Save button. Users had to zoom the browser out to complete the form. The modal overlay now scrolls and the card caps at `calc(100vh - 2rem)` with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting.
 - **Add/Edit Printer Modal Clipped on Short Viewports** ([#964](https://github.com/maziggy/bambuddy/issues/964)) — On short or zoomed-in browser windows, the Add Printer and Edit Printer dialogs exceeded the viewport height with no scroll, hiding the lower fields (Access Code, Model, Location) and the Save button. Users had to zoom the browser out to complete the form. The modal overlay now scrolls and the card caps at `calc(100vh - 2rem)` with internal overflow so every field stays reachable regardless of viewport height. Thanks to @MartinNYHC for reporting.

+ 24 - 2
backend/app/api/routes/obico.py

@@ -2,13 +2,13 @@
 
 
 import logging
 import logging
 
 
-from fastapi import APIRouter
+from fastapi import APIRouter, HTTPException, Response
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.services.obico_detection import obico_detection_service
+from backend.app.services.obico_detection import obico_detection_service, pop_frame
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -33,6 +33,7 @@ async def get_status(
         "sensitivity": settings["sensitivity"],
         "sensitivity": settings["sensitivity"],
         "action": settings["action"],
         "action": settings["action"],
         "poll_interval": settings["poll_interval"],
         "poll_interval": settings["poll_interval"],
+        "external_url_configured": bool(settings["external_url"]),
     }
     }
 
 
 
 
@@ -45,3 +46,24 @@ async def test_connection(
     if not req.url:
     if not req.url:
         return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
         return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
     return await obico_detection_service.test_connection(req.url)
     return await obico_detection_service.test_connection(req.url)
+
+
+@router.get("/cached-frame/{nonce}")
+async def cached_frame(nonce: str):
+    """Serve a pre-captured JPEG to the Obico ML API.
+
+    The detection loop captures a snapshot locally (where we control the timeout),
+    stashes the bytes under a one-shot random nonce, then hands this URL to Obico's
+    ML API. Obico's hardcoded 5s read timeout never races our snapshot pipeline.
+
+    Unauthenticated: the unguessable 32-byte nonce is single-use and expires in
+    seconds, so exposing this path doesn't widen the camera access surface.
+    """
+    data = await pop_frame(nonce)
+    if data is None:
+        raise HTTPException(status_code=404, detail="Frame not found or expired")
+    return Response(
+        content=data,
+        media_type="image/jpeg",
+        headers={"Cache-Control": "no-store"},
+    )

+ 3 - 0
backend/app/main.py

@@ -4276,6 +4276,9 @@ PUBLIC_API_PATTERNS = [
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # download token in the URL path instead.
     # download token in the URL path instead.
     "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
     "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
+    # Obico ML API fetches JPEG frames by one-shot nonce (issue #172 follow-up).
+    # The nonce itself is the credential: 32-byte random, single-use, ~30s TTL.
+    "/obico/cached-frame/",  # /obico/cached-frame/{nonce}
 ]
 ]
 
 
 
 

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

@@ -10,6 +10,8 @@ See `obico_smoothing.py` for the per-print EWM + rolling-mean math.
 import asyncio
 import asyncio
 import json
 import json
 import logging
 import logging
+import secrets
+import time
 from collections import deque
 from collections import deque
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 
 
@@ -32,6 +34,44 @@ HISTORY_MAX = 50
 HEALTH_TIMEOUT = 5.0
 HEALTH_TIMEOUT = 5.0
 DETECTION_TIMEOUT = 30.0
 DETECTION_TIMEOUT = 30.0
 SNAPSHOT_CAPTURE_TIMEOUT = 20  # seconds — we control this, not Obico
 SNAPSHOT_CAPTURE_TIMEOUT = 20  # seconds — we control this, not Obico
+FRAME_CACHE_TTL = 30.0  # seconds — Obico usually fetches within 1s of receiving the URL
+
+# Module-level one-shot frame cache. Obico's ML API is GET-only (/p/?img=URL) and
+# fetches the URL itself with a hardcoded 5s read timeout. We capture locally first,
+# stash the JPEG under a random nonce, and hand Obico a URL that serves the cached
+# bytes instantly — so the 5s ceiling never races RTSP keyframe wait.
+_frame_cache: dict[str, tuple[bytes, float]] = {}
+_frame_cache_lock = asyncio.Lock()
+
+
+def _prune_frame_cache() -> None:
+    """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
+    now = time.monotonic()
+    expired = [k for k, (_b, ts) in _frame_cache.items() if now - ts > FRAME_CACHE_TTL]
+    for k in expired:
+        _frame_cache.pop(k, None)
+
+
+async def stash_frame(data: bytes) -> str:
+    """Store JPEG bytes and return a URL-safe nonce that serves them once."""
+    nonce = secrets.token_urlsafe(32)
+    async with _frame_cache_lock:
+        _prune_frame_cache()
+        _frame_cache[nonce] = (data, time.monotonic())
+    return nonce
+
+
+async def pop_frame(nonce: str) -> bytes | None:
+    """Return and remove a cached frame by nonce; None if missing or expired."""
+    async with _frame_cache_lock:
+        _prune_frame_cache()
+        entry = _frame_cache.pop(nonce, None)
+    if entry is None:
+        return None
+    data, ts = entry
+    if time.monotonic() - ts > FRAME_CACHE_TTL:
+        return None
+    return data
 
 
 
 
 class ObicoDetectionService:
 class ObicoDetectionService:
@@ -75,6 +115,7 @@ class ObicoDetectionService:
             "obico_action",
             "obico_action",
             "obico_poll_interval",
             "obico_poll_interval",
             "obico_enabled_printers",
             "obico_enabled_printers",
+            "external_url",
         ]
         ]
         async with async_session() as db:
         async with async_session() as db:
             result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
             result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
@@ -96,6 +137,7 @@ class ObicoDetectionService:
             "action": rows.get("obico_action", "notify"),
             "action": rows.get("obico_action", "notify"),
             "poll_interval": int(rows.get("obico_poll_interval", "10")),
             "poll_interval": int(rows.get("obico_poll_interval", "10")),
             "enabled_printers": enabled_printers,
             "enabled_printers": enabled_printers,
+            "external_url": (rows.get("external_url") or "").rstrip("/"),
         }
         }
 
 
     # ---- main loop ----
     # ---- main loop ----
@@ -116,7 +158,7 @@ class ObicoDetectionService:
                 break
                 break
             except Exception as e:
             except Exception as e:
                 logger.error("Obico detection loop error: %s", e)
                 logger.error("Obico detection loop error: %s", e)
-                self._last_error = str(e)
+                self._last_error = str(e) or type(e).__name__
                 await asyncio.sleep(30)
                 await asyncio.sleep(30)
 
 
     async def _poll_once(self, settings: dict):
     async def _poll_once(self, settings: dict):
@@ -171,28 +213,37 @@ class ObicoDetectionService:
             self._state_keys[printer_id] = key
             self._state_keys[printer_id] = key
             self._action_fired[printer_id] = False
             self._action_fired[printer_id] = False
 
 
-        # Capture locally, then POST the JPEG bytes directly to the ML API.
-        # This avoids the entire class of URL-reachability problems — the ML API
-        # never needs to call back into Bambuddy, so reverse proxies, external
-        # auth layers, and Docker networking are all irrelevant.
+        # Capture locally first, then hand Obico a nonce URL that returns the
+        # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a
+        # hardcoded 5s read timeout which would otherwise race our /camera/snapshot
+        # keyframe wait.
         frame = await self._capture_frame(printer_id)
         frame = await self._capture_frame(printer_id)
         if not frame:
         if not frame:
             self._last_error = f"Failed to capture snapshot for printer {printer_id}"
             self._last_error = f"Failed to capture snapshot for printer {printer_id}"
             logger.warning(self._last_error)
             logger.warning(self._last_error)
             return
             return
 
 
+        external_url = settings.get("external_url") or ""
+        if not external_url:
+            self._last_error = (
+                "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
+                "Set Settings → General → External URL."
+            )
+            logger.warning(self._last_error)
+            return
+
+        nonce = await stash_frame(frame)
+        snapshot_url = f"{external_url}/api/v1/obico/cached-frame/{nonce}"
         ml_url = f"{settings['ml_url']}/p/"
         ml_url = f"{settings['ml_url']}/p/"
 
 
         try:
         try:
             async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
             async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
-                resp = await client.post(
-                    ml_url,
-                    files={"img": ("snapshot.jpg", frame, "image/jpeg")},
-                )
+                resp = await client.get(ml_url, params={"img": snapshot_url})
                 resp.raise_for_status()
                 resp.raise_for_status()
                 payload = resp.json()
                 payload = resp.json()
         except Exception as e:
         except Exception as e:
-            self._last_error = f"ML API call failed for printer {printer_id}: {e}"
+            detail = str(e) or type(e).__name__
+            self._last_error = f"ML API call failed for printer {printer_id}: {detail}"
             logger.warning(self._last_error)
             logger.warning(self._last_error)
             return
             return
 
 
@@ -234,7 +285,7 @@ class ObicoDetectionService:
         try:
         try:
             await execute_action(printer_id, action, task_name, score)
             await execute_action(printer_id, action, task_name, score)
         except Exception as e:
         except Exception as e:
-            self._last_error = f"Action dispatch failed: {e}"
+            self._last_error = f"Action dispatch failed: {e or type(e).__name__}"
             logger.error(self._last_error)
             logger.error(self._last_error)
 
 
     # ---- queries ----
     # ---- queries ----
@@ -270,7 +321,7 @@ class ObicoDetectionService:
                 "error": None,
                 "error": None,
             }
             }
         except Exception as e:
         except Exception as e:
-            return {"ok": False, "status_code": None, "body": None, "error": str(e)}
+            return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
 
 
 
 
 obico_detection_service = ObicoDetectionService()
 obico_detection_service = ObicoDetectionService()

+ 71 - 0
backend/tests/integration/test_obico_api.py

@@ -0,0 +1,71 @@
+"""Integration tests for Obico API endpoints (#172 follow-up).
+
+Verifies the /obico/cached-frame/{nonce} endpoint used by Obico's ML API to fetch
+pre-captured JPEG frames. This endpoint lets the detection loop sidestep Obico's
+hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.obico_detection import _frame_cache, stash_frame
+
+FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
+
+
+@pytest.fixture(autouse=True)
+def clear_cache():
+    _frame_cache.clear()
+    yield
+    _frame_cache.clear()
+
+
+class TestObicoCachedFrame:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_valid_nonce_returns_jpeg(self, async_client: AsyncClient):
+        """A stashed nonce returns the stored JPEG bytes with image/jpeg."""
+        nonce = await stash_frame(FAKE_JPEG)
+        response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
+        assert response.status_code == 200
+        assert response.headers["content-type"] == "image/jpeg"
+        assert response.content == FAKE_JPEG
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_nonce_is_404(self, async_client: AsyncClient):
+        """An unguessable URL must not leak that the endpoint exists — return 404."""
+        response = await async_client.get("/api/v1/obico/cached-frame/definitely-not-a-real-nonce")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_nonce_is_single_use(self, async_client: AsyncClient):
+        """A second fetch with the same nonce returns 404 — prevents replay."""
+        nonce = await stash_frame(FAKE_JPEG)
+        first = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
+        assert first.status_code == 200
+        second = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
+        assert second.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_endpoint_is_public(self, async_client: AsyncClient):
+        """Obico's ML API can't send auth headers, so the nonce IS the credential.
+        The path must be in PUBLIC_API_PATTERNS (no auth wall)."""
+        nonce = await stash_frame(FAKE_JPEG)
+        # Intentionally omit any auth headers even if the fixture would normally inject them
+        response = await async_client.get(
+            f"/api/v1/obico/cached-frame/{nonce}",
+            headers={},  # no Authorization header
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_response_is_not_cached(self, async_client: AsyncClient):
+        """Browsers/proxies must not hold onto the image after Obico consumes it."""
+        nonce = await stash_frame(FAKE_JPEG)
+        response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
+        assert response.status_code == 200
+        assert "no-store" in response.headers.get("cache-control", "")

+ 154 - 21
backend/tests/unit/test_obico_detection.py

@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 import pytest
 
 
 from backend.app.schemas.settings import AppSettingsUpdate
 from backend.app.schemas.settings import AppSettingsUpdate
-from backend.app.services.obico_detection import ObicoDetectionService
+from backend.app.services.obico_detection import (
+    FRAME_CACHE_TTL,
+    ObicoDetectionService,
+    _frame_cache,
+    pop_frame,
+    stash_frame,
+)
 from backend.app.services.obico_smoothing import WARMUP_FRAMES
 from backend.app.services.obico_smoothing import WARMUP_FRAMES
 
 
 FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
 FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
@@ -133,6 +139,7 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "action": "notify",
             "poll_interval": 10,
             "poll_interval": 10,
             "enabled_printers": None,
             "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
         }
         }
         status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
         status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
 
 
@@ -140,7 +147,7 @@ class TestPollOneStateLifecycle:
         mock_response.json.return_value = {"detections": []}
         mock_response.json.return_value = {"detections": []}
         mock_response.raise_for_status = MagicMock()
         mock_response.raise_for_status = MagicMock()
         mock_client = MagicMock()
         mock_client = MagicMock()
-        mock_client.post = AsyncMock(return_value=mock_response)
+        mock_client.get = AsyncMock(return_value=mock_response)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
 
@@ -165,11 +172,12 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "action": "notify",
             "poll_interval": 10,
             "poll_interval": 10,
             "enabled_printers": None,
             "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
         }
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
-        mock_client.post = AsyncMock(side_effect=RuntimeError("connection refused"))
+        mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
 
@@ -182,6 +190,41 @@ class TestPollOneStateLifecycle:
         assert svc._last_error is not None
         assert svc._last_error is not None
         assert "connection refused" in svc._last_error
         assert "connection refused" in svc._last_error
 
 
+    @pytest.mark.asyncio
+    async def test_ml_api_empty_exception_message_falls_back_to_type(self):
+        """If str(exc) is empty, log the exception class name instead of a blank suffix."""
+        svc = ObicoDetectionService()
+        settings = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        class _SilentError(Exception):
+            def __str__(self) -> str:
+                return ""
+
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(side_effect=_SilentError())
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        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, settings)
+
+        assert svc._last_error is not None
+        assert "_SilentError" in svc._last_error
+        # The suffix is never blank
+        assert not svc._last_error.rstrip().endswith(":")
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_failure_fires_action_only_once(self):
     async def test_failure_fires_action_only_once(self):
         """Once a failure has fired for a print, subsequent failures should not re-fire."""
         """Once a failure has fired for a print, subsequent failures should not re-fire."""
@@ -193,6 +236,7 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "action": "notify",
             "poll_interval": 10,
             "poll_interval": 10,
             "enabled_printers": None,
             "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
         }
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
 
@@ -210,7 +254,7 @@ class TestPollOneStateLifecycle:
         mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
         mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
         mock_response.raise_for_status = MagicMock()
         mock_response.raise_for_status = MagicMock()
         mock_client = MagicMock()
         mock_client = MagicMock()
-        mock_client.post = AsyncMock(return_value=mock_response)
+        mock_client.get = AsyncMock(return_value=mock_response)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
 
@@ -226,11 +270,70 @@ class TestPollOneStateLifecycle:
             assert mock_action.call_count == 1
             assert mock_action.call_count == 1
 
 
 
 
-class TestCheckPrinterPostsImageDirectly:
-    """The detection loop must POST JPEG bytes directly to the ML API."""
+class TestFrameCache:
+    """One-shot JPEG cache that lets us sidestep Obico's 5s read timeout.
+
+    Obico's ML API fetches snapshots via `GET /p/?img=URL` with `timeout=(0.1, 5)`.
+    Our /camera/snapshot can exceed that on cold calls (RTSP keyframe wait). So the
+    detection loop captures locally, stashes the JPEG bytes under a nonce, then hands
+    Obico a URL that returns those bytes instantly. The cache is single-use + TTLed
+    so a leaked nonce can't be replayed.
+    """
+
+    def setup_method(self):
+        _frame_cache.clear()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_ml_api_called_with_post_and_image_bytes(self):
+    async def test_stash_and_pop_roundtrip(self):
+        nonce = await stash_frame(FAKE_JPEG)
+        assert nonce  # non-empty URL-safe token
+        data = await pop_frame(nonce)
+        assert data == FAKE_JPEG
+
+    @pytest.mark.asyncio
+    async def test_nonce_is_single_use(self):
+        nonce = await stash_frame(FAKE_JPEG)
+        assert await pop_frame(nonce) == FAKE_JPEG
+        # Second pop returns None — caches replay protection
+        assert await pop_frame(nonce) is None
+
+    @pytest.mark.asyncio
+    async def test_unknown_nonce_returns_none(self):
+        assert await pop_frame("not-a-real-nonce") is None
+
+    @pytest.mark.asyncio
+    async def test_stash_produces_unique_nonces(self):
+        nonces = {await stash_frame(FAKE_JPEG) for _ in range(10)}
+        assert len(nonces) == 10
+
+    @pytest.mark.asyncio
+    async def test_expired_entries_are_pruned_on_stash(self):
+        """New entries trigger pruning of TTL-expired ones — prevents unbounded growth."""
+        # Manually seed an entry with a stale timestamp
+        import time as time_module
+
+        _frame_cache["stale-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
+        await stash_frame(FAKE_JPEG)
+        # Stale entry was pruned
+        assert "stale-nonce" not in _frame_cache
+
+    @pytest.mark.asyncio
+    async def test_pop_rejects_expired_nonce(self):
+        """Even if the entry is still in the dict, an expired TTL returns None."""
+        import time as time_module
+
+        _frame_cache["aging-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
+        assert await pop_frame("aging-nonce") is None
+
+
+class TestCheckPrinterUsesCachedFrameUrl:
+    """The URL sent to Obico must point at our nonce endpoint, not /camera/snapshot."""
+
+    def setup_method(self):
+        _frame_cache.clear()
+
+    @pytest.mark.asyncio
+    async def test_ml_api_called_with_cached_frame_url(self):
         svc = ObicoDetectionService()
         svc = ObicoDetectionService()
         settings = {
         settings = {
             "enabled": True,
             "enabled": True,
@@ -239,6 +342,7 @@ class TestCheckPrinterPostsImageDirectly:
             "action": "notify",
             "action": "notify",
             "poll_interval": 10,
             "poll_interval": 10,
             "enabled_printers": None,
             "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
         }
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
 
@@ -246,7 +350,7 @@ class TestCheckPrinterPostsImageDirectly:
         mock_response.json.return_value = {"detections": []}
         mock_response.json.return_value = {"detections": []}
         mock_response.raise_for_status = MagicMock()
         mock_response.raise_for_status = MagicMock()
         mock_client = MagicMock()
         mock_client = MagicMock()
-        mock_client.post = AsyncMock(return_value=mock_response)
+        mock_client.get = AsyncMock(return_value=mock_response)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
 
@@ -256,18 +360,16 @@ class TestCheckPrinterPostsImageDirectly:
         ):
         ):
             await svc._check_printer(1, status, settings)
             await svc._check_printer(1, status, settings)
 
 
-        # ML API was called via POST
-        mock_client.post.assert_called_once()
-        _args, kwargs = mock_client.post.call_args
-        # URL is the ML API /p/ endpoint
+        # ML API was called via GET (Obico's /p/ is GET-only)
+        mock_client.get.assert_called_once()
+        _args, kwargs = mock_client.get.call_args
         assert _args[0] == "http://obico:3333/p/"
         assert _args[0] == "http://obico:3333/p/"
-        # Image bytes sent as multipart file upload
-        files = kwargs["files"]
-        assert "img" in files
-        filename, data, content_type = files["img"]
-        assert filename == "snapshot.jpg"
-        assert data == FAKE_JPEG
-        assert content_type == "image/jpeg"
+        img_url = kwargs["params"]["img"]
+        assert img_url.startswith("http://bambuddy:8000/api/v1/obico/cached-frame/")
+        # The path segment after /cached-frame/ is the nonce itself — that nonce must
+        # resolve back to our stashed frame (single-use guarantees freshness).
+        nonce = img_url.rsplit("/", 1)[-1]
+        assert await pop_frame(nonce) == FAKE_JPEG
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_capture_failure_skips_ml_call(self):
     async def test_capture_failure_skips_ml_call(self):
@@ -280,11 +382,12 @@ class TestCheckPrinterPostsImageDirectly:
             "action": "notify",
             "action": "notify",
             "poll_interval": 10,
             "poll_interval": 10,
             "enabled_printers": None,
             "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
         }
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
 
         mock_client = MagicMock()
         mock_client = MagicMock()
-        mock_client.post = AsyncMock()
+        mock_client.get = AsyncMock()
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
 
@@ -294,6 +397,36 @@ class TestCheckPrinterPostsImageDirectly:
         ):
         ):
             await svc._check_printer(1, status, settings)
             await svc._check_printer(1, status, settings)
 
 
-        mock_client.post.assert_not_called()
+        mock_client.get.assert_not_called()
         assert svc._last_error is not None
         assert svc._last_error is not None
         assert "Failed to capture snapshot" in svc._last_error
         assert "Failed to capture snapshot" in svc._last_error
+
+    @pytest.mark.asyncio
+    async def test_missing_external_url_skips_ml_call(self):
+        """Without external_url, Obico can't reach our cached-frame endpoint."""
+        svc = ObicoDetectionService()
+        settings = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "",
+        }
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock()
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        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, settings)
+
+        mock_client.get.assert_not_called()
+        assert svc._last_error is not None
+        assert "external_url" in svc._last_error

+ 1 - 0
frontend/src/api/client.ts

@@ -1868,6 +1868,7 @@ export interface ObicoStatus {
   sensitivity: 'low' | 'medium' | 'high';
   sensitivity: 'low' | 'medium' | 'high';
   action: 'notify' | 'pause' | 'pause_and_off';
   action: 'notify' | 'pause' | 'pause_and_off';
   poll_interval: number;
   poll_interval: number;
+  external_url_configured: boolean;
 }
 }
 
 
 export interface ObicoTestConnection {
 export interface ObicoTestConnection {

+ 10 - 1
frontend/src/components/FailureDetectionSettings.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from 'react';
 import { useState, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, ScanEye, Check, X, Info } from 'lucide-react';
+import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -221,6 +221,15 @@ export function FailureDetectionSettings() {
               <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.pollIntervalHint')}</p>
               <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.pollIntervalHint')}</p>
             </div>
             </div>
 
 
+            {status && !status.external_url_configured && enabled && (
+              <div className="flex items-start gap-2 p-3 bg-amber-900/30 border border-amber-700 rounded text-sm text-amber-200">
+                <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+                <div>
+                  <div className="font-medium">{t('failureDetection.externalUrlMissing')}</div>
+                  <div className="text-xs mt-1">{t('failureDetection.externalUrlHint')}</div>
+                </div>
+              </div>
+            )}
           </CardContent>
           </CardContent>
         </Card>
         </Card>
 
 

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

@@ -5054,6 +5054,8 @@ export default {
     actionPauseOff: 'Pausieren und Strom abschalten',
     actionPauseOff: 'Pausieren und Strom abschalten',
     pollInterval: 'Prüfintervall (Sekunden)',
     pollInterval: 'Prüfintervall (Sekunden)',
     pollIntervalHint: 'Wie oft jeder Drucker während eines laufenden Drucks geprüft wird. Minimum 5 s, Maximum 120 s.',
     pollIntervalHint: 'Wie oft jeder Drucker während eines laufenden Drucks geprüft wird. Minimum 5 s, Maximum 120 s.',
+    externalUrlMissing: 'Externe URL ist nicht gesetzt.',
+    externalUrlHint: 'Die ML-API ruft das Kamera-Snapshot per URL ab. Setze die externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.',
     perPrinterTitle: 'Überwachte Drucker',
     perPrinterTitle: 'Überwachte Drucker',
     perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.',
     perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.',
     monitorAll: 'Alle verbundenen Drucker überwachen',
     monitorAll: 'Alle verbundenen Drucker überwachen',

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

@@ -5062,6 +5062,8 @@ export default {
     actionPauseOff: 'Pause and cut power',
     actionPauseOff: 'Pause and cut power',
     pollInterval: 'Poll interval (seconds)',
     pollInterval: 'Poll interval (seconds)',
     pollIntervalHint: 'How often to check each printer while it is printing. Minimum 5s, maximum 120s.',
     pollIntervalHint: 'How often to check each printer while it is printing. Minimum 5s, maximum 120s.',
+    externalUrlMissing: 'External URL is not set.',
+    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
     perPrinterTitle: 'Monitored Printers',
     perPrinterTitle: 'Monitored Printers',
     perPrinterHint: 'Choose which printers the detection service watches.',
     perPrinterHint: 'Choose which printers the detection service watches.',
     monitorAll: 'Monitor all connected printers',
     monitorAll: 'Monitor all connected printers',

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

@@ -4968,6 +4968,8 @@ export default {
     actionPauseOff: 'Pause et couper l\'alimentation',
     actionPauseOff: 'Pause et couper l\'alimentation',
     pollInterval: 'Intervalle de vérification (secondes)',
     pollInterval: 'Intervalle de vérification (secondes)',
     pollIntervalHint: 'Fréquence de vérification de chaque imprimante pendant l\'impression. Minimum 5s, maximum 120s.',
     pollIntervalHint: 'Fréquence de vérification de chaque imprimante pendant l\'impression. Minimum 5s, maximum 120s.',
+    externalUrlMissing: 'External URL is not set.',
+    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
     perPrinterTitle: 'Imprimantes surveillées',
     perPrinterTitle: 'Imprimantes surveillées',
     perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.',
     perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.',
     monitorAll: 'Surveiller toutes les imprimantes connectées',
     monitorAll: 'Surveiller toutes les imprimantes connectées',

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

@@ -4967,6 +4967,8 @@ export default {
     actionPauseOff: 'Pausa e stacca corrente',
     actionPauseOff: 'Pausa e stacca corrente',
     pollInterval: 'Intervallo di controllo (secondi)',
     pollInterval: 'Intervallo di controllo (secondi)',
     pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',
     pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',
+    externalUrlMissing: 'External URL is not set.',
+    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
     perPrinterTitle: 'Stampanti monitorate',
     perPrinterTitle: 'Stampanti monitorate',
     perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',
     perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',
     monitorAll: 'Monitora tutte le stampanti connesse',
     monitorAll: 'Monitora tutte le stampanti connesse',

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

@@ -5006,6 +5006,8 @@ export default {
     actionPauseOff: '一時停止して電源を切る',
     actionPauseOff: '一時停止して電源を切る',
     pollInterval: 'ポーリング間隔(秒)',
     pollInterval: 'ポーリング間隔(秒)',
     pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',
     pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',
+    externalUrlMissing: 'External URL is not set.',
+    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
     perPrinterTitle: '監視対象プリンター',
     perPrinterTitle: '監視対象プリンター',
     perPrinterHint: '検出サービスが監視するプリンターを選択します。',
     perPrinterHint: '検出サービスが監視するプリンターを選択します。',
     monitorAll: '接続されているすべてのプリンターを監視',
     monitorAll: '接続されているすべてのプリンターを監視',

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

@@ -4981,6 +4981,8 @@ export default {
     actionPauseOff: 'Pausar e cortar energia',
     actionPauseOff: 'Pausar e cortar energia',
     pollInterval: 'Intervalo de verificação (segundos)',
     pollInterval: 'Intervalo de verificação (segundos)',
     pollIntervalHint: 'Frequência de verificação de cada impressora durante a impressão. Mínimo 5s, máximo 120s.',
     pollIntervalHint: 'Frequência de verificação de cada impressora durante a impressão. Mínimo 5s, máximo 120s.',
+    externalUrlMissing: 'External URL is not set.',
+    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
     perPrinterTitle: 'Impressoras monitoradas',
     perPrinterTitle: 'Impressoras monitoradas',
     perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.',
     perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.',
     monitorAll: 'Monitorar todas as impressoras conectadas',
     monitorAll: 'Monitorar todas as impressoras conectadas',

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

@@ -4966,6 +4966,8 @@ export default {
     actionPauseOff: '暂停并切断电源',
     actionPauseOff: '暂停并切断电源',
     pollInterval: '检查间隔(秒)',
     pollInterval: '检查间隔(秒)',
     pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',
     pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',
+    externalUrlMissing: 'External URL is not set.',
+    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
     perPrinterTitle: '监控的打印机',
     perPrinterTitle: '监控的打印机',
     perPrinterHint: '选择检测服务要监视哪些打印机。',
     perPrinterHint: '选择检测服务要监视哪些打印机。',
     monitorAll: '监控所有已连接的打印机',
     monitorAll: '监控所有已连接的打印机',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-3s5orqQ4.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CE6WIfZ7.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-pzZiBCy1.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B_mkjHUo.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CE6WIfZ7.css">
+    <script type="module" crossorigin src="/assets/index-pzZiBCy1.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff