Parcourir la source

Merge pull request #555 from maziggy/0.2.1.1

v0.2.1.1

Just one day ago after the last release, but I decided to make life a little better for those of you guys who are just received their new H2C :)

What happened?

Recent H2C deliveries from Bambu Lab ship with an updated internal model identifier: O1C2 instead of the previously known O1C. Bambuddy only recognized O1C, so these newer H2C units were not properly identified.
  
 Also included some fixes.
 
- Fix sidebar navigation not respecting user permissions
- Fix camera button permissions & ffmpeg process leak (#550)
- Fix A1 Mini "unknown" status from non-UTF-8 MQTT payload (#549)
- Fix Windows install syntax error from LF line endings (#544)
- Fix queue badge showing on printers without matching filament (#486)
- Fix naive-vs-aware datetime crash from 0.2.1 timezone migration
MartinNYHC il y a 2 mois
Parent
commit
f56882642f

+ 3 - 0
.gitattributes

@@ -0,0 +1,3 @@
+# Force CRLF line endings for Windows batch files so cmd.exe can parse them
+# regardless of the user's core.autocrlf setting.
+*.bat text eol=crlf

+ 12 - 0
CHANGELOG.md

@@ -2,6 +2,18 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.2.1.1] - 2026-02-28
+
+
+### Fixed
+- **H2C Dual Nozzle Variant (O1C2) Not Recognized** ([#489](https://github.com/maziggy/bambuddy/issues/489)) — The H2C dual nozzle variant reports model code `O1C2` via MQTT, but only `O1C` was in the recognized model maps. This caused the camera to use the wrong protocol (chamber image on port 6000 instead of RTSP on port 322) — the printer immediately closed the connection, producing a reconnect loop. Also affected model display names, chamber temperature support detection, linear rail classification, and virtual printer model mapping. Added `O1C2` to all model ID maps across backend and frontend.
+- **Sidebar Navigation Ignores User Permissions** — All sidebar navigation items (Archives, Queue, Stats, Profiles, Maintenance, Projects, Inventory, Files) were visible to every user regardless of their role's permissions. Only the Settings item was permission-gated. Now each nav item is hidden when the user lacks the corresponding read permission (e.g., `archives:read`, `queue:read`, `library:read`). The Printers item remains always visible as the home page. Also added the missing `inventory:read|create|update|delete` permissions to the frontend Permission type (they existed in the backend but were absent from the frontend type definition).
+- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
+- **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.
+- **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
+- **A1 Mini Shows "Unknown" Status After MQTT Payload Decode Failure** ([#549](https://github.com/maziggy/bambuddy/issues/549)) — Some printer firmware versions (observed on A1 Mini 01.07.02.00) occasionally send MQTT payloads containing non-UTF-8 bytes. The `_on_message` handler called `msg.payload.decode()` (strict UTF-8), and the resulting `UnicodeDecodeError` was not caught — only `json.JSONDecodeError` was handled. The entire message was silently dropped, causing printer status to show "unknown", temperatures to read 0°C, and AMS data to disappear. Now catches `UnicodeDecodeError` and falls back to `decode(errors="replace")`, which substitutes invalid bytes with U+FFFD while keeping the JSON structure intact. Logs a warning for diagnostics.
+
+
 ## [0.2.1] - 2026-02-27
 
 ### Fixed

+ 164 - 1
backend/app/api/routes/camera.py

@@ -45,6 +45,10 @@ _stream_start_times: dict[int, float] = {}
 # Track active external camera streams by printer ID
 _active_external_streams: set[int] = set()
 
+# Track ALL spawned ffmpeg PIDs (persists even if _active_streams entries are removed)
+# Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
+_spawned_ffmpeg_pids: dict[int, float] = {}
+
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
@@ -228,6 +232,9 @@ async def generate_rtsp_mjpeg_stream(
         # Track active process for cleanup
         if stream_id:
             _active_streams[stream_id] = process
+        import time as _time
+
+        _spawned_ffmpeg_pids[process.pid] = _time.time()
 
         # Give ffmpeg a moment to start and check for immediate failures
         await asyncio.sleep(0.5)
@@ -344,6 +351,9 @@ async def generate_rtsp_mjpeg_stream(
             except OSError as e:
                 logger.warning("Error terminating ffmpeg: %s", e)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
+        # Remove from PID tracking now that process is confirmed dead
+        if process:
+            _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
 @router.get("/{printer_id}/camera/stream")
@@ -444,6 +454,43 @@ async def camera_stream(
 
     _stream_start_times[printer_id] = time.time()
 
+    async def _kill_stream_process(sid: str):
+        """Terminate+kill the ffmpeg process for a stream ID."""
+        proc = _active_streams.get(sid)
+        if proc and proc.returncode is None:
+            try:
+                proc.terminate()
+                try:
+                    await asyncio.wait_for(proc.wait(), timeout=2.0)
+                except TimeoutError:
+                    proc.kill()
+                    await proc.wait()
+            except (ProcessLookupError, OSError):
+                pass
+
+    async def _monitor_disconnect():
+        """Background task: poll for client disconnect independently of frame loop."""
+        try:
+            while not disconnect_event.is_set():
+                await asyncio.sleep(2)
+                if await request.is_disconnected():
+                    logger.info("Disconnect monitor: client gone (stream %s)", stream_id)
+                    disconnect_event.set()
+                    # Kill ffmpeg process (RTSP streams)
+                    await _kill_stream_process(stream_id)
+                    # Close chamber stream connection if applicable
+                    chamber = _active_chamber_streams.get(stream_id)
+                    if chamber:
+                        try:
+                            chamber[1].close()
+                        except OSError:
+                            pass
+                    break
+        except asyncio.CancelledError:
+            pass
+
+    monitor_task = asyncio.create_task(_monitor_disconnect())
+
     async def stream_with_disconnect_check():
         """Wrapper generator that monitors for client disconnect."""
         try:
@@ -457,7 +504,7 @@ async def camera_stream(
                 printer_id=printer_id,
             ):
                 # Check if client is still connected
-                if await request.is_disconnected():
+                if disconnect_event.is_set() or await request.is_disconnected():
                     logger.info("Client disconnected detected for stream %s", stream_id)
                     disconnect_event.set()
                     break
@@ -470,6 +517,7 @@ async def camera_stream(
             disconnect_event.set()
         finally:
             disconnect_event.set()
+            monitor_task.cancel()
             # Give a moment for the inner generator to clean up
             await asyncio.sleep(0.1)
 
@@ -504,10 +552,19 @@ async def stop_camera_stream(
             if process.returncode is None:
                 try:
                     process.terminate()
+                    try:
+                        await asyncio.wait_for(process.wait(), timeout=2.0)
+                    except TimeoutError:
+                        logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
+                        process.kill()
+                        await process.wait()
                     stopped += 1
                     logger.info("Terminated ffmpeg process for stream %s", stream_id)
+                except ProcessLookupError:
+                    pass  # Process already dead
                 except OSError as e:
                     logger.warning("Error stopping stream %s: %s", stream_id, e)
+            _spawned_ffmpeg_pids.pop(process.pid, None)
 
     for stream_id in to_remove:
         _active_streams.pop(stream_id, None)
@@ -1086,3 +1143,109 @@ async def delete_reference(
         raise HTTPException(404, "Reference not found")
 
     return {"success": True, "message": "Reference deleted"}
+
+
+def _scan_bambu_ffmpeg_pids() -> list[int]:
+    """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
+
+    These are definitely ours — no other software connects to rtsps://bblp:.
+    This catches orphans that survive app restarts and are not in any tracking dict.
+    """
+    import os
+
+    pids = []
+    try:
+        for entry in os.listdir("/proc"):
+            if not entry.isdigit():
+                continue
+            try:
+                with open(f"/proc/{entry}/cmdline", "rb") as f:
+                    cmdline = f.read()
+                if b"ffmpeg" in cmdline and b"rtsps://bblp:" in cmdline:
+                    pids.append(int(entry))
+            except (OSError, PermissionError, ValueError):
+                continue
+    except OSError:
+        pass
+    return pids
+
+
+async def cleanup_orphaned_streams():
+    """Clean up orphaned ffmpeg processes and stale stream entries.
+
+    Called periodically from the background task loop in main.py.
+
+    Three-layer cleanup:
+    1. /proc scan — finds ALL Bambu ffmpeg processes on the system, even those
+       from previous app sessions. This is the nuclear safety net.
+    2. _spawned_ffmpeg_pids — tracks PIDs spawned this session, catches orphans
+       that were removed from _active_streams but not killed.
+    3. _active_streams — kills stale entries with no recent frames.
+    """
+    import os
+    import signal
+    import time
+
+    cleaned = 0
+    now = time.time()
+
+    # Collect PIDs that are legitimately in-use (active stream, process alive)
+    active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
+
+    # 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.
+    #    Any ffmpeg with rtsps://bblp: that is NOT in an active stream is orphaned.
+    for pid in _scan_bambu_ffmpeg_pids():
+        if pid in active_pids:
+            continue
+        logger.info("Killing orphaned ffmpeg process found via /proc (pid=%d)", pid)
+        try:
+            os.kill(pid, signal.SIGKILL)
+        except (ProcessLookupError, OSError):
+            pass
+        _spawned_ffmpeg_pids.pop(pid, None)
+        cleaned += 1
+
+    # 2. Clean up _spawned_ffmpeg_pids entries for dead processes
+    for pid in list(_spawned_ffmpeg_pids):
+        try:
+            os.kill(pid, 0)  # existence check
+        except (ProcessLookupError, OSError):
+            _spawned_ffmpeg_pids.pop(pid, None)
+
+    # 3. Clean up _active_streams entries with dead processes
+    dead_streams = [sid for sid, proc in _active_streams.items() if proc.returncode is not None]
+    for sid in dead_streams:
+        proc = _active_streams.pop(sid, None)
+        if proc:
+            _spawned_ffmpeg_pids.pop(proc.pid, None)
+        cleaned += 1
+
+    # 4. Kill stale active streams (alive but no frames for >60s)
+    for sid, proc in list(_active_streams.items()):
+        if proc.returncode is not None:
+            continue
+        try:
+            printer_id = int(sid.split("-", 1)[0])
+        except (ValueError, IndexError):
+            continue
+        start_time = _stream_start_times.get(printer_id, now)
+        last_frame = _last_frame_times.get(printer_id, start_time)
+        if now - start_time > 120 and now - last_frame > 60:
+            logger.info("Killing stale ffmpeg stream %s (no frames for %.0fs)", sid, now - last_frame)
+            try:
+                proc.kill()
+                await proc.wait()
+            except (ProcessLookupError, OSError):
+                pass
+            _active_streams.pop(sid, None)
+            _spawned_ffmpeg_pids.pop(proc.pid, None)
+            cleaned += 1
+
+    # 4. Clean stale chamber stream entries
+    dead_chamber = [sid for sid, (_reader, writer) in _active_chamber_streams.items() if writer.is_closing()]
+    for sid in dead_chamber:
+        _active_chamber_streams.pop(sid, None)
+        cleaned += 1
+
+    if cleaned:
+        logger.info("Cleaned up %d orphaned camera stream(s)", cleaned)

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.1"
+APP_VERSION = "0.2.1.1"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

+ 38 - 0
backend/app/main.py

@@ -3217,6 +3217,40 @@ def stop_spoolbuddy_watchdog():
         logging.getLogger(__name__).info("SpoolBuddy watchdog stopped")
 
 
+# Camera stream orphan cleanup
+_camera_cleanup_task: asyncio.Task | None = None
+CAMERA_CLEANUP_INTERVAL = 60
+
+
+async def _camera_cleanup_loop():
+    """Periodically clean up orphaned ffmpeg processes."""
+    from backend.app.api.routes.camera import cleanup_orphaned_streams
+
+    while True:
+        try:
+            await cleanup_orphaned_streams()
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logging.getLogger(__name__).warning("Camera stream cleanup failed: %s", e)
+        await asyncio.sleep(CAMERA_CLEANUP_INTERVAL)
+
+
+def start_camera_cleanup():
+    global _camera_cleanup_task
+    if _camera_cleanup_task is None:
+        _camera_cleanup_task = asyncio.create_task(_camera_cleanup_loop())
+        logging.getLogger(__name__).info("Camera stream cleanup started")
+
+
+def stop_camera_cleanup():
+    global _camera_cleanup_task
+    if _camera_cleanup_task:
+        _camera_cleanup_task.cancel()
+        _camera_cleanup_task = None
+        logging.getLogger(__name__).info("Camera stream cleanup stopped")
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -3326,6 +3360,9 @@ async def lifespan(app: FastAPI):
     # Start SpoolBuddy device watchdog
     start_spoolbuddy_watchdog()
 
+    # Start camera stream orphan cleanup
+    start_camera_cleanup()
+
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -3347,6 +3384,7 @@ async def lifespan(app: FastAPI):
     stop_ams_history_recording()
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
+    stop_camera_cleanup()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 

+ 13 - 1
backend/app/services/bambu_mqtt.py

@@ -442,7 +442,19 @@ class BambuMQTTClient:
 
     def _on_message(self, client, userdata, msg):
         try:
-            payload = json.loads(msg.payload.decode())
+            try:
+                raw = msg.payload.decode()
+            except UnicodeDecodeError:
+                # Some firmware versions (e.g. A1 Mini 01.07.02.00) send payloads
+                # with non-UTF-8 bytes. Replace invalid bytes to keep JSON parseable.
+                raw = msg.payload.decode(errors="replace")
+                logger.warning(
+                    "[%s] MQTT payload contained non-UTF-8 bytes (topic=%s, len=%d)",
+                    self.serial_number,
+                    msg.topic,
+                    len(msg.payload),
+                )
+            payload = json.loads(raw)
             # Track last message time - receiving a message proves we're connected
             self._last_message_time = time.time()
             self.state.connected = True

+ 2 - 2
backend/app/services/camera.py

@@ -72,7 +72,7 @@ def supports_rtsp(model: str | None) -> bool:
       - BL-P001: X1/X1C
       - C13: X1E
       - O1D: H2D
-      - O1C: H2C
+      - O1C, O1C2: H2C
       - O1S: H2S
       - O1E, O2D: H2D Pro
       - N7: P2S
@@ -83,7 +83,7 @@ def supports_rtsp(model: str | None) -> bool:
         if model_upper.startswith(("X1", "H2", "P2")):
             return True
         # Internal codes for RTSP models
-        if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1S", "O1E", "O2D", "N7"):
+        if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
             return True
     # A1/P1 and unknown models use chamber image protocol
     return False

+ 1 - 0
backend/app/services/printer_manager.py

@@ -31,6 +31,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "C13",  # X1E
         "O1D",  # H2D
         "O1C",  # H2C
+        "O1C2",  # H2C (dual nozzle variant)
         "O1S",  # H2S
         "O1E",  # H2D Pro
         "O2D",  # H2D Pro (alternate code)

+ 2 - 0
backend/app/services/virtual_printer/manager.py

@@ -41,6 +41,7 @@ VIRTUAL_PRINTER_MODELS = {
     # H2 Series
     "O1D": "H2D",  # H2D
     "O1C": "H2C",  # H2C
+    "O1C2": "H2C",  # H2C (dual nozzle variant)
     "O1S": "H2S",  # H2S
 }
 
@@ -68,6 +69,7 @@ MODEL_SERIAL_PREFIXES = {
     # H2 Series
     "O1D": "09400A",  # H2D
     "O1C": "09400A",  # H2C
+    "O1C2": "09400A",  # H2C (dual nozzle variant)
     "O1S": "09400A",  # H2S
 }
 

+ 1 - 0
backend/app/services/virtual_printer/mqtt_server.py

@@ -28,6 +28,7 @@ MODEL_PRODUCT_NAMES = {
     "N1": "A1 mini",
     "O1D": "H2D",
     "O1C": "H2C",
+    "O1C2": "H2C",
     "O1S": "H2S",
 }
 

+ 2 - 0
backend/app/utils/printer_models.py

@@ -44,6 +44,7 @@ PRINTER_MODEL_ID_MAP = {
     "O1E": "H2D Pro",  # Some devices report O1E
     "O2D": "H2D Pro",  # Some devices report O2D
     "O1C": "H2C",
+    "O1C2": "H2C",
     "O1S": "H2S",
 }
 
@@ -88,6 +89,7 @@ LINEAR_RAIL_MODELS = frozenset(
         "O1E",  # H2D Pro
         "O2D",  # H2D Pro (alternate)
         "O1C",  # H2C
+        "O1C2",  # H2C (dual nozzle variant)
         "O1S",  # H2S
     ]
 )

+ 8 - 2
backend/tests/integration/test_camera_api.py

@@ -59,10 +59,12 @@ class TestCameraAPI:
         """Verify stop terminates active streams for the printer."""
         printer = await printer_factory()
 
-        # Mock an active stream
+        # Mock an active stream — wait() must be AsyncMock since it's awaited
         mock_process = MagicMock()
         mock_process.returncode = None
+        mock_process.pid = 99999
         mock_process.terminate = MagicMock()
+        mock_process.wait = AsyncMock()
 
         with patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc123": mock_process}):
             response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
@@ -78,14 +80,18 @@ class TestCameraAPI:
         printer1 = await printer_factory(name="Printer 1")
         printer2 = await printer_factory(name="Printer 2")
 
-        # Mock active streams for both printers
+        # Mock active streams for both printers — wait() must be AsyncMock since it's awaited
         mock_process1 = MagicMock()
         mock_process1.returncode = None
+        mock_process1.pid = 99998
         mock_process1.terminate = MagicMock()
+        mock_process1.wait = AsyncMock()
 
         mock_process2 = MagicMock()
         mock_process2.returncode = None
+        mock_process2.pid = 99997
         mock_process2.terminate = MagicMock()
+        mock_process2.wait = AsyncMock()
 
         active_streams = {
             f"{printer1.id}-abc123": mock_process1,

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

@@ -1998,6 +1998,7 @@ export type Permission =
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
+  | 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete'
   | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
   | 'camera:view'
   | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'

+ 20 - 6
frontend/src/components/Layout.tsx

@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery, useQueries } from '@tanstack/react-query';
-import { api, supportApi, pendingUploadsApi } from '../api/client';
+import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 import { useAuth } from '../contexts/AuthContext';
@@ -216,16 +216,30 @@ export function Layout() {
   const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
 
   // Compute the ordered sidebar: include stored order + any new items
-  // Filter out 'settings' for users with 'user' role
+  // Hide nav items the user doesn't have read permission for
   const orderedSidebarIds = (() => {
     const result: string[] = [];
     const seen = new Set<string>();
 
-    // Determine if settings should be hidden (no settings:read permission)
-    const hideSettings = authEnabled && !hasPermission('settings:read');
+    // Map nav item IDs to the permission required to see them
+    const navPermissions: Record<string, Permission> = {
+      archives: 'archives:read',
+      queue: 'queue:read',
+      stats: 'stats:read',
+      profiles: 'kprofiles:read',
+      maintenance: 'maintenance:read',
+      projects: 'projects:read',
+      inventory: 'inventory:read',
+      files: 'library:read',
+      settings: 'settings:read',
+    };
+
+    const isHidden = (id: string) =>
+      authEnabled && id in navPermissions && !hasPermission(navPermissions[id]);
+
     // Add items in stored order
     for (const id of sidebarOrder) {
-      if (hideSettings && id === 'settings') continue;
+      if (isHidden(id)) continue;
       if (navItemsMap.has(id) || extLinksMap.has(id)) {
         result.push(id);
         seen.add(id);
@@ -234,7 +248,7 @@ export function Layout() {
 
     // Add any new internal nav items not in stored order
     for (const item of defaultNavItems) {
-      if (hideSettings && item.id === 'settings') continue;
+      if (isHidden(item.id)) continue;
       if (!seen.has(item.id)) {
         result.push(item.id);
         seen.add(item.id);

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'Sie haben keine Berechtigung, auf Druckerdateien zuzugreifen',
       noAmsRfid: 'Sie haben keine Berechtigung, AMS-RFID erneut zu lesen',
       noSmartPlugControl: 'Sie haben keine Berechtigung, Smart Plugs zu steuern',
+      noCamera: 'Sie haben keine Berechtigung, Kameras anzuzeigen',
     },
     // Add/Edit modal
     modal: {

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'You do not have permission to access printer files',
       noAmsRfid: 'You do not have permission to re-read AMS RFID',
       noSmartPlugControl: 'You do not have permission to control smart plugs',
+      noCamera: 'You do not have permission to view cameras',
     },
     // Add/Edit modal
     modal: {

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'Pas d\'autorisation pour les fichiers',
       noAmsRfid: 'Pas d\'autorisation pour le RFID',
       noSmartPlugControl: 'Pas d\'autorisation pour les prises',
+      noCamera: 'Pas d\'autorisation pour les caméras',
     },
     // Add/Edit modal
     modal: {

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

@@ -296,6 +296,7 @@ export default {
       noFiles: 'Non hai il permesso di accedere ai file stampante',
       noAmsRfid: 'Non hai il permesso di rileggere AMS RFID',
       noSmartPlugControl: 'Non hai il permesso di controllare smart plug',
+      noCamera: 'Non hai il permesso di visualizzare le telecamere',
     },
     // Add/Edit modal
     modal: {

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

@@ -269,6 +269,7 @@ export default {
       noControl: 'プリンターを制御する権限がありません',
       noAmsRfid: 'AMS RFIDを再読み取りする権限がありません',
       noSmartPlugControl: 'スマートプラグを制御する権限がありません',
+      noCamera: 'カメラを表示する権限がありません',
     },
     modal: {
       selectModel: 'モデルを選択...',

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'Você não tem permissão para acessar arquivos de impressora',
       noAmsRfid: 'Você não tem permissão para reler RFID AMS',
       noSmartPlugControl: 'Você não tem permissão para controlar tomadas inteligentes',
+      noCamera: 'Você não tem permissão para visualizar câmeras',
     },
     // Add/Edit modal
     modal: {

+ 24 - 3
frontend/src/pages/PrintersPage.tsx

@@ -1363,6 +1363,7 @@ function mapModelCode(ssdpModel: string | null): string {
     'O1E': 'H2D Pro',
     'O2D': 'H2D Pro',
     'O1C': 'H2C',
+    'O1C2': 'H2C',
     'O1S': 'H2S',
     // X1 Series
     'BL-P001': 'X1C',
@@ -1672,7 +1673,27 @@ function PrinterCard({
     queryKey: ['queue', printer.id, 'pending'],
     queryFn: () => api.getQueue(printer.id, 'pending'),
   });
-  const queueCount = queueItems?.length || 0;
+  // Filter queue items by filament compatibility (same logic as PrinterQueueWidget)
+  // so the badge only shows on printers that can actually run the queued jobs.
+  const queueCount = useMemo(() => {
+    if (!queueItems?.length) return 0;
+    return queueItems.filter(item => {
+      if (item.required_filament_types?.length && loadedFilamentTypes?.size) {
+        if (!item.required_filament_types.every((t: string) => loadedFilamentTypes.has(t.toUpperCase()))) {
+          return false;
+        }
+      }
+      if (item.filament_overrides?.length && loadedFilaments?.size) {
+        const hasColorMatch = item.filament_overrides.some((o: { type?: string; color?: string }) => {
+          const oType = (o.type || '').toUpperCase();
+          const oColor = (o.color || '').replace('#', '').toLowerCase().slice(0, 6);
+          return loadedFilaments.has(`${oType}:${oColor}`);
+        });
+        if (!hasColorMatch) return false;
+      }
+      return true;
+    }).length;
+  }, [queueItems, loadedFilamentTypes, loadedFilaments]);
 
   // Fetch currently printing queue item to show who started it (Issue #206)
   const { data: printingQueueItems } = useQuery({
@@ -3589,8 +3610,8 @@ function PrinterCard({
                     window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
                   }
                 }}
-                disabled={!status?.connected}
-                title={cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow')}
+                disabled={!status?.connected || !hasPermission('camera:view')}
+                title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : (cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow'))}
               >
                 <Video className="w-4 h-4" />
               </Button>

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-D35pKbMC.js


+ 1 - 1
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff