diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..29b0ed792 --- /dev/null +++ b/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index eb983bd95..53613bdf0 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/backend/app/api/routes/camera.py b/backend/app/api/routes/camera.py index 265bc0942..449add070 100644 --- a/backend/app/api/routes/camera.py +++ b/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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2597b9a42..65e1275eb 100644 --- a/backend/app/core/config.py +++ b/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) diff --git a/backend/app/main.py b/backend/app/main.py index f2938ce4b..60d82156c 100644 --- a/backend/app/main.py +++ b/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() diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 3e44efd2b..b92590202 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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 diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index 7b991ff87..1e5c47448 100644 --- a/backend/app/services/camera.py +++ b/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 diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index fadfcc065..760c7145f 100644 --- a/backend/app/services/printer_manager.py +++ b/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) diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 756ffdbfd..a8920f65b 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/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 } diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py index 74abdf958..8e4e6e195 100644 --- a/backend/app/services/virtual_printer/mqtt_server.py +++ b/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", } diff --git a/backend/app/utils/printer_models.py b/backend/app/utils/printer_models.py index 8fdd61be7..41d33efa8 100644 --- a/backend/app/utils/printer_models.py +++ b/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 ] ) diff --git a/backend/tests/integration/test_camera_api.py b/backend/tests/integration/test_camera_api.py index e963b1ffb..439552121 100644 --- a/backend/tests/integration/test_camera_api.py +++ b/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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5559650b8..2180c65a5 100644 --- a/frontend/src/api/client.ts +++ b/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' diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 82c80bc81..a1792c8e7 100644 --- a/frontend/src/components/Layout.tsx +++ b/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(); - // 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 = { + 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); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 9307e8732..81a8e5f1b 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 09ba17fe3..433442295 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 8af201a5d..3352fe330 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 67f05a0e9..0f4e135c9 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 58ee1f9fd..9960f0352 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -269,6 +269,7 @@ export default { noControl: 'プリンターを制御する権限がありません', noAmsRfid: 'AMS RFIDを再読み取りする権限がありません', noSmartPlugControl: 'スマートプラグを制御する権限がありません', + noCamera: 'カメラを表示する権限がありません', }, modal: { selectModel: 'モデルを選択...', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index e5d949c06..3e30155e9 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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: { diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index d70e3c342..49a41dd00 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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'))} >