From e688c357190075ba7ab7ef89c98ceb477d9ca716 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 7 Dec 2025 14:29:54 +0000 Subject: [PATCH] sync --- backend/app/api/routes/camera.py | 32 +- backend/app/api/routes/printers.py | 6 +- backend/app/api/routes/settings.py | 19 +- backend/app/core/database.py | 9 + backend/app/models/printer.py | 1 + backend/app/schemas/printer.py | 3 + backend/app/services/camera.py | 53 +- backend/app/services/printer_manager.py | 71 ++ frontend/src/App.tsx | 4 + frontend/src/api/client.ts | 5 + .../src/components/PrinterQueueWidget.tsx | 23 +- frontend/src/index.css | 21 + frontend/src/pages/CameraPage.tsx | 233 +++++ frontend/src/pages/PrintersPage.tsx | 969 ++++++++++++++---- static/assets/index-BkIG8nsB.css | 1 - .../{index-B5zVKOmX.js => index-DzQp7Kty.js} | 806 +++++++-------- static/assets/index-SGO6jLr2.css | 1 + static/index.html | 4 +- 18 files changed, 1630 insertions(+), 631 deletions(-) create mode 100644 frontend/src/pages/CameraPage.tsx delete mode 100644 static/assets/index-BkIG8nsB.css rename static/assets/{index-B5zVKOmX.js => index-DzQp7Kty.js} (58%) create mode 100644 static/assets/index-SGO6jLr2.css diff --git a/backend/app/api/routes/camera.py b/backend/app/api/routes/camera.py index 797e11690..44a534626 100644 --- a/backend/app/api/routes/camera.py +++ b/backend/app/api/routes/camera.py @@ -15,6 +15,8 @@ from backend.app.services.camera import ( build_camera_url, capture_camera_frame, test_camera_connection, + get_ffmpeg_path, + get_camera_port, ) from backend.app.services.printer_manager import printer_manager @@ -41,20 +43,29 @@ async def generate_mjpeg_stream( This captures frames continuously and yields them in MJPEG format. """ - from backend.app.services.camera import get_camera_port + ffmpeg = get_ffmpeg_path() + if not ffmpeg: + logger.error("ffmpeg not found - camera streaming requires ffmpeg") + yield ( + b"--frame\r\n" + b"Content-Type: text/plain\r\n\r\n" + b"Error: ffmpeg not installed\r\n" + ) + return port = get_camera_port(model) camera_url = f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1" # ffmpeg command to output MJPEG stream to stdout - # -re: Read input at native frame rate # -rtsp_transport tcp: Use TCP for reliability + # -rtsp_flags prefer_tcp: Prefer TCP for RTSP # -f mjpeg: Output as MJPEG # -q:v 5: Quality (lower = better, 2-10 is good range) # -r: Output framerate cmd = [ - "ffmpeg", + ffmpeg, "-rtsp_transport", "tcp", + "-rtsp_flags", "prefer_tcp", "-i", camera_url, "-f", "mjpeg", "-q:v", "5", @@ -63,7 +74,8 @@ async def generate_mjpeg_stream( "-" # Output to stdout ] - logger.info(f"Starting camera stream for {ip_address}") + logger.info(f"Starting camera stream for {ip_address} using URL: rtsps://bblp:***@{ip_address}:{port}/streaming/live/1") + logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)") process = None try: @@ -73,6 +85,18 @@ async def generate_mjpeg_stream( stderr=asyncio.subprocess.PIPE, ) + # Give ffmpeg a moment to start and check for immediate failures + await asyncio.sleep(0.5) + if process.returncode is not None: + stderr = await process.stderr.read() + logger.error(f"ffmpeg failed immediately: {stderr.decode()}") + yield ( + b"--frame\r\n" + b"Content-Type: text/plain\r\n\r\n" + b"Error: Camera connection failed. Check printer is on and camera is enabled.\r\n" + ) + return + # Read JPEG frames from ffmpeg output # JPEG images start with 0xFFD8 and end with 0xFFD9 buffer = b"" diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 8698f1970..54e092b24 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -190,8 +190,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) # humidity_raw is the actual percentage value from the sensor humidity_raw = ams_data.get("humidity_raw") humidity_idx = ams_data.get("humidity") - # Use humidity_raw if available, otherwise fall back to humidity index humidity_value = None + if humidity_raw is not None: try: humidity_value = int(humidity_raw) @@ -202,10 +202,14 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) humidity_value = int(humidity_idx) except (ValueError, TypeError): pass + # AMS-HT has 1 tray, regular AMS has 4 trays + is_ams_ht = len(trays) == 1 + ams_units.append(AMSUnit( id=ams_data.get("id", 0), humidity=humidity_value, temp=ams_data.get("temp"), + is_ams_ht=is_ams_ht, tray=trays, )) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index bf9c2859b..27574585b 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -1,6 +1,3 @@ -import shutil -from pathlib import Path - from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -95,21 +92,9 @@ async def reset_settings(db: AsyncSession = Depends(get_db)): @router.get("/check-ffmpeg") async def check_ffmpeg(): """Check if ffmpeg is installed and available.""" - ffmpeg_path = shutil.which("ffmpeg") + from backend.app.services.camera import get_ffmpeg_path - # If not found via PATH, check common installation locations - # (systemd services often have limited PATH) - if ffmpeg_path is None: - common_paths = [ - "/usr/bin/ffmpeg", - "/usr/local/bin/ffmpeg", - "/opt/homebrew/bin/ffmpeg", - "/snap/bin/ffmpeg", - ] - for path in common_paths: - if Path(path).exists(): - ffmpeg_path = path - break + ffmpeg_path = get_ffmpeg_path() return { "installed": ffmpeg_path is not None, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 756c8586a..a9ddcc977 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -100,3 +100,12 @@ async def run_migrations(conn): except Exception: # Column already exists pass + + # Migration: Add location column to printers for grouping + try: + await conn.execute(text( + "ALTER TABLE printers ADD COLUMN location VARCHAR(100)" + )) + except Exception: + # Column already exists + pass diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index eddd0dc09..ab30794db 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -14,6 +14,7 @@ class Printer(Base): ip_address: Mapped[str] = mapped_column(String(45)) access_code: Mapped[str] = mapped_column(String(20)) model: Mapped[str | None] = mapped_column(String(50)) + location: Mapped[str | None] = mapped_column(String(100)) # Group/location name nozzle_count: Mapped[int] = mapped_column(default=1) # 1 or 2, auto-detected from MQTT is_active: Mapped[bool] = mapped_column(Boolean, default=True) auto_archive: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index fd5923a66..bd29ecf8f 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -8,6 +8,7 @@ class PrinterBase(BaseModel): ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") access_code: str = Field(..., min_length=1, max_length=20) model: str | None = None + location: str | None = None # Group/location name auto_archive: bool = True @@ -20,6 +21,7 @@ class PrinterUpdate(BaseModel): ip_address: str | None = None access_code: str | None = None model: str | None = None + location: str | None = None is_active: bool | None = None auto_archive: bool | None = None print_hours_offset: float | None = None @@ -63,6 +65,7 @@ class AMSUnit(BaseModel): id: int humidity: int | None = None temp: float | None = None + is_ams_ht: bool = False # True for AMS-HT (single spool), False for regular AMS (4 spools) tray: list[AMSTray] = [] diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index 5675d7ed7..d2d454044 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -5,7 +5,7 @@ Captures images from the printer's RTSPS camera stream using ffmpeg. import asyncio import logging -import subprocess +import shutil from pathlib import Path from datetime import datetime import uuid @@ -14,6 +14,46 @@ from backend.app.core.config import settings logger = logging.getLogger(__name__) +# Cache the ffmpeg path after first lookup +_ffmpeg_path: str | None = None + + +def get_ffmpeg_path() -> str | None: + """Find the ffmpeg executable path. + + Uses shutil.which first, then checks common installation locations + for systems where PATH may be limited (e.g., systemd services). + """ + global _ffmpeg_path + + if _ffmpeg_path is not None: + return _ffmpeg_path + + # Try PATH first + ffmpeg_path = shutil.which("ffmpeg") + + # If not found via PATH, check common installation locations + if ffmpeg_path is None: + common_paths = [ + "/usr/bin/ffmpeg", + "/usr/local/bin/ffmpeg", + "/opt/homebrew/bin/ffmpeg", # macOS Homebrew + "/snap/bin/ffmpeg", # Ubuntu Snap + "C:\\ffmpeg\\bin\\ffmpeg.exe", # Windows common + ] + for path in common_paths: + if Path(path).exists(): + ffmpeg_path = path + break + + _ffmpeg_path = ffmpeg_path + if ffmpeg_path: + logger.info(f"Found ffmpeg at: {ffmpeg_path}") + else: + logger.warning("ffmpeg not found in PATH or common locations") + + return ffmpeg_path + def get_camera_port(model: str | None) -> int: """Get the RTSPS port based on printer model. @@ -59,17 +99,26 @@ async def capture_camera_frame( # Ensure output directory exists output_path.parent.mkdir(parents=True, exist_ok=True) + ffmpeg = get_ffmpeg_path() + if not ffmpeg: + logger.error("ffmpeg not found. Please install ffmpeg to enable camera capture.") + return False + # ffmpeg command to capture a single frame from RTSPS stream # -rtsp_transport tcp: Use TCP for RTSP (more reliable) + # -rtsp_flags prefer_tcp: Prefer TCP for RTSP # -y: Overwrite output file # -frames:v 1: Capture only 1 frame + # -update 1: Allow writing single image without sequence pattern # -q:v 2: High quality JPEG (1-31, lower is better) cmd = [ - "ffmpeg", + ffmpeg, "-y", # Overwrite output "-rtsp_transport", "tcp", + "-rtsp_flags", "prefer_tcp", "-i", camera_url, "-frames:v", "1", + "-update", "1", "-q:v", "2", str(output_path), ] diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index be03b603c..d39b9f4bb 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -268,6 +268,74 @@ class PrinterManager: def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict: """Convert PrinterState to a JSON-serializable dict.""" + # Parse AMS data from raw_data + ams_units = [] + vt_tray = None + raw_data = state.raw_data or {} + + if "ams" in raw_data and isinstance(raw_data["ams"], list): + for ams_data in raw_data["ams"]: + trays = [] + for tray in ams_data.get("tray", []): + tag_uid = tray.get("tag_uid") + if tag_uid in ("", "0000000000000000"): + tag_uid = None + tray_uuid = tray.get("tray_uuid") + if tray_uuid in ("", "00000000000000000000000000000000"): + tray_uuid = None + trays.append({ + "id": tray.get("id", 0), + "tray_color": tray.get("tray_color"), + "tray_type": tray.get("tray_type"), + "tray_sub_brands": tray.get("tray_sub_brands"), + "remain": tray.get("remain", 0), + "k": tray.get("k"), + "tag_uid": tag_uid, + "tray_uuid": tray_uuid, + }) + # Prefer humidity_raw (actual percentage) over humidity (index 1-5) + humidity_raw = ams_data.get("humidity_raw") + humidity_idx = ams_data.get("humidity") + humidity_value = None + + if humidity_raw is not None: + try: + humidity_value = int(humidity_raw) + except (ValueError, TypeError): + pass + # Fall back to index if no raw value (index is 1-5, not percentage) + if humidity_value is None and humidity_idx is not None: + try: + humidity_value = int(humidity_idx) + except (ValueError, TypeError): + pass + + # AMS-HT has 1 tray, regular AMS has 4 trays + is_ams_ht = len(trays) == 1 + + ams_units.append({ + "id": ams_data.get("id", 0), + "humidity": humidity_value, + "temp": ams_data.get("temp"), + "is_ams_ht": is_ams_ht, + "tray": trays, + }) + + # Parse virtual tray (external spool) + if "vt_tray" in raw_data: + vt_data = raw_data["vt_tray"] + vt_tag_uid = vt_data.get("tag_uid") + if vt_tag_uid in ("", "0000000000000000"): + vt_tag_uid = None + vt_tray = { + "id": 254, + "tray_color": vt_data.get("tray_color"), + "tray_type": vt_data.get("tray_type"), + "tray_sub_brands": vt_data.get("tray_sub_brands"), + "remain": vt_data.get("remain", 0), + "tag_uid": vt_tag_uid, + } + result = { "connected": state.connected, "state": state.state, @@ -283,6 +351,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity} for e in (state.hms_errors or []) ], + # AMS data for filament colors + "ams": ams_units if ams_units else None, + "vt_tray": vt_tray, # AMS status for filament change tracking "ams_status_main": state.ams_status_main, "ams_status_sub": state.ams_status_sub, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e8f89331..5d7863066 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage'; import { SettingsPage } from './pages/SettingsPage'; import { ProfilesPage } from './pages/ProfilesPage'; import { MaintenancePage } from './pages/MaintenancePage'; +import { CameraPage } from './pages/CameraPage'; import { useWebSocket } from './hooks/useWebSocket'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; @@ -34,6 +35,9 @@ function App() { + {/* Camera page - standalone, no layout */} + } /> + }> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ecac83ee0..8a082e605 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -28,6 +28,7 @@ export interface Printer { ip_address: string; access_code: string; model: string | null; + location: string | null; // Group/location name nozzle_count: number; // 1 or 2, auto-detected from MQTT is_active: boolean; auto_archive: boolean; @@ -61,6 +62,7 @@ export interface AMSUnit { id: number; humidity: number | null; temp: number | null; + is_ams_ht: boolean; // True for AMS-HT (single spool), False for regular AMS (4 spools) tray: AMSTray[]; } @@ -106,6 +108,8 @@ export interface PrinterStatus { bed_target?: number; nozzle?: number; nozzle_target?: number; + nozzle_2?: number; // Second nozzle for H2 series (dual nozzle) + nozzle_2_target?: number; chamber?: number; } | null; cover_url: string | null; @@ -157,6 +161,7 @@ export interface PrinterCreate { ip_address: string; access_code: string; model?: string; + location?: string; auto_archive?: boolean; } diff --git a/frontend/src/components/PrinterQueueWidget.tsx b/frontend/src/components/PrinterQueueWidget.tsx index 707c8b2f1..4862e13d2 100644 --- a/frontend/src/components/PrinterQueueWidget.tsx +++ b/frontend/src/components/PrinterQueueWidget.tsx @@ -35,11 +35,14 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) { } return ( -
-
-
- -
+ +
+
+ +

Next in queue

{nextItem?.archive_name || `Archive #${nextItem?.archive_id}`} @@ -56,15 +59,9 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) { +{totalPending - 1} )} - - - +

-
+ ); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 61c43b649..9d18ffbc4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -122,3 +122,24 @@ body { .dark .jogpad-theme { filter: brightness(0.35) contrast(1.2); } + +/* Empty AMS slot with diagonal stripes */ +.ams-empty-slot { + background: repeating-linear-gradient( + 45deg, + #444, + #444 2px, + #222 2px, + #222 4px + ); +} + +.dark .ams-empty-slot { + background: repeating-linear-gradient( + 45deg, + #555, + #555 2px, + #333 2px, + #333 4px + ); +} diff --git a/frontend/src/pages/CameraPage.tsx b/frontend/src/pages/CameraPage.tsx new file mode 100644 index 000000000..62db3d437 --- /dev/null +++ b/frontend/src/pages/CameraPage.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize } from 'lucide-react'; +import { api } from '../api/client'; + +export function CameraPage() { + const { printerId } = useParams<{ printerId: string }>(); + const id = parseInt(printerId || '0', 10); + + const [streamMode, setStreamMode] = useState<'stream' | 'snapshot'>('stream'); + const [streamError, setStreamError] = useState(false); + const [streamLoading, setStreamLoading] = useState(true); + const [imageKey, setImageKey] = useState(Date.now()); + const [transitioning, setTransitioning] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const imgRef = useRef(null); + const containerRef = useRef(null); + + // Fetch printer info for the title + const { data: printer } = useQuery({ + queryKey: ['printer', id], + queryFn: () => api.getPrinter(id), + enabled: id > 0, + }); + + // Update document title + useEffect(() => { + if (printer) { + document.title = `${printer.name} - Camera`; + } + return () => { + document.title = 'Bambusy'; + }; + }, [printer]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (imgRef.current) { + imgRef.current.src = ''; + } + }; + }, []); + + // Auto-hide loading after timeout + useEffect(() => { + if (streamLoading && !transitioning) { + const timeout = streamMode === 'stream' ? 3000 : 20000; + const timer = setTimeout(() => { + setStreamLoading(false); + }, timeout); + return () => clearTimeout(timer); + } + }, [streamMode, streamLoading, imageKey, transitioning]); + + // Fullscreen change listener + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + + const handleStreamError = () => { + setStreamError(true); + setStreamLoading(false); + }; + + const handleStreamLoad = () => { + setStreamLoading(false); + setStreamError(false); + }; + + const switchToMode = (newMode: 'stream' | 'snapshot') => { + if (streamMode === newMode || transitioning) return; + setTransitioning(true); + setStreamLoading(true); + setStreamError(false); + + if (imgRef.current) { + imgRef.current.src = ''; + } + + setTimeout(() => { + setStreamMode(newMode); + setImageKey(Date.now()); + setTransitioning(false); + }, 100); + }; + + const refresh = () => { + if (transitioning) return; + setTransitioning(true); + setStreamLoading(true); + setStreamError(false); + + if (imgRef.current) { + imgRef.current.src = ''; + } + + setTimeout(() => { + setImageKey(Date.now()); + setTransitioning(false); + }, 100); + }; + + const toggleFullscreen = () => { + if (!containerRef.current) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + containerRef.current.requestFullscreen(); + } + }; + + const currentUrl = transitioning + ? '' + : streamMode === 'stream' + ? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}` + : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`; + + const isDisabled = streamLoading || transitioning; + + if (!id) { + return ( +
+

Invalid printer ID

+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ + {printer?.name || `Printer ${id}`} +

+
+ {/* Mode toggle */} +
+ + +
+ + +
+
+ + {/* Video area */} +
+
+ {(streamLoading || transitioning) && ( +
+
+ +

+ {streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'} +

+
+
+ )} + {streamError && ( +
+
+ +

Camera unavailable

+

+ Make sure the printer is powered on and connected. +

+ +
+
+ )} + Camera stream +
+
+
+ ); +} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 3f089342b..2b30d44c1 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTheme } from '../contexts/ThemeContext'; import { Plus, Link, @@ -20,10 +21,16 @@ import { Wrench, ChevronDown, Pencil, + ArrowUp, + ArrowDown, + LayoutGrid, + LayoutList, + Layers, + Video, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { api } from '../api/client'; -import type { Printer, PrinterCreate } from '../api/client'; +import type { Printer, PrinterCreate, AMSUnit } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; @@ -32,12 +39,134 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal } from '../components/HMSErrorModal'; import { PrinterQueueWidget } from '../components/PrinterQueueWidget'; +// Nozzle side indicators (Bambu Lab style - square badge with L/R) +function NozzleBadge({ side }: { side: 'L' | 'R' }) { + const { theme } = useTheme(); + // Light theme: #e7f5e9 (light green), Dark theme: #1a4d2e (dark green) + const bgColor = theme === 'dark' ? '#1a4d2e' : '#e7f5e9'; + return ( + + {side} + + ); +} + +// Water drop SVG - empty outline (Bambu Lab style from bambu-humidity) +function WaterDropEmpty({ className }: { className?: string }) { + return ( + + + + ); +} + +// Water drop SVG - half filled with blue water (Bambu Lab style from bambu-humidity) +function WaterDropHalf({ className }: { className?: string }) { + return ( + + + + + ); +} + +// Water drop SVG - fully filled with blue water (Bambu Lab style from bambu-humidity) +function WaterDropFull({ className }: { className?: string }) { + return ( + + + + + ); +} + +// Humidity indicator with water drop that fills based on level (Bambu Lab style) +// Reference: https://github.com/theicedmango/bambu-humidity +function HumidityIndicator({ humidity }: { humidity: number | string }) { + const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity; + + // Status thresholds from bambu-humidity: + // Good: ≤40% (green #22a352), Fair: 41-60% (gold #d4a017), Bad: >60% (red #c62828) + let textColor: string; + let statusText: string; + + if (isNaN(humidityValue)) { + textColor = '#C3C2C1'; + statusText = 'Unknown'; + } else if (humidityValue <= 40) { + textColor = '#22a352'; // Green - Good + statusText = 'Good'; + } else if (humidityValue <= 60) { + textColor = '#d4a017'; // Gold - Fair + statusText = 'Fair'; + } else { + textColor = '#c62828'; // Red - Bad + statusText = 'Bad'; + } + + // Fill level based on humidity (matching bambu-humidity visual style) + // ≤40% (Good): Half fill, 41-60% (Fair): Full fill, >60% (Bad): Full fill + let DropComponent: React.FC<{ className?: string }>; + if (isNaN(humidityValue)) { + DropComponent = WaterDropEmpty; + } else if (humidityValue <= 40) { + DropComponent = WaterDropHalf; + } else { + DropComponent = WaterDropFull; + } + + return ( +
+ + {humidityValue}% +
+ ); +} + +// Get AMS label: AMS-A/B/C/D for regular AMS, HT-A/B for AMS-HT (single spool) +// Always use tray count as the source of truth (1 tray = AMS-HT, 4 trays = regular AMS) +// AMS-HT uses IDs 128+ while regular AMS uses 0-3 +function getAmsLabel(amsId: number | string, trayCount: number): string { + // Ensure amsId is a number (backend might send string) + const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId; + const safeId = isNaN(id) ? 0 : id; + const isHt = trayCount === 1; + // AMS-HT uses IDs starting at 128, regular AMS uses 0-3 + const normalizedId = safeId >= 128 ? safeId - 128 : safeId; + const letter = String.fromCharCode(65 + normalizedId); // 0=A, 1=B, 2=C, 3=D + return isHt ? `HT-${letter}` : `AMS-${letter}`; +} + function formatTime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } +function formatETA(remainingMinutes: number): string { + const now = new Date(); + const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const etaDay = new Date(eta); + etaDay.setHours(0, 0, 0, 0); + + const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + // Check if it's tomorrow or later + const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + if (dayDiff === 0) { + return timeStr; + } else if (dayDiff === 1) { + return `Tomorrow ${timeStr}`; + } else { + return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr; + } +} + function getPrinterImage(model: string | null | undefined): string { if (!model) return '/img/printers/default.png'; @@ -122,14 +251,83 @@ interface PrinterMaintenanceInfo { total_print_hours: number; } +// Status summary bar component - uses queryClient to read cached statuses +function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { + const queryClient = useQueryClient(); + + const counts = useMemo(() => { + let printing = 0; + let idle = 0; + let offline = 0; + + printers?.forEach((printer) => { + const status = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', printer.id]); + if (!status?.connected) { + offline++; + } else if (status.state === 'RUNNING') { + printing++; + } else { + idle++; + } + }); + + return { printing, idle, offline, total: (printers?.length || 0) }; + }, [printers, queryClient]); + + // Subscribe to query cache changes to re-render when status updates + const [, setTick] = useState(0); + useEffect(() => { + const unsubscribe = queryClient.getQueryCache().subscribe(() => { + setTick(t => t + 1); + }); + return () => unsubscribe(); + }, [queryClient]); + + if (!printers?.length) return null; + + return ( +
+ {counts.printing > 0 && ( +
+
+ + {counts.printing} printing + +
+ )} + {counts.idle > 0 && ( +
+
+ + {counts.idle} idle + +
+ )} + {counts.offline > 0 && ( +
+
+ + {counts.offline} offline + +
+ )} +
+ ); +} + +type SortOption = 'name' | 'status' | 'model' | 'location'; +type ViewMode = 'expanded' | 'compact'; + function PrinterCard({ printer, hideIfDisconnected, - maintenanceInfo + maintenanceInfo, + viewMode = 'expanded', }: { printer: Printer; hideIfDisconnected?: boolean; maintenanceInfo?: PrinterMaintenanceInfo; + viewMode?: ViewMode; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -157,6 +355,26 @@ function PrinterCard({ }, [status?.wifi_signal]); const wifiSignal = status?.wifi_signal ?? cachedWifiSignal; + // Cache ams_extruder_map to prevent L/R indicators bouncing on updates + const cachedAmsExtruderMap = useRef>({}); + useEffect(() => { + if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) { + cachedAmsExtruderMap.current = status.ams_extruder_map; + } + }, [status?.ams_extruder_map]); + const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) + ? status.ams_extruder_map + : cachedAmsExtruderMap.current; + + // Cache AMS data to prevent it disappearing on idle/offline printers + const cachedAmsData = useRef([]); + useEffect(() => { + if (status?.ams && status.ams.length > 0) { + cachedAmsData.current = status.ams; + } + }, [status?.ams]); + const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current; + // Fetch smart plug for this printer const { data: smartPlug } = useQuery({ queryKey: ['smartPlugByPrinter', printer.id], @@ -171,6 +389,21 @@ function PrinterCard({ refetchInterval: 10000, // 10 seconds for real-time power display }); + // Fetch queue count for this printer + const { data: queueItems } = useQuery({ + queryKey: ['queue', printer.id, 'pending'], + queryFn: () => api.getQueue(printer.id, 'pending'), + }); + const queueCount = queueItems?.length || 0; + + // Fetch last completed print for this printer + const { data: lastPrints } = useQuery({ + queryKey: ['archives', printer.id, 'last'], + queryFn: () => api.getArchives(printer.id, 1, 0), + enabled: status?.connected && status?.state !== 'RUNNING', + }); + const lastPrint = lastPrints?.[0]; + // Determine if this card should be hidden const shouldHide = hideIfDisconnected && status && !status.connected; @@ -215,106 +448,48 @@ function PrinterCard({ {/* Header */} -
-
- {/* Printer Model Image */} - {printer.model -
-

{printer.name}

-

- {printer.model || 'Unknown Model'} - {maintenanceInfo && maintenanceInfo.total_print_hours > 0 && ( - - - {Math.round(maintenanceInfo.total_print_hours)}h - - )} -

+
+ {/* Top row: Image, Name, Menu */} +
+
+ {/* Printer Model Image */} + {printer.model +
+
+

{printer.name}

+ {/* Connection indicator dot for compact mode */} + {viewMode === 'compact' && ( +
+ )} +
+

+ {printer.model || 'Unknown Model'} + {/* Nozzle Info - only in expanded */} + {viewMode === 'expanded' && status?.nozzles && status.nozzles[0]?.nozzle_diameter && ( + + • {status.nozzles[0].nozzle_diameter}mm + + )} + {viewMode === 'expanded' && maintenanceInfo && maintenanceInfo.total_print_hours > 0 && ( + + + {Math.round(maintenanceInfo.total_print_hours)}h + + )} +

+
-
-
- {/* Connection status badge */} - - {status?.connected ? ( - - ) : ( - - )} - {status?.connected ? 'Connected' : 'Offline'} - - {/* WiFi signal strength indicator */} - {status?.connected && wifiSignal != null && ( - = -50 - ? 'bg-bambu-green/20 text-bambu-green' - : wifiSignal >= -60 - ? 'bg-bambu-green/20 text-bambu-green' - : wifiSignal >= -70 - ? 'bg-amber-500/20 text-amber-600' - : wifiSignal >= -80 - ? 'bg-orange-500/20 text-orange-600' - : 'bg-red-500/20 text-red-600' - }`} - title={`WiFi: ${wifiSignal} dBm - ${getWifiStrength(wifiSignal).label}`} - > - - {wifiSignal}dBm - - )} - {/* HMS Status Indicator */} - {status?.connected && ( - - )} - {/* Maintenance Status Indicator - always show */} - {maintenanceInfo && ( - - )} -
+ {/* Menu button */} +
+ + {/* Badges row - only in expanded mode */} + {viewMode === 'expanded' && ( +
+ {/* Connection status badge */} + + {status?.connected ? ( + + ) : ( + + )} + {status?.connected ? 'Connected' : 'Offline'} + + {/* WiFi signal strength indicator */} + {status?.connected && wifiSignal != null && ( + = -50 + ? 'bg-bambu-green/20 text-bambu-green' + : wifiSignal >= -60 + ? 'bg-bambu-green/20 text-bambu-green' + : wifiSignal >= -70 + ? 'bg-amber-500/20 text-amber-600' + : wifiSignal >= -80 + ? 'bg-orange-500/20 text-orange-600' + : 'bg-red-500/20 text-red-600' + }`} + title={`WiFi: ${wifiSignal} dBm - ${getWifiStrength(wifiSignal).label}`} + > + + {wifiSignal}dBm + + )} + {/* HMS Status Indicator */} + {status?.connected && ( + + )} + {/* Maintenance Status Indicator */} + {maintenanceInfo && ( + + )} + {/* Queue Count Badge */} + {queueCount > 0 && ( + + )} +
+ )}
{/* Delete Confirmation */} @@ -388,78 +657,129 @@ function PrinterCard({ {/* Status */} {status?.connected && ( <> - {/* Current Print or Idle Placeholder */} -
-
- {/* Cover Image */} - - {/* Print Info */} -
- {status.current_print && status.state === 'RUNNING' ? ( - <> -

Printing

-

- {status.subtask_name || status.current_print} -

-
-
-
-
- {Math.round(status.progress || 0)}% -
-
- {status.remaining_time != null && status.remaining_time > 0 && ( - - - {formatTime(status.remaining_time * 60)} - - )} - {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && ( - - Layer {status.layer_num}/{status.total_layers} - - )} -
- - ) : ( - <> -

Status

-

- {status.state?.toLowerCase() || 'Idle'} -

-
-
-
-
- — -
-

Ready to print

- - )} -
+ {/* Compact: Simple status bar */} + {viewMode === 'compact' ? ( +
+ {status.state === 'RUNNING' ? ( +
+
+
+
+ {Math.round(status.progress || 0)}% +
+ ) : ( +

{status.state?.toLowerCase() || 'Idle'}

+ )}
-
+ ) : ( + /* Expanded: Full status section */ + <> + {/* Current Print or Idle Placeholder */} +
+
+ {/* Cover Image */} + + {/* Print Info */} +
+ {status.current_print && status.state === 'RUNNING' ? ( + <> +

Printing

+

+ {status.subtask_name || status.current_print} +

+
+
+
+
+ {Math.round(status.progress || 0)}% +
+
+ {status.remaining_time != null && status.remaining_time > 0 && ( + <> + + + {formatTime(status.remaining_time * 60)} + + + ETA {formatETA(status.remaining_time)} + + + )} + {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && ( + + + {status.layer_num}/{status.total_layers} + + )} +
+ + ) : ( + <> +

Status

+

+ {status.state?.toLowerCase() || 'Idle'} +

+
+
+
+
+ — +
+ {lastPrint ? ( +

+ Last: {lastPrint.print_name || lastPrint.filename} + {lastPrint.completed_at && ( + + • {new Date(lastPrint.completed_at).toLocaleDateString([], { month: 'short', day: 'numeric' })} + + )} +

+ ) : ( +

Ready to print

+ )} + + )} +
+
+
- {/* Queue Widget - shows next scheduled print */} - {status.state !== 'RUNNING' && ( - + {/* Queue Widget - shows next scheduled print */} + {status.state !== 'RUNNING' && ( + + )} + )} {/* Temperatures */} - {status.temperatures && ( + {status.temperatures && viewMode === 'expanded' && (
+ {/* Nozzle temp - combined for dual nozzle */}
-

Nozzle

-

- {Math.round(status.temperatures.nozzle || 0)}°C -

+ {status.temperatures.nozzle_2 !== undefined ? ( + <> +

Left / Right

+

+ {Math.round(status.temperatures.nozzle || 0)}°C / {Math.round(status.temperatures.nozzle_2 || 0)}°C +

+ + ) : ( + <> +

Nozzle

+

+ {Math.round(status.temperatures.nozzle || 0)}°C +

+ + )}
@@ -479,11 +799,92 @@ function PrinterCard({ )}
)} + + {/* AMS Units with Filament Colors, Humidity & Temperature */} + {amsData && amsData.length > 0 && viewMode === 'expanded' && ( +
+
+ {amsData.map((ams) => { + // For dual nozzle printers, determine which nozzle this AMS is connected to + const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id; + // Use cached extruder map, or fallback to conventional mapping (0=R, 1=L) + const mappedExtruderId = amsExtruderMap[String(normalizedId)]; + const extruderId = mappedExtruderId !== undefined + ? mappedExtruderId + : normalizedId; // Fallback: AMS 0 → extruder 0 (R), AMS 1 → extruder 1 (L) + // Use printer.nozzle_count as primary source (stable), fallback to nozzle_2 temp + const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined; + // extruder 0 = Right, extruder 1 = Left + const isLeftNozzle = extruderId === 1; + const isRightNozzle = extruderId === 0; + + return ( +
+
+ {/* Nozzle indicator for dual nozzle printers */} + {isDualNozzle && (isLeftNozzle || isRightNozzle) && ( + + )} + + {getAmsLabel(ams.id, ams.tray.length)} + +
+ {ams.tray.map((tray, trayIdx) => ( +
+ ))} +
+
+ {/* Humidity & Temperature */} + {(ams.humidity != null || ams.temp != null) && ( +
+ {ams.humidity != null && ( + + )} + {ams.temp != null && ( + + + {ams.temp}°C + + )} +
+ )} +
+ ); + })} +
+ {/* External spool indicator */} + {status.vt_tray && status.vt_tray.tray_type && ( +
+ Ext +
+
+ )} +
+ )} )} - {/* Smart Plug Controls */} - {smartPlug && ( + {/* Smart Plug Controls - hidden in compact mode */} + {smartPlug && viewMode === 'expanded' && (
{/* Plug name and status */} @@ -568,22 +969,41 @@ function PrinterCard({
)} - {/* Connection Info & Actions */} -
-
-

{printer.ip_address}

-

{printer.serial_number}

+ {/* Connection Info & Actions - hidden in compact mode */} + {viewMode === 'expanded' && ( +
+
+

{printer.ip_address}

+

{printer.serial_number}

+
+
+ + +
- -
+ )} {/* File Manager Modal */} @@ -812,6 +1232,7 @@ function EditPrinterModal({ ip_address: printer.ip_address, access_code: '', model: printer.model || '', + location: printer.location || '', auto_archive: printer.auto_archive, }); @@ -839,6 +1260,7 @@ function EditPrinterModal({ name: form.name, ip_address: form.ip_address, model: form.model || undefined, + location: form.location || undefined, auto_archive: form.auto_archive, }; // Only include access_code if it was changed @@ -929,6 +1351,17 @@ function EditPrinterModal({
+
+ + setForm({ ...form, location: e.target.value })} + placeholder="e.g., Workshop, Office, Basement" + /> +

Used to group printers on the dashboard

+
(null); + const [sortBy, setSortBy] = useState(() => { + return (localStorage.getItem('printerSortBy') as SortOption) || 'name'; + }); + const [sortAsc, setSortAsc] = useState(() => { + return localStorage.getItem('printerSortAsc') !== 'false'; + }); + const [viewMode, setViewMode] = useState(() => { + return (localStorage.getItem('printerViewMode') as ViewMode) || 'expanded'; + }); const queryClient = useQueryClient(); const { data: printers, isLoading } = useQuery({ @@ -1100,14 +1542,131 @@ export function PrintersPage() { localStorage.setItem('hideDisconnectedPrinters', String(newValue)); }; + const handleSortChange = (newSort: SortOption) => { + setSortBy(newSort); + localStorage.setItem('printerSortBy', newSort); + }; + + const toggleSortDirection = () => { + const newAsc = !sortAsc; + setSortAsc(newAsc); + localStorage.setItem('printerSortAsc', String(newAsc)); + }; + + const toggleViewMode = () => { + const newMode = viewMode === 'expanded' ? 'compact' : 'expanded'; + setViewMode(newMode); + localStorage.setItem('printerViewMode', newMode); + }; + + // Sort printers based on selected option + const sortedPrinters = useMemo(() => { + if (!printers) return []; + const sorted = [...printers]; + + switch (sortBy) { + case 'name': + sorted.sort((a, b) => a.name.localeCompare(b.name)); + break; + case 'model': + sorted.sort((a, b) => (a.model || '').localeCompare(b.model || '')); + break; + case 'location': + // Sort by location, with ungrouped printers last + sorted.sort((a, b) => { + const locA = a.location || ''; + const locB = b.location || ''; + if (!locA && locB) return 1; + if (locA && !locB) return -1; + return locA.localeCompare(locB) || a.name.localeCompare(b.name); + }); + break; + case 'status': + // Sort by status: printing > idle > offline + sorted.sort((a, b) => { + const statusA = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', a.id]); + const statusB = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', b.id]); + + const getPriority = (s: typeof statusA) => { + if (!s?.connected) return 2; // offline + if (s.state === 'RUNNING') return 0; // printing + return 1; // idle + }; + + return getPriority(statusA) - getPriority(statusB); + }); + break; + } + + // Apply ascending/descending + if (!sortAsc) { + sorted.reverse(); + } + + return sorted; + }, [printers, sortBy, sortAsc, queryClient]); + + // Group printers by location when sorted by location + const groupedPrinters = useMemo(() => { + if (sortBy !== 'location') return null; + + const groups: Record = {}; + sortedPrinters.forEach(printer => { + const location = printer.location || 'Ungrouped'; + if (!groups[location]) groups[location] = []; + groups[location].push(printer); + }); + return groups; + }, [sortBy, sortedPrinters]); + return (
-
+

Printers

-

Manage your Bambu Lab printers

+
-
+
+ {/* Sort dropdown */} +
+ + +
+ + {/* View mode toggle */} + + +
+