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
This commit is contained in:
MartinNYHC
2026-02-28 15:16:46 +01:00
committed by GitHub
23 changed files with 348 additions and 66 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
@@ -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
}
@@ -28,6 +28,7 @@ MODEL_PRODUCT_NAMES = {
"N1": "A1 mini",
"O1D": "H2D",
"O1C": "H2C",
"O1C2": "H2C",
"O1S": "H2S",
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -269,6 +269,7 @@ export default {
noControl: 'プリンターを制御する権限がありません',
noAmsRfid: 'AMS RFIDを再読み取りする権限がありません',
noSmartPlugControl: 'スマートプラグを制御する権限がありません',
noCamera: 'カメラを表示する権限がありません',
},
modal: {
selectModel: 'モデルを選択...',
+1
View File
@@ -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
View File
@@ -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>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>