Jelajahi Sumber

Root cause: the Obico detection service handed the ML API a bare /camera/snapshot URL, and Bambuddy's auth returned 401 on that unauthenticated GET. The ML API then
surfaced it as "Failed to get image", which Bambuddy reported back as a 400.

Fix: the snapshot endpoint already accepts a reusable camera-stream token (the same mechanism used by <img>-based camera consumers, since browsers can't send auth headers
on image loads). The detection service now appends that token to the URL it gives the ML API. The token is cached on the service, refreshed 5 min before its 60-min expiry,
and is simply ignored when Bambuddy auth is disabled — so no behavior change for users without auth.

Refs #172

maziggy 1 bulan lalu
induk
melakukan
0198226db1

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Plate-Clear Confirmation Disabled by Default** — New installs ship with Settings → Workflow → "Require Plate-Clear Confirmation" off. Multiple new users reported queued prints appearing to not start because the prompt was waiting for acknowledgement; opt in from Workflow if you want the confirmation gate.
 
 ### Fixed
+- **Obico ML API Got 401 When Fetching Snapshot with Auth Enabled** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — The Obico failure-detection service handed the ML API container a snapshot URL (`/api/v1/printers/{id}/camera/snapshot`) for it to `GET` directly, but when Bambuddy authentication was enabled the endpoint returned 401 and the ML API surfaced "Failed to get image" (visible as a 400 from the ML API back to Bambuddy). The detection service now appends a short-lived camera-stream token to the snapshot URL — the same token scheme already used by `<img>`-based camera consumers, which the snapshot endpoint already accepts. The token is cached on the service and refreshed before its 60-minute expiry, so no extra per-call DB churn. When auth is disabled the token is simply ignored. Thanks to @fblix for reporting.
 - **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.
 - **AMS Drying Silently Does Nothing** ([#971](https://github.com/maziggy/bambuddy/issues/971)) — Clicking Start Drying on a supported printer (e.g. P1S with AMS 2 Pro) could publish the MQTT command successfully but leave the AMS idle with no UI feedback. Two issues: (1) the firmware rejects the command when `dry_sf_reason` reports a blocking state (most commonly code 8 — AMS 2 Pro external power adapter not plugged in — but also "AMS busy", "already drying", etc.), and Bambuddy parsed that array but never surfaced it to the user; (2) the payload sent `filament: ""`, which some firmwares treat as an invalid-field refusal. The `/drying/start` endpoint now inspects the live `dry_sf_reason` for the target AMS unit and returns a descriptive 409 (e.g. "Plug in the external AMS power adapter to start drying") instead of silently publishing, and backfills an empty `filament` from the first loaded tray's type (defaulting to `PLA`) so the printer never rejects the command for a missing field. Thanks to @MartinNYHC for reporting.

+ 34 - 2
backend/app/services/obico_detection.py

@@ -11,11 +11,16 @@ import asyncio
 import json
 import logging
 from collections import deque
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urlencode
 
 import httpx
 from sqlalchemy import select
 
+from backend.app.core.auth import (
+    CAMERA_STREAM_TOKEN_EXPIRE_MINUTES,
+    create_camera_stream_token,
+)
 from backend.app.core.database import async_session
 from backend.app.models.settings import Settings
 from backend.app.services.obico_smoothing import (
@@ -48,6 +53,10 @@ class ObicoDetectionService:
         # Global detection event log (most-recent-first)
         self._history: deque = deque(maxlen=HISTORY_MAX)
         self._last_error: str | None = None
+        # Cached camera-stream token so the ML API can fetch snapshots when
+        # auth is enabled. Refreshed before expiry; harmless when auth is off.
+        self._snapshot_token: str | None = None
+        self._snapshot_token_expires_at: datetime | None = None
 
     # ---- lifecycle ----
 
@@ -63,6 +72,26 @@ class ObicoDetectionService:
             self._task = None
             logger.info("Stopped Obico detection service")
 
+    # ---- snapshot auth ----
+
+    async def _get_snapshot_token(self) -> str:
+        """Return a valid camera-stream token, refreshing it before expiry.
+
+        The ML API fetches the snapshot URL directly, so when Bambuddy's auth
+        is enabled the URL must carry a token (same scheme used by <img>-based
+        camera consumers). When auth is disabled the token is simply ignored.
+        """
+        now = datetime.now(timezone.utc)
+        refresh_before = timedelta(minutes=5)
+        if (
+            self._snapshot_token is None
+            or self._snapshot_token_expires_at is None
+            or self._snapshot_token_expires_at - now <= refresh_before
+        ):
+            self._snapshot_token = await create_camera_stream_token()
+            self._snapshot_token_expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
+        return self._snapshot_token
+
     # ---- settings ----
 
     async def _load_settings(self) -> dict:
@@ -151,7 +180,10 @@ class ObicoDetectionService:
             self._state_keys[printer_id] = key
             self._action_fired[printer_id] = False
 
-        snapshot_url = f"{settings['external_url']}/api/v1/printers/{printer_id}/camera/snapshot"
+        token = await self._get_snapshot_token()
+        snapshot_url = (
+            f"{settings['external_url']}/api/v1/printers/{printer_id}/camera/snapshot?{urlencode({'token': token})}"
+        )
         ml_url = f"{settings['ml_url']}/p/"
 
         try:

+ 18 - 2
backend/tests/unit/test_obico_detection.py

@@ -143,7 +143,13 @@ class TestPollOneStateLifecycle:
         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):
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch(
+                "backend.app.services.obico_detection.create_camera_stream_token",
+                new=AsyncMock(return_value="tok"),
+            ),
+        ):
             await svc._check_printer(1, status, settings)
 
         # State was reset (frame_count is 1 after the single update, not 36)
@@ -170,7 +176,13 @@ class TestPollOneStateLifecycle:
         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):
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch(
+                "backend.app.services.obico_detection.create_camera_stream_token",
+                new=AsyncMock(return_value="tok"),
+            ),
+        ):
             await svc._check_printer(1, status, settings)
 
         assert svc._last_error is not None
@@ -212,6 +224,10 @@ class TestPollOneStateLifecycle:
         with (
             patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
             patch("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action,
+            patch(
+                "backend.app.services.obico_detection.create_camera_stream_token",
+                new=AsyncMock(return_value="tok"),
+            ),
         ):
             await svc._check_printer(1, status, settings)
             assert mock_action.call_count == 1