Browse Source

fix(obico): POST image bytes directly to ML API instead of callback URL (#1003)

  The ML API previously called back into Bambuddy to fetch snapshots,
  which failed behind reverse proxies with external auth (Authelia, etc.).
  Now the detection loop captures the JPEG locally and POSTs it directly
  as multipart form data — no callback URL, no nonce cache, no
  external_url dependency.
maziggy 1 month ago
parent
commit
475e34ebda

File diff suppressed because it is too large
+ 1 - 1
CHANGELOG.md


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

@@ -2,13 +2,13 @@
 
 import logging
 
-from fastapi import APIRouter, HTTPException, Response
+from fastapi import APIRouter
 from pydantic import BaseModel
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
-from backend.app.services.obico_detection import obico_detection_service, pop_frame
+from backend.app.services.obico_detection import obico_detection_service
 
 logger = logging.getLogger(__name__)
 
@@ -33,7 +33,6 @@ async def get_status(
         "sensitivity": settings["sensitivity"],
         "action": settings["action"],
         "poll_interval": settings["poll_interval"],
-        "external_url_configured": bool(settings["external_url"]),
     }
 
 
@@ -46,24 +45,3 @@ async def test_connection(
     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)
-
-
-@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"},
-    )

+ 0 - 3
backend/app/main.py

@@ -4272,9 +4272,6 @@ PUBLIC_API_PATTERNS = [
     # Camera (streams loaded via <img> tag)
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
-    # 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}
     # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # download token in the URL path instead.

+ 9 - 59
backend/app/services/obico_detection.py

@@ -10,8 +10,6 @@ See `obico_smoothing.py` for the per-print EWM + rolling-mean math.
 import asyncio
 import json
 import logging
-import secrets
-import time
 from collections import deque
 from datetime import datetime, timezone
 
@@ -34,44 +32,6 @@ HISTORY_MAX = 50
 HEALTH_TIMEOUT = 5.0
 DETECTION_TIMEOUT = 30.0
 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 has a hardcoded 5s read timeout
-# on the URL it fetches, which our /camera/snapshot endpoint can exceed (RTSP keyframe
-# wait + ffmpeg startup on cold calls). We capture locally first, stash the JPEG under
-# a random nonce, and hand Obico a URL that serves the cached bytes instantly.
-_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:
@@ -115,7 +75,6 @@ class ObicoDetectionService:
             "obico_action",
             "obico_poll_interval",
             "obico_enabled_printers",
-            "external_url",
         ]
         async with async_session() as db:
             result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
@@ -137,7 +96,6 @@ class ObicoDetectionService:
             "action": rows.get("obico_action", "notify"),
             "poll_interval": int(rows.get("obico_poll_interval", "10")),
             "enabled_printers": enabled_printers,
-            "external_url": (rows.get("external_url") or "").rstrip("/"),
         }
 
     # ---- main loop ----
@@ -151,11 +109,6 @@ class ObicoDetectionService:
                 if not settings["enabled"] or not settings["ml_url"]:
                     await asyncio.sleep(interval)
                     continue
-                if not settings["external_url"]:
-                    # Without a reachable base URL, the ML API can't fetch snapshots.
-                    self._last_error = "external_url not set — ML API cannot reach snapshot endpoint"
-                    await asyncio.sleep(interval)
-                    continue
 
                 await self._poll_once(settings)
                 await asyncio.sleep(interval)
@@ -186,11 +139,7 @@ class ObicoDetectionService:
             await self._check_printer(printer_id, status, settings)
 
     async def _capture_frame(self, printer_id: int) -> bytes | None:
-        """Capture one JPEG frame from the printer camera. Returns None on failure.
-
-        Uses a long local timeout because we control it — Obico's ML API never
-        waits on the slow path (it fetches from the nonce-cached URL).
-        """
+        """Capture one JPEG frame from the printer camera. Returns None on failure."""
         # Late import to avoid cycles at module load time
         from backend.app.services.camera import capture_camera_frame_bytes
         from backend.app.services.external_camera import capture_frame as capture_external_frame
@@ -222,23 +171,24 @@ class ObicoDetectionService:
             self._state_keys[printer_id] = key
             self._action_fired[printer_id] = False
 
-        # Capture locally first, then hand Obico a nonce URL that returns the
-        # cached bytes instantly. Obico's ML API has a hardcoded 5s read timeout
-        # which would otherwise race our /camera/snapshot endpoint's keyframe wait.
+        # 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.
         frame = await self._capture_frame(printer_id)
         if not frame:
             self._last_error = f"Failed to capture snapshot for printer {printer_id}"
             logger.warning(self._last_error)
             return
 
-        # secrets.token_urlsafe() already produces a URL-safe path segment.
-        nonce = await stash_frame(frame)
-        snapshot_url = f"{settings['external_url']}/api/v1/obico/cached-frame/{nonce}"
         ml_url = f"{settings['ml_url']}/p/"
 
         try:
             async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
-                resp = await client.get(ml_url, params={"img": snapshot_url})
+                resp = await client.post(
+                    ml_url,
+                    files={"img": ("snapshot.jpg", frame, "image/jpeg")},
+                )
                 resp.raise_for_status()
                 payload = resp.json()
         except Exception as e:

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

@@ -1,71 +0,0 @@
-"""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", "")

+ 22 - 88
backend/tests/unit/test_obico_detection.py

@@ -5,13 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 from backend.app.schemas.settings import AppSettingsUpdate
-from backend.app.services.obico_detection import (
-    FRAME_CACHE_TTL,
-    ObicoDetectionService,
-    _frame_cache,
-    pop_frame,
-    stash_frame,
-)
+from backend.app.services.obico_detection import ObicoDetectionService
 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"
@@ -139,7 +133,6 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
 
@@ -147,7 +140,7 @@ class TestPollOneStateLifecycle:
         mock_response.json.return_value = {"detections": []}
         mock_response.raise_for_status = MagicMock()
         mock_client = MagicMock()
-        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.post = AsyncMock(return_value=mock_response)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
@@ -172,12 +165,11 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
         mock_client = MagicMock()
-        mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
+        mock_client.post = AsyncMock(side_effect=RuntimeError("connection refused"))
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
@@ -201,7 +193,6 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -219,7 +210,7 @@ class TestPollOneStateLifecycle:
         mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
         mock_response.raise_for_status = MagicMock()
         mock_client = MagicMock()
-        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.post = AsyncMock(return_value=mock_response)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
@@ -235,70 +226,11 @@ class TestPollOneStateLifecycle:
             assert mock_action.call_count == 1
 
 
-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
-    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()
+class TestCheckPrinterPostsImageDirectly:
+    """The detection loop must POST JPEG bytes directly to the ML API."""
 
     @pytest.mark.asyncio
-    async def test_ml_api_called_with_cached_frame_url(self):
+    async def test_ml_api_called_with_post_and_image_bytes(self):
         svc = ObicoDetectionService()
         settings = {
             "enabled": True,
@@ -307,7 +239,6 @@ class TestCheckPrinterUsesCachedFrameUrl:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -315,7 +246,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
         mock_response.json.return_value = {"detections": []}
         mock_response.raise_for_status = MagicMock()
         mock_client = MagicMock()
-        mock_client.get = AsyncMock(return_value=mock_response)
+        mock_client.post = AsyncMock(return_value=mock_response)
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
@@ -325,14 +256,18 @@ class TestCheckPrinterUsesCachedFrameUrl:
         ):
             await svc._check_printer(1, status, settings)
 
-        # Extract the img URL handed to the ML API
-        _args, kwargs = mock_client.get.call_args
-        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
+        # 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
+        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"
 
     @pytest.mark.asyncio
     async def test_capture_failure_skips_ml_call(self):
@@ -345,12 +280,11 @@ class TestCheckPrinterUsesCachedFrameUrl:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
         mock_client = MagicMock()
-        mock_client.get = AsyncMock()
+        mock_client.post = AsyncMock()
         mock_client.__aenter__ = AsyncMock(return_value=mock_client)
         mock_client.__aexit__ = AsyncMock(return_value=False)
 
@@ -360,6 +294,6 @@ class TestCheckPrinterUsesCachedFrameUrl:
         ):
             await svc._check_printer(1, status, settings)
 
-        mock_client.get.assert_not_called()
+        mock_client.post.assert_not_called()
         assert svc._last_error is not None
         assert "Failed to capture snapshot" in svc._last_error

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

@@ -40,7 +40,6 @@ const baseStatus = {
   sensitivity: 'medium',
   action: 'notify',
   poll_interval: 10,
-  external_url_configured: true,
 };
 
 describe('FailureDetectionSettings', () => {
@@ -62,19 +61,6 @@ describe('FailureDetectionSettings', () => {
     expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument();
   });
 
-  it('warns when external URL is missing and detection is enabled', async () => {
-    server.use(
-      http.get('/api/v1/settings/', () => HttpResponse.json({ ...baseSettings, obico_enabled: true })),
-      http.get('/api/v1/obico/status', () =>
-        HttpResponse.json({ ...baseStatus, enabled: true, external_url_configured: false }),
-      ),
-    );
-    render(<FailureDetectionSettings />);
-    await waitFor(() => {
-      expect(screen.getByText(/External URL is not set/i)).toBeInTheDocument();
-    });
-  });
-
   it('test button calls the test-connection endpoint and shows success', async () => {
     let called = false;
     server.use(

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

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

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

@@ -1,7 +1,7 @@
 import { useState, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
+import { Loader2, ScanEye, Check, X, Info } from 'lucide-react';
 import { api } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
@@ -221,15 +221,6 @@ export function FailureDetectionSettings() {
               <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.pollIntervalHint')}</p>
             </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>
         </Card>
 

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

@@ -5054,8 +5054,6 @@ export default {
     actionPauseOff: 'Pausieren und Strom abschalten',
     pollInterval: 'Prüfintervall (Sekunden)',
     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 lädt das Kamerabild per URL. Setze die Externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.',
     perPrinterTitle: 'Überwachte Drucker',
     perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.',
     monitorAll: 'Alle verbundenen Drucker überwachen',

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

@@ -5062,8 +5062,6 @@ export default {
     actionPauseOff: 'Pause and cut power',
     pollInterval: 'Poll interval (seconds)',
     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',
     perPrinterHint: 'Choose which printers the detection service watches.',
     monitorAll: 'Monitor all connected printers',

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

@@ -4968,8 +4968,6 @@ export default {
     actionPauseOff: 'Pause et couper l\'alimentation',
     pollInterval: 'Intervalle de vérification (secondes)',
     pollIntervalHint: 'Fréquence de vérification de chaque imprimante pendant l\'impression. Minimum 5s, maximum 120s.',
-    externalUrlMissing: 'L\'URL externe n\'est pas définie.',
-    externalUrlHint: 'L\'API ML récupère l\'image de la caméra via URL. Définissez l\'URL externe dans les paramètres généraux pour que le conteneur ML API puisse atteindre Bambuddy.',
     perPrinterTitle: 'Imprimantes surveillées',
     perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.',
     monitorAll: 'Surveiller toutes les imprimantes connectées',

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

@@ -4967,8 +4967,6 @@ export default {
     actionPauseOff: 'Pausa e stacca corrente',
     pollInterval: 'Intervallo di controllo (secondi)',
     pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',
-    externalUrlMissing: 'URL esterno non impostato.',
-    externalUrlHint: 'L\'API ML recupera l\'immagine della fotocamera tramite URL. Imposta l\'URL esterno nelle impostazioni generali affinché il container ML API possa raggiungere Bambuddy.',
     perPrinterTitle: 'Stampanti monitorate',
     perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',
     monitorAll: 'Monitora tutte le stampanti connesse',

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

@@ -5006,8 +5006,6 @@ export default {
     actionPauseOff: '一時停止して電源を切る',
     pollInterval: 'ポーリング間隔(秒)',
     pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',
-    externalUrlMissing: '外部 URL が設定されていません。',
-    externalUrlHint: 'ML API は URL 経由でカメラ画像を取得します。ML API コンテナが Bambuddy に到達できるよう、一般設定で外部 URL を設定してください。',
     perPrinterTitle: '監視対象プリンター',
     perPrinterHint: '検出サービスが監視するプリンターを選択します。',
     monitorAll: '接続されているすべてのプリンターを監視',

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

@@ -4981,8 +4981,6 @@ export default {
     actionPauseOff: 'Pausar e cortar energia',
     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.',
-    externalUrlMissing: 'URL externa não configurada.',
-    externalUrlHint: 'A API ML busca a imagem da câmera por URL. Defina a URL externa nas configurações gerais para que o contêiner da API ML possa alcançar o Bambuddy.',
     perPrinterTitle: 'Impressoras monitoradas',
     perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.',
     monitorAll: 'Monitorar todas as impressoras conectadas',

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

@@ -4966,8 +4966,6 @@ export default {
     actionPauseOff: '暂停并切断电源',
     pollInterval: '检查间隔(秒)',
     pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',
-    externalUrlMissing: '未设置外部 URL。',
-    externalUrlHint: 'ML API 通过 URL 获取相机截图。请在常规设置中设置外部 URL,以便 ML API 容器可以访问 Bambuddy。',
     perPrinterTitle: '监控的打印机',
     perPrinterHint: '选择检测服务要监视哪些打印机。',
     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-B_mkjHUo.js


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


+ 2 - 2
static/index.html

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

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