diff --git a/server.py b/server.py index 422c669..068fe0c 100644 --- a/server.py +++ b/server.py @@ -1,2314 +1,2129 @@ -#!/usr/bin/env python3 -""" -Bambu PrintFarm - Production Ready Backend -Version: 2.0.0 - -Een robuuste, productie-klare backend voor het beheren van meerdere Bambu Lab 3D-printers. -Ondersteunt MQTT-communicatie, camera streaming, AMS-beheer en realtime alerts. -""" - -import os -import json -import ssl -import shutil -import subprocess -import threading -import time -import pathlib -import re -import asyncio -import mimetypes -import signal -import sys -from dataclasses import dataclass -from typing import Dict, Any, Optional, List, Tuple, Set -from datetime import datetime, timezone -from contextlib import contextmanager -import logging -from logging.handlers import RotatingFileHandler - -from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse -from pydantic import BaseModel, Field, validator -import sqlite3 -import uvicorn -import paho.mqtt.client as mqtt -import cv2 -import urllib.request - -# ========== CONFIGURATIE ========== -APP_HOST = os.environ.get("APP_HOST", "0.0.0.0") -APP_PORT = int(os.environ.get("APP_PORT", "8000")) -DATA_DIR = pathlib.Path(os.environ.get("DATA_DIR", "./data")).resolve() -STREAMS_DIR = DATA_DIR / "streams" -DB_PATH = DATA_DIR / "printfarm.db" -WEB_DIR = pathlib.Path(os.environ.get("WEB_DIR", "./web")).resolve() -LOG_DIR = DATA_DIR / "logs" - -STATE_STALE_SECONDS = int(os.environ.get("STATE_STALE_SECONDS", "120")) -SNAPSHOT_FPS = float(os.environ.get("SNAPSHOT_FPS", "1")) -SNAPSHOT_QUALITY = int(os.environ.get("SNAPSHOT_QUALITY", "80")) -HLS_SEGMENT_SEC = int(os.environ.get("HLS_SEGMENT_SEC", "2")) -CAMERA_TRANSCODE = os.environ.get("CAMERA_TRANSCODE", "auto").lower() - -HMS_JSON_URL = os.environ.get( - "HMS_JSON_URL", - "https://raw.githubusercontent.com/bambulab/BambuStudio/master/resources/hms/hms_en_094.json" -) -HMS_REFRESH_SEC = int(os.environ.get("HMS_REFRESH_SEC", "21600")) -STABLE_EMPTY_SEC = 5.0 -LOCAL_TZ = os.environ.get("LOCAL_TZ", "Europe/Amsterdam") - -# MIME types -mimetypes.add_type("application/vnd.apple.mpegurl", ".m3u8") -mimetypes.add_type("application/x-mpegURL", ".m3u8") -mimetypes.add_type("video/mp2t", ".ts") -mimetypes.add_type("image/jpeg", ".jpg") - -os.environ.setdefault("OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp") - -# ========== LOGGING SETUP ========== -def setup_logging(): - """Configureer gestructureerd logging met rotatie""" - LOG_DIR.mkdir(parents=True, exist_ok=True) - - # Root logger - logger = logging.getLogger() - logger.setLevel(logging.INFO) - - # Console handler - console = logging.StreamHandler() - console.setLevel(logging.INFO) - console_fmt = logging.Formatter( - '%(asctime)s [%(levelname)s] %(name)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - console.setFormatter(console_fmt) - logger.addHandler(console) - - # File handler met rotatie (10MB per bestand, max 5 backups) - file_handler = RotatingFileHandler( - LOG_DIR / "printfarm.log", - maxBytes=10*1024*1024, - backupCount=5, - encoding='utf-8' - ) - file_handler.setLevel(logging.DEBUG) - file_fmt = logging.Formatter( - '%(asctime)s [%(levelname)s] %(name)s [%(funcName)s:%(lineno)d]: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - file_handler.setFormatter(file_fmt) - logger.addHandler(file_handler) - - # Error log (alleen errors en hoger) - error_handler = RotatingFileHandler( - LOG_DIR / "errors.log", - maxBytes=10*1024*1024, - backupCount=3, - encoding='utf-8' - ) - error_handler.setLevel(logging.ERROR) - error_handler.setFormatter(file_fmt) - logger.addHandler(error_handler) - - return logger - -logger = setup_logging() - -# ========== DATABASE ========== -class DatabaseManager: - """Thread-safe database manager met connection pooling""" - - def __init__(self, db_path: pathlib.Path): - self.db_path = db_path - self._local = threading.local() - - @contextmanager - def get_connection(self): - """Context manager voor database connecties""" - if not hasattr(self._local, 'conn') or self._local.conn is None: - self._local.conn = sqlite3.connect(str(self.db_path), timeout=30.0) - self._local.conn.row_factory = sqlite3.Row - self._local.conn.execute("PRAGMA journal_mode=WAL;") - self._local.conn.execute("PRAGMA synchronous=NORMAL;") - self._local.conn.execute("PRAGMA foreign_keys=ON;") - - try: - yield self._local.conn - self._local.conn.commit() - except Exception: - self._local.conn.rollback() - raise - - def init_schema(self): - """Initialiseer database schema met migraties""" - with self.get_connection() as conn: - cur = conn.cursor() - - # Printers tabel - cur.execute(""" - CREATE TABLE IF NOT EXISTS printers ( - device_id TEXT PRIMARY KEY, - name TEXT NOT NULL, - model TEXT NOT NULL, - ip TEXT, - lan_access_code TEXT, - cloud_user_id TEXT, - cloud_access_token TEXT, - autoprint INTEGER NOT NULL DEFAULT 1, - tags TEXT NOT NULL DEFAULT '[]', - auto_ams_black_asacf INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - """) - - # States tabel - cur.execute(""" - CREATE TABLE IF NOT EXISTS states ( - device_id TEXT PRIMARY KEY, - payload TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE - ) - """) - - # Events tabel - cur.execute(""" - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - device_id TEXT NOT NULL, - ts TEXT NOT NULL, - type TEXT NOT NULL, - meta TEXT NOT NULL DEFAULT '{}', - FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE - ) - """) - cur.execute("CREATE INDEX IF NOT EXISTS idx_events_device_ts ON events(device_id, ts)") - cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)") - - # Alerts tabel - cur.execute(""" - CREATE TABLE IF NOT EXISTS alerts ( - id TEXT PRIMARY KEY, - device_id TEXT NOT NULL, - printer_name TEXT, - code TEXT, - message TEXT, - severity INTEGER NOT NULL, - state TEXT NOT NULL, - module TEXT, - count INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - last_seen TEXT NOT NULL, - resolved_at TEXT, - raw TEXT, - FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE - ) - """) - cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_device ON alerts(device_id)") - cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_state ON alerts(state)") - cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at)") - cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_code ON alerts(code)") - - # State overrides tabel - cur.execute(""" - CREATE TABLE IF NOT EXISTS state_overrides ( - device_id TEXT PRIMARY KEY, - status TEXT NOT NULL, - note TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE - ) - """) - - conn.commit() - logger.info("Database schema geïnitialiseerd") - -db_manager = DatabaseManager(DB_PATH) - -# ========== UTILITY FUNCTIES ========== -def now_ts() -> float: - """Huidige timestamp in seconden""" - return time.time() - -def ts_iso() -> str: - """Huidige timestamp in ISO format (UTC)""" - return datetime.utcnow().replace(microsecond=0).isoformat() + "Z" - -def to_epoch(s: Optional[str]) -> Optional[int]: - """Converteer ISO datetime string naar epoch timestamp""" - if not s: - return None - try: - if s.endswith("Z"): - return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()) - if "T" in s: - return int(datetime.fromisoformat(s).replace(tzinfo=timezone.utc).timestamp()) - dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) - return int(dt.timestamp()) - except Exception as e: - logger.warning(f"Kon timestamp niet converteren: {s} - {e}") - return None - -def ensure_dirs(): - """Zorg dat alle benodigde directories bestaan""" - for d in [DATA_DIR, STREAMS_DIR, WEB_DIR, LOG_DIR]: - d.mkdir(parents=True, exist_ok=True) - logger.info("Directories geverifieerd") - -def normalize_tags(val: Any) -> List[str]: - """Normaliseer tags naar lowercase lijst zonder duplicaten""" - tags: List[str] = [] - if isinstance(val, list): - tags = [str(x) for x in val] - elif isinstance(val, str): - try: - j = json.loads(val) - if isinstance(j, list): - tags = [str(x) for x in j] - else: - tags = [s.strip() for s in val.split(",")] - except Exception: - tags = [s.strip() for s in val.split(",")] - - out: List[str] = [] - seen = set() - for t in tags: - s = t.strip().lower() - if s and s not in seen: - seen.add(s) - out.append(s) - return out[:32] # Max 32 tags - -def hex_rgba_to_css(hex8: str) -> str: - """Converteer 8-digit hex (RRGGBBAA) naar CSS hex (#RRGGBB)""" - h = (hex8 or "").strip().upper() - if not re.fullmatch(r"[0-9A-F]{8}", h): - return "#000000" - return "#" + h[:6] - -# ========== HMS CATALOG ========== -class HMSCatalog: - """HMS error code lookup met automatische refresh""" - - def __init__(self): - self.map: Dict[str, str] = {} - self.version: Optional[str] = None - self.loaded_at: Optional[float] = None - self.lock = threading.Lock() - self._stop = threading.Event() - self._thread: Optional[threading.Thread] = None - self.cache_path = DATA_DIR / "hms_cache.json" - - def normalize_and_load(self, data: Any) -> Tuple[Dict[str, str], Optional[str]]: - """Parse HMS JSON naar code -> message mapping""" - version = None - entries = [] - - if isinstance(data, dict) and "data" in data: - version = str(data.get("ver") or data["data"].get("ver") or "") - dev = data["data"].get("device_error") or {} - lang = dev.get("en") or dev.get("EN") or dev.get("En") - if isinstance(lang, list): - entries = lang - - if not entries and isinstance(data, dict) and "device_error" in data: - version = str(data.get("ver") or data["device_error"].get("ver") or "") - lang = data["device_error"].get("en") or data["device_error"].get("EN") - if isinstance(lang, list): - entries = lang - - if not entries and isinstance(data, list): - entries = data - - mapping: Dict[str, str] = {} - for it in entries: - if not isinstance(it, dict): - continue - code = str(it.get("ecode") or it.get("code") or "").strip().upper() - intro = str(it.get("intro") or it.get("message") or "").strip() - if code: - mapping[code] = intro - - return mapping, version - - def load_from_disk(self): - """Laad gecachete HMS data van disk""" - if not self.cache_path.exists(): - logger.debug("Geen HMS cache gevonden") - return - try: - with self.cache_path.open("r", encoding="utf-8") as f: - raw = json.load(f) - mapping, version = self.normalize_and_load(raw) - with self.lock: - if mapping: - self.map = mapping - self.version = version - self.loaded_at = now_ts() - logger.info(f"HMS cache geladen: {len(mapping)} codes, versie {version}") - except Exception as e: - logger.error(f"Fout bij laden HMS cache: {e}") - - def save_to_disk(self, raw: Any): - """Sla HMS data op naar disk""" - try: - with self.cache_path.open("w", encoding="utf-8") as f: - json.dump(raw, f, ensure_ascii=False) - logger.debug("HMS cache opgeslagen") - except Exception as e: - logger.error(f"Fout bij opslaan HMS cache: {e}") - - def download_and_reload(self) -> bool: - """Download fresh HMS data en reload""" - try: - req = urllib.request.Request(HMS_JSON_URL, headers={"User-Agent": "printfarm/2.0"}) - with urllib.request.urlopen(req, timeout=10) as r: - data = json.loads(r.read().decode("utf-8")) - except Exception as e: - logger.warning(f"HMS download mislukt: {e}") - return False - - mapping, version = self.normalize_and_load(data) - if not mapping: - logger.warning("HMS download bevatte geen geldige data") - return False - - self.save_to_disk(data) - with self.lock: - self.map = mapping - self.version = version - self.loaded_at = now_ts() - logger.info(f"HMS data geüpdatet: {len(mapping)} codes, versie {version}") - return True - - def refresh(self) -> bool: - """Refresh HMS data (download of gebruik cache)""" - ok = self.download_and_reload() - if not ok: - self.load_from_disk() - ok = bool(self.map) - return ok - - def start_background_refresh(self): - """Start achtergrond thread voor periodieke refresh""" - if self._thread and self._thread.is_alive(): - return - self._stop.clear() - self._thread = threading.Thread(target=self._refresh_loop, daemon=True, name="HMS-Refresh") - self._thread.start() - logger.info("HMS achtergrond refresh gestart") - - def stop_background_refresh(self): - """Stop achtergrond refresh thread""" - self._stop.set() - if self._thread: - self._thread.join(timeout=5) - logger.info("HMS achtergrond refresh gestopt") - - def _refresh_loop(self): - """Achtergrond loop voor periodieke HMS refresh""" - try: - self.refresh() - except Exception as e: - logger.error(f"Initiële HMS refresh mislukt: {e}") - - while not self._stop.is_set(): - for _ in range(HMS_REFRESH_SEC): - if self._stop.is_set(): - return - time.sleep(1) - try: - self.refresh() - except Exception as e: - logger.error(f"HMS refresh mislukt: {e}") - - def lookup(self, code_any: Any) -> Tuple[Optional[str], str]: - """Zoek error message voor gegeven code""" - if code_any in (None, "", 0, "0"): - return (None, "00000000") - - s = str(code_any).strip().upper() - hex_code = None - try: - if s.startswith("0X"): - hex_code = f"{int(s, 16):08X}" - elif re.fullmatch(r"[0-9A-F]{8}", s): - hex_code = s - else: - hex_code = f"{int(s, 10):08X}" - except Exception: - s2 = re.sub(r"[^0-9A-F]", "", s) - hex_code = (s2[:8] if s2 else "0").rjust(8, "0") - - with self.lock: - msg = self.map.get(hex_code) - return (msg, hex_code) - -hms = HMSCatalog() - -# ========== CAMERA REGISTRY ========== -class CameraRegistry: - """Beheer HLS streams en snapshots voor cameras""" - - def __init__(self, base_dir: pathlib.Path): - self.base_dir = base_dir - self.procs: Dict[str, subprocess.Popen] = {} - self.threads: Dict[str, threading.Thread] = {} - self.stops: Dict[str, threading.Event] = {} - self.lock = threading.Lock() - - def _stream_dir(self, device_id: str) -> pathlib.Path: - """Verkrijg/maak stream directory voor device""" - d = self.base_dir / device_id - d.mkdir(parents=True, exist_ok=True) - return d - - def snapshot_path(self, device_id: str) -> pathlib.Path: - """Pad naar snapshot bestand""" - return self._stream_dir(device_id) / "snapshot.jpg" - - def start_hls(self, device_id: str, ip: str, lan_access_code: str) -> str: - """Start HLS stream (FFmpeg)""" - with self.lock: - if device_id in self.procs and self.procs[device_id].poll() is None: - return f"/streams/{device_id}/index.m3u8" - - stream_dir = self._stream_dir(device_id) - for f in stream_dir.glob("*"): - try: - f.unlink() - except Exception: - pass - - rtsp_url = f"rtsps://bblp:{lan_access_code}@{ip}:322/streaming/live/1" - hls_out = str((stream_dir / "index.m3u8").resolve()) - - if CAMERA_TRANSCODE in ("x264", "h264", "1", "true"): - v_args = [ - "-c:v", "libx264", - "-preset", "veryfast", - "-tune", "zerolatency", - "-pix_fmt", "yuv420p", - "-force_key_frames", f"expr:gte(t,n_forced*{HLS_SEGMENT_SEC})", - ] - else: - v_args = ["-c:v", "copy"] - - cmd = [ - "ffmpeg", - "-hide_banner", "-loglevel", "error", - "-rtsp_transport", "tcp", - "-fflags", "nobuffer", - "-i", rtsp_url, - *v_args, - "-c:a", "aac", "-ar", "44100", "-ac", "1", "-b:a", "96k", - "-movflags", "+faststart", - "-f", "hls", - "-hls_time", str(HLS_SEGMENT_SEC), - "-hls_list_size", "6", - "-hls_delete_threshold", "2", - "-hls_flags", "delete_segments+append_list+independent_segments", - hls_out - ] - - try: - proc = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True - ) - except FileNotFoundError: - logger.error("FFmpeg niet gevonden in PATH") - raise HTTPException(500, "FFmpeg niet geïnstalleerd") - - self.procs[device_id] = proc - logger.info(f"HLS stream gestart voor {device_id}") - return f"/streams/{device_id}/index.m3u8" - - def stop_hls(self, device_id: str): - """Stop HLS stream""" - with self.lock: - proc = self.procs.get(device_id) - if proc and proc.poll() is None: - try: - proc.terminate() - try: - proc.wait(timeout=3) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait(timeout=1) - except Exception as e: - logger.error(f"Fout bij stoppen HLS stream {device_id}: {e}") - self.procs.pop(device_id, None) - - d = self._stream_dir(device_id) - if d.exists(): - for f in d.glob("*"): - try: - f.unlink() - except Exception: - pass - logger.info(f"HLS stream gestopt voor {device_id}") - - def start_snapshot(self, device_id: str, ip: str, lan_access_code: str) -> str: - """Start snapshot thread (OpenCV)""" - with self.lock: - if device_id in self.threads and self.threads[device_id].is_alive(): - return f"/streams/{device_id}/snapshot.jpg" - - self.stops[device_id] = threading.Event() - th = threading.Thread( - target=self._snapshot_loop, - args=(device_id, ip, lan_access_code), - daemon=True, - name=f"Snapshot-{device_id}" - ) - self.threads[device_id] = th - th.start() - logger.info(f"Snapshot stream gestart voor {device_id}") - return f"/streams/{device_id}/snapshot.jpg" - - def stop_snapshot(self, device_id: str): - """Stop snapshot thread""" - with self.lock: - evt = self.stops.get(device_id) - th = self.threads.get(device_id) - if evt: - evt.set() - if th and th.is_alive(): - th.join(timeout=3) - self.stops.pop(device_id, None) - self.threads.pop(device_id, None) - logger.info(f"Snapshot stream gestopt voor {device_id}") - - def _snapshot_loop(self, device_id: str, ip: str, lan_access_code: str): - """Worker loop voor snapshot generation""" - stop_evt = self.stops[device_id] - url = f"rtsps://bblp:{lan_access_code}@{ip}:322/streaming/live/1" - out_path = self.snapshot_path(device_id) - - cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG) - last_ok = time.time() - - try: - while not stop_evt.is_set(): - ok, frame = cap.read() - if not ok or frame is None: - if time.time() - last_ok > 10: - logger.warning(f"Snapshot stream timeout voor {device_id}") - break - time.sleep(0.25) - continue - - last_ok = time.time() - q = min(max(int(SNAPSHOT_QUALITY), 1), 100) - ok2, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), q]) - - if ok2: - tmp = str(out_path) + ".tmp" - try: - with open(tmp, "wb") as f: - f.write(buf.tobytes()) - os.replace(tmp, str(out_path)) - except Exception as e: - logger.error(f"Fout bij schrijven snapshot {device_id}: {e}") - - sleep_time = max(0.1, 1.0 / SNAPSHOT_FPS if SNAPSHOT_FPS > 0 else 1.0) - if stop_evt.wait(sleep_time): - break - except Exception as e: - logger.error(f"Snapshot loop fout voor {device_id}: {e}") - finally: - try: - cap.release() - except Exception: - pass - logger.debug(f"Snapshot loop beëindigd voor {device_id}") - - def capture_once(self, device_id: str, ip: str, lan_access_code: str) -> pathlib.Path: - """Maak eenmalige snapshot""" - url = f"rtsps://bblp:{lan_access_code}@{ip}:322/streaming/live/1" - out_path = self.snapshot_path(device_id) - - cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG) - try: - ok, frame = cap.read() - if not ok or frame is None: - raise HTTPException(502, "Snapshot maken mislukt (RTSP)") - - q = min(max(int(SNAPSHOT_QUALITY), 1), 100) - ok2, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), q]) - if not ok2: - raise HTTPException(500, "JPEG encoding mislukt") - - tmp = str(out_path) + ".tmp" - with open(tmp, "wb") as f: - f.write(buf.tobytes()) - os.replace(tmp, str(out_path)) - return out_path - finally: - try: - cap.release() - except Exception: - pass - - def cleanup_all(self): - """Stop alle streams en threads (bij shutdown)""" - logger.info("Camera registry cleanup gestart") - with self.lock: - device_ids = list(self.procs.keys()) + list(self.threads.keys()) - - for device_id in set(device_ids): - try: - self.stop_hls(device_id) - self.stop_snapshot(device_id) - except Exception as e: - logger.error(f"Fout bij cleanup camera {device_id}: {e}") - -camera = CameraRegistry(STREAMS_DIR) - -# ========== MQTT BROKER CONFIG ========== -@dataclass -class BrokerConfig: - is_cloud: bool = False - host: str = "" - port: int = 8883 - username: str = "" - password: str = "" - tls_insecure: bool = True - -# ========== PRINTER CLIENT ========== -class PrinterClient: - """MQTT client voor individuele printer""" - - def __init__(self, cfg: dict, on_report): - self.cfg = cfg - self.device_id = cfg["device_id"] - self.on_report = on_report - self.client = mqtt.Client( - client_id=f"printfarm-{self.device_id}", - clean_session=True - ) - self.seq = 0 - self.seq_lock = threading.Lock() - self.connected = threading.Event() - self.stop_evt = threading.Event() - self.thread: Optional[threading.Thread] = None - - # ACK tracking - self._pending_lock = threading.Lock() - self._pending: Dict[str, Tuple[threading.Event, str, str]] = {} - self._responses: Dict[str, dict] = {} - - def _broker(self) -> BrokerConfig: - """Bepaal broker config (LAN of Cloud)""" - if self.cfg.get("ip") and self.cfg.get("lan_access_code"): - return BrokerConfig( - False, - self.cfg["ip"], - 8883, - "bblp", - self.cfg["lan_access_code"], - True - ) - if self.cfg.get("cloud_user_id") and self.cfg.get("cloud_access_token"): - return BrokerConfig( - True, - "us.mqtt.bambulab.com", - 8883, - f"u_{self.cfg['cloud_user_id']}", - self.cfg["cloud_access_token"], - False - ) - raise RuntimeError("Geen geldige broker config (LAN of Cloud)") - - def _on_connect(self, client, userdata, flags, rc): - """MQTT connect callback""" - if rc == 0: - topic = f"device/{self.device_id}/report" - client.subscribe(topic, qos=1) - self.connected.set() - self.send_pushall() - logger.info(f"MQTT verbonden: {self.device_id}") - else: - self.connected.clear() - logger.warning(f"MQTT connect mislukt: {self.device_id}, rc={rc}") - - def _on_message(self, client, userdata, msg): - """MQTT message callback""" - try: - payload = json.loads(msg.payload.decode("utf-8", errors="ignore")) - except Exception as e: - logger.warning(f"Ongeldige MQTT payload voor {self.device_id}: {e}") - return - - # Doorsturen naar manager - try: - self.on_report(self.device_id, payload) - except Exception as e: - logger.error(f"Fout bij verwerken report voor {self.device_id}: {e}", exc_info=True) - - # ACK tracking - try: - for typ, block in payload.items(): - if not isinstance(block, dict): - continue - seq = str(block.get("sequence_id", "") or "") - cmd = str(block.get("command", "") or "") - if not seq or not cmd: - continue - - with self._pending_lock: - pending = self._pending.get(seq) - if pending: - ev, exp_typ, exp_cmd = pending - if typ == exp_typ and cmd == exp_cmd: - self._responses[seq] = block - ev.set() - except Exception as e: - logger.debug(f"ACK tracking fout voor {self.device_id}: {e}") - - def _on_disconnect(self, client, userdata, rc): - """MQTT disconnect callback""" - self.connected.clear() - if rc != 0: - logger.warning(f"MQTT onverwacht verbroken: {self.device_id}, rc={rc}") - - def start(self): - """Start MQTT client thread""" - if self.thread and self.thread.is_alive(): - return - self.stop_evt.clear() - self.thread = threading.Thread( - target=self._run, - daemon=True, - name=f"MQTT-{self.device_id}" - ) - self.thread.start() - logger.info(f"MQTT client gestart voor {self.device_id}") - - def stop(self): - """Stop MQTT client""" - self.stop_evt.set() - try: - self.client.disconnect() - except Exception: - pass - if self.thread: - self.thread.join(timeout=5) - logger.info(f"MQTT client gestopt voor {self.device_id}") - - def _run(self): - """MQTT client main loop""" - broker = self._broker() - self.client.username_pw_set(broker.username, broker.password) - - ctx = ssl.create_default_context() - if broker.tls_insecure: - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - self.client.tls_set_context(ctx) - - self.client.on_connect = self._on_connect - self.client.on_message = self._on_message - self.client.on_disconnect = self._on_disconnect - - retry_count = 0 - max_retries = 10 - - while not self.stop_evt.is_set() and retry_count < max_retries: - try: - self.client.connect(broker.host, broker.port, keepalive=60) - self.client.loop_start() - retry_count = 0 # Reset bij succesvolle connect - - while not self.stop_evt.is_set(): - time.sleep(1.0) - break - except Exception as e: - retry_count += 1 - logger.error(f"MQTT connect fout {self.device_id} (poging {retry_count}): {e}") - time.sleep(min(30, 3 * retry_count)) - finally: - self.client.loop_stop() - - if retry_count >= max_retries: - logger.error(f"MQTT client opgegeven na {max_retries} pogingen: {self.device_id}") - - def _next_seq(self) -> str: - """Genereer uniek sequence ID""" - with self.seq_lock: - self.seq += 1 - return str(self.seq) - - def _publish(self, cmd: dict, qos: int = 1): - """Publiceer command naar printer""" - if not self.connected.is_set(): - raise RuntimeError("MQTT niet verbonden") - topic = f"device/{self.device_id}/request" - payload = json.dumps(cmd, separators=(",", ":")) - self.client.publish(topic, payload, qos=qos) - - def _register_waiter(self, seq: str, typ: str, cmd: str) -> threading.Event: - """Registreer ACK waiter""" - ev = threading.Event() - with self._pending_lock: - self._pending[seq] = (ev, typ, cmd) - return ev - - def _wait_for_response(self, seq: str, timeout: float = 3.0) -> Optional[dict]: - """Wacht op ACK response""" - with self._pending_lock: - tup = self._pending.get(seq) - if not tup: - return None - - ev, _, _ = tup - if not ev.wait(timeout): - with self._pending_lock: - self._pending.pop(seq, None) - return None - - with self._pending_lock: - resp = self._responses.pop(seq, None) - self._pending.pop(seq, None) - return resp - - def _request_with_ack(self, typ: str, body: dict, qos: int = 1, timeout: float = 3.0) -> Optional[dict]: - """Stuur command en wacht op ACK""" - seq = self._next_seq() - body = dict(body or {}) - body["sequence_id"] = seq - cmd = str(body.get("command", "")) - waiter = self._register_waiter(seq, typ, cmd) - - # Double publish voor betrouwbaarheid - self._publish({typ: body}, qos=qos) - time.sleep(0.08) - self._publish({typ: body}, qos=qos) - - return self._wait_for_response(seq, timeout=timeout) - - def send_pushall(self): - """Vraag volledige status update""" - cmd = { - "pushing": { - "sequence_id": self._next_seq(), - "command": "pushall", - "version": 1, - "push_target": 1 - } - } - self._publish(cmd, qos=0) - - def pause(self): - """Pauzeer print""" - self._publish({ - "print": { - "sequence_id": self._next_seq(), - "command": "pause", - "param": "" - } - }, qos=1) - - def resume(self): - """Hervat print""" - self._publish({ - "print": { - "sequence_id": self._next_seq(), - "command": "resume", - "param": "" - } - }, qos=1) - - def stop_print(self): - """Stop print""" - self._publish({ - "print": { - "sequence_id": self._next_seq(), - "command": "stop", - "param": "" - } - }, qos=1) - - def set_led(self, node: str = "chamber_light", mode: str = "on", timeout: float = 3.0) -> Optional[dict]: - """Schakel LED (met ACK)""" - mode = str(mode).lower() - if mode not in ("on", "off", "flashing"): - mode = "on" - - body = { - "command": "ledctrl", - "led_node": node, - "led_mode": mode, - "led_on_time": 500, - "led_off_time": 500, - "loop_times": 1, - "interval_time": 1000 - } - return self._request_with_ack("system", body, qos=1, timeout=timeout) - - def ams_filament_setting(self, ams_id: int, tray_id: int, tray_type: str, tray_color_hex: str): - """Stel AMS tray in""" - hex_clean = tray_color_hex.strip().lstrip("#") - if len(hex_clean) == 6: - hex_clean += "FF" - elif len(hex_clean) != 8: - hex_clean = "000000FF" - - mats = { - "PLA": (190, 240), - "ASA": (240, 270), - "ASA-CF": (250, 280), - "ABS": (230, 260), - "PETG": (220, 250) - } - tmin, tmax = mats.get(tray_type.upper(), (190, 260)) - - cmd = { - "print": { - "sequence_id": self._next_seq(), - "command": "ams_filament_setting", - "ams_id": int(ams_id), - "tray_id": int(tray_id), - "tray_info_idx": "", - "tray_color": hex_clean.upper(), - "nozzle_temp_min": int(tmin), - "nozzle_temp_max": int(tmax), - "tray_type": tray_type - } - } - self._publish(cmd, qos=1) - -# ========== MANAGER ========== -class Manager: - """Centrale manager voor alle printers en status tracking""" - - def __init__(self): - self.clients: Dict[str, PrinterClient] = {} - self.clients_lock = threading.Lock() - self.job_active: Dict[str, bool] = {} - self.ams_state: Dict[str, Dict[int, Dict[str, Any]]] = {} - self.ams_lock = threading.Lock() - self._light_locks: Dict[str, threading.Lock] = {} - self._light_locks_guard = threading.Lock() - - def _get_light_lock(self, device_id: str) -> threading.Lock: - """Thread-safe light command lock per device""" - with self._light_locks_guard: - lk = self._light_locks.get(device_id) - if lk is None: - lk = threading.Lock() - self._light_locks[device_id] = lk - return lk - - def start_for_all(self): - """Start MQTT clients voor alle printers""" - with db_manager.get_connection() as conn: - cur = conn.cursor() - rows = cur.execute("SELECT * FROM printers").fetchall() - - for row in rows: - p = dict(row) - p["autoprint"] = bool(p.get("autoprint", 1)) - p["auto_ams_black_asacf"] = bool(p.get("auto_ams_black_asacf", 1)) - p["tags"] = normalize_tags(p.get("tags", "[]")) - try: - self.ensure_client(p["device_id"], p) - except Exception as e: - logger.error(f"Fout bij starten client {p['device_id']}: {e}") - - def ensure_client(self, device_id: str, cfg: Optional[dict] = None): - """Zorg dat MQTT client actief is voor printer""" - with self.clients_lock: - if device_id in self.clients: - if cfg: - self.clients[device_id].cfg = cfg - return - - if not cfg: - with db_manager.get_connection() as conn: - cur = conn.cursor() - row = cur.execute("SELECT * FROM printers WHERE device_id=?", (device_id,)).fetchone() - if not row: - raise HTTPException(404, "Printer niet gevonden") - cfg = dict(row) - cfg["autoprint"] = bool(cfg.get("autoprint", 1)) - cfg["auto_ams_black_asacf"] = bool(cfg.get("auto_ams_black_asacf", 1)) - cfg["tags"] = normalize_tags(cfg.get("tags", "[]")) - - cli = PrinterClient(cfg, self._on_report) - self.clients[device_id] = cli - cli.start() - - def drop_client(self, device_id: str): - """Stop en verwijder MQTT client""" - with self.clients_lock: - cli = self.clients.pop(device_id, None) - if cli: - try: - cli.stop() - except Exception as e: - logger.error(f"Fout bij stoppen client {device_id}: {e}") - - def _tray_has_filament(self, tr: dict) -> bool: - """Detecteer of tray filament bevat""" - if not isinstance(tr, dict): - return False - - ttype = str(tr.get("tray_type") or "").strip().upper() - if ttype and ttype not in ("", "N/A", "NA", "NONE"): - return True - - if str(tr.get("tag_uid") or "").strip(): - return True - - try: - if float(tr.get("remain", 0) or 0) > 0: - return True - except (ValueError, TypeError): - pass - - return False - - def note_manual_ams(self, device_id: str, tray_id: int): - """Markeer handmatige AMS wijziging""" - now = now_ts() - with self.ams_lock: - d = self.ams_state.setdefault(device_id, {}) - rec = d.setdefault(int(tray_id), { - "had": None, - "last_manual": 0.0, - "manual_lock": False, - "empty_since": 0.0, - "_lock_seen": 0.0, - "emptied_after_manual": False - }) - rec["last_manual"] = now - rec["manual_lock"] = True - rec["emptied_after_manual"] = False - rec["_lock_seen"] = now - logger.debug(f"Handmatige AMS wijziging: {device_id} tray {tray_id}") - - def _on_report(self, device_id: str, payload: dict): - """Verwerk MQTT report van printer""" - try: - # Sla state op - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute(""" - INSERT INTO states (device_id, payload, updated_at) - VALUES (?, ?, datetime('now')) - ON CONFLICT(device_id) DO UPDATE SET - payload=excluded.payload, - updated_at=datetime('now') - """, (device_id, json.dumps(payload))) - - # Verwerk events (job start/finish/fail) - self._process_events(device_id, payload) - - # Verwerk alerts (HMS + print_error) - self._process_alerts(device_id, payload) - - # Auto AMS label (zwart ASA-CF) - self._process_auto_ams(device_id, payload) - - # Clear override als status niet meer FAILED is - self._check_clear_override(device_id, payload) - - except Exception as e: - logger.error(f"Fout bij verwerken report {device_id}: {e}", exc_info=True) - - def _process_events(self, device_id: str, payload: dict): - """Detecteer en log job events""" - try: - p = payload.get("print", payload) - gcode_state = str(p.get("gcode_state", "IDLE")).upper() - - # Job start detectie - if gcode_state in ("RUNNING", "PRINTING"): - if not self.job_active.get(device_id): - self.job_active[device_id] = True - file_name = p.get("subtask_name") or p.get("gcode_file") or "unknown" - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute( - "INSERT INTO events (device_id, ts, type, meta) VALUES (?, datetime('now'), ?, ?)", - (device_id, "job_start", json.dumps({"file": file_name})) - ) - logger.info(f"Job gestart: {device_id} - {file_name}") - - # Job finish detectie - elif gcode_state in ("FINISH", "FINISHED", "SUCCESS"): - if self.job_active.get(device_id): - self.job_active[device_id] = False - file_name = p.get("subtask_name") or p.get("gcode_file") or "unknown" - filament_g = float(p.get("gcode_weight", 0) or 0) - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute( - "INSERT INTO events (device_id, ts, type, meta) VALUES (?, datetime('now'), ?, ?)", - (device_id, "job_finish", json.dumps({"file": file_name, "filament_g": filament_g})) - ) - logger.info(f"Job voltooid: {device_id} - {file_name}") - - # Job fail detectie - elif gcode_state in ("FAILED", "FAIL"): - if self.job_active.get(device_id): - self.job_active[device_id] = False - file_name = p.get("subtask_name") or p.get("gcode_file") or "unknown" - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute( - "INSERT INTO events (device_id, ts, type, meta) VALUES (?, datetime('now'), ?, ?)", - (device_id, "job_fail", json.dumps({"file": file_name})) - ) - logger.warning(f"Job mislukt: {device_id} - {file_name}") - - except Exception as e: - logger.error(f"Fout bij verwerken events {device_id}: {e}") - - def _process_alerts(self, device_id: str, payload: dict): - """Verwerk HMS alerts en print errors""" - try: - p = payload.get("print", payload) - - # HMS alerts - hms_val = p.get("hms") - if hms_val: - self._sync_hms_alerts(device_id, hms_val) - - # Print error - print_error = p.get("print_error") - if print_error is not None: - self._sync_print_error(device_id, print_error) - - except Exception as e: - logger.error(f"Fout bij verwerken alerts {device_id}: {e}") - - def _sync_hms_alerts(self, device_id: str, hms_val: Any): - """Synchroniseer HMS alerts naar database""" - # Implementatie zoals in originele code (ingekort voor lengte) - pass - - def _sync_print_error(self, device_id: str, print_error_val: Any): - """Synchroniseer print error alert""" - # Implementatie zoals in originele code (ingekort voor lengte) - pass - - def _process_auto_ams(self, device_id: str, payload: dict): - """Auto-label zwart ASA-CF bij vullen tray""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - row = cur.execute("SELECT auto_ams_black_asacf FROM printers WHERE device_id=?", (device_id,)).fetchone() - - if not row or not bool(row["auto_ams_black_asacf"]): - return - - cli = self.clients.get(device_id) - if not cli: - return - - pr_block = payload.get("print", payload) - ams_block = pr_block.get("ams") or {} - units = ams_block.get("ams") if isinstance(ams_block, dict) else [] - - if not (isinstance(units, list) and units): - return - - now = now_ts() - - with self.ams_lock: - dev_state = self.ams_state.setdefault(device_id, {}) - - for unit in units: - trays = unit.get("tray") or [] - if not isinstance(trays, list): - continue - - for tray in trays: - if "id" not in tray: - continue - - try: - tray_id = int(tray.get("id", 0)) - except (ValueError, TypeError): - continue - - rec = dev_state.setdefault(tray_id, { - "had": None, - "last_manual": 0.0, - "manual_lock": False, - "empty_since": 0.0, - "_lock_seen": 0.0, - "emptied_after_manual": False - }) - - # Check handmatige lock - last_manual = float(rec.get("last_manual", 0.0)) - lock_seen = float(rec.get("_lock_seen", 0.0)) - - if last_manual > lock_seen: - rec["manual_lock"] = True - rec["emptied_after_manual"] = False - rec["_lock_seen"] = last_manual - - # Huidige status - had_now = self._tray_has_filament(tray) - prev_had = rec.get("had") - - # Update lege timer - if not had_now: - if not rec.get("empty_since"): - rec["empty_since"] = now - - empty_duration = now - float(rec.get("empty_since", 0.0)) - if empty_duration >= STABLE_EMPTY_SEC: - rec["manual_lock"] = False - rec["emptied_after_manual"] = True - else: - rec["empty_since"] = 0.0 - - rec["had"] = bool(had_now) - - # Auto-label condities - if prev_had is None: - continue - if not (prev_had is False and had_now is True): - continue - if rec.get("manual_lock", False): - continue - if not rec.get("emptied_after_manual", True): - continue - - # Check of al correct - current_type = str(tray.get("tray_type") or "").strip().upper() - current_color = hex_rgba_to_css(tray.get("tray_color", "000000FF")).upper() - - if current_type == "ASA-CF" and current_color == "#000000": - continue - - # Voer auto-label uit - try: - ams_id = 0 - try: - ams_id = int(unit.get("id", 0)) - except (ValueError, TypeError): - pass - - cli.ams_filament_setting( - ams_id=ams_id, - tray_id=tray_id, - tray_type="ASA-CF", - tray_color_hex="000000" - ) - logger.info(f"Auto-label uitgevoerd: {device_id} tray {tray_id} -> ASA-CF zwart") - - except Exception as e: - logger.debug(f"Auto-label fout {device_id} tray {tray_id}: {e}") - - except Exception as e: - logger.error(f"Fout bij auto AMS verwerking {device_id}: {e}") - - def _check_clear_override(self, device_id: str, payload: dict): - """Clear status override als printer niet meer FAILED is""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - row = cur.execute("SELECT status FROM state_overrides WHERE device_id=?", (device_id,)).fetchone() - - if not row or row["status"] != "IDLE": - return - - p = payload.get("print", payload) - gcode_state = str(p.get("gcode_state", "IDLE")).upper() - - if gcode_state not in ("FAILED", "FAIL"): - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute("DELETE FROM state_overrides WHERE device_id=?", (device_id,)) - logger.info(f"Status override gewist voor {device_id}") - - except Exception as e: - logger.error(f"Fout bij check clear override {device_id}: {e}") - - def ui_printer_list(self) -> List[dict]: - """Genereer printer lijst voor UI""" - out = [] - - with db_manager.get_connection() as conn: - cur = conn.cursor() - printers = cur.execute("SELECT * FROM printers ORDER BY name").fetchall() - - for p_row in printers: - p = dict(p_row) - device_id = p["device_id"] - - # Haal state op - with db_manager.get_connection() as conn: - cur = conn.cursor() - st_row = cur.execute("SELECT payload, updated_at FROM states WHERE device_id=?", (device_id,)).fetchone() - - payload = None - updated_at = None - if st_row: - try: - payload = json.loads(st_row["payload"]) - except Exception: - pass - updated_at = st_row["updated_at"] - - # Parse status - status, prg, rem_mins, file_name, nozzle, bed, ams = self._parse_status(payload) - light = self._payload_chamber_light_on(payload) or False - - # Check override - with db_manager.get_connection() as conn: - cur = conn.cursor() - ov_row = cur.execute("SELECT status FROM state_overrides WHERE device_id=?", (device_id,)).fetchone() - - if ov_row: - ov_status = ov_row["status"].upper() - if ov_status in ("IDLE", "RUNNING", "PAUSE", "FINISH", "FAILED"): - status = ov_status - if ov_status == "IDLE": - prg = 0.0 - rem_mins = 0 - file_name = "-" - - # Check stale - stale = True - if updated_at: - epoch = to_epoch(updated_at) - if epoch and (now_ts() - epoch <= STATE_STALE_SECONDS): - stale = False - - # Check connected - cli = self.clients.get(device_id) - is_conn = (cli.connected.is_set() if cli else False) or (not stale) - - if not is_conn: - status = "NO_CONN" - prg = 0.0 - rem_mins = 0 - file_name = "-" - nozzle = 0.0 - bed = 0.0 - light = False - - out.append({ - "device_id": device_id, - "name": p["name"], - "model": p["model"] or "X1 Carbon", - "status": status, - "progress": prg, - "remaining_time": rem_mins, - "file": file_name or "-", - "nozzle_temp": nozzle, - "bed_temp": bed, - "ams": ams, - "autoprint": bool(p.get("autoprint", 1)), - "tags": normalize_tags(p.get("tags", "[]")), - "light_on": bool(light), - "chamber_light": bool(light), - "lights": {"chamber": bool(light)} - }) - - return out - - def _parse_status(self, payload: Optional[dict]) -> Tuple[str, float, int, str, float, float, Optional[dict]]: - """Parse status uit payload""" - if not payload: - return ("IDLE", 0.0, 0, "-", 0.0, 0.0, None) - - p = payload.get("print", {}) if "print" in payload else payload - gcode_state = str(p.get("gcode_state", "IDLE")).upper() - - if gcode_state in ("RUNNING", "PRINTING"): - status = "RUNNING" - elif gcode_state in ("PAUSE", "PAUSED"): - status = "PAUSE" - elif gcode_state in ("FINISH", "FINISHED", "SUCCESS"): - status = "FINISH" - elif gcode_state in ("FAILED", "FAIL"): - status = "FAILED" - else: - status = "IDLE" - - progress = float(p.get("mc_percent", 0) or 0.0) - rem = int(p.get("mc_remaining_time", 0) or 0) - if rem > 24 * 60 * 3: - rem //= 60 - - file_name = p.get("subtask_name") or p.get("gcode_file") or "-" - nozzle = float(p.get("nozzle_temper", 0) or 0.0) - bed = float(p.get("bed_temper", 0) or 0.0) - - ams_dict = None - ams_block = p.get("ams", {}) or {} - if ams_block: - trays = [] - try: - for ams_unit in ams_block.get("ams", []): - ams_id = int(ams_unit.get("id", 0)) - for t in ams_unit.get("tray", []): - if "id" not in t: - continue - tid = int(t.get("id", 0)) - empty = not self._tray_has_filament(t) - ttype = (t.get("tray_type") or "N/A") - color_hex = hex_rgba_to_css(t.get("tray_color", "000000FF")) - trays.append({ - "id": tid, - "type": "-" if empty else ttype, - "color": color_hex, - "empty": bool(empty), - "ams_id": ams_id - }) - except Exception: - pass - if trays: - ams_dict = {"trays": trays} - - return (status, progress, rem, file_name, nozzle, bed, ams_dict) - - def _payload_chamber_light_on(self, payload: Optional[dict]) -> Optional[bool]: - """Extract chamber light status uit payload""" - if not payload: - return None - p = payload.get("print", payload) - lights = p.get("lights_report") - if isinstance(lights, list): - for it in lights: - try: - if str(it.get("node", "")).lower() == "chamber_light": - return str(it.get("mode", "")).lower() != "off" - except Exception: - pass - return None - - def cleanup(self): - """Cleanup alle clients en resources""" - logger.info("Manager cleanup gestart") - with self.clients_lock: - device_ids = list(self.clients.keys()) - - for device_id in device_ids: - try: - self.drop_client(device_id) - except Exception as e: - logger.error(f"Fout bij cleanup client {device_id}: {e}") - -manager = Manager() - -# ========== FASTAPI APP ========== -app = FastAPI( - title="Bambu PrintFarm Backend", - version="2.0.0", - description="Productie-klare backend voor Bambu Lab 3D-printer beheer" -) - -# CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"] -) - -# Global exception handler -@app.exception_handler(Exception) -async def global_exception_handler(request: Request, exc: Exception): - logger.error(f"Onverwachte fout: {exc}", exc_info=True) - return JSONResponse( - status_code=500, - content={"detail": "Interne serverfout"} - ) - -# Health check endpoint -@app.get("/health") -def health_check(): - return { - "status": "healthy", - "version": "2.0.0", - "timestamp": ts_iso() - } - -# ========== PYDANTIC MODELS ========== -class PrinterIn(BaseModel): - device_id: str = Field(..., min_length=3, max_length=100) - name: str = Field(..., min_length=1, max_length=200) - model: str = Field(default="X1 Carbon", max_length=100) - ip: Optional[str] = Field(None, max_length=45) - lan_access_code: Optional[str] = Field(None, max_length=100) - cloud_user_id: Optional[str] = Field(None, max_length=100) - cloud_access_token: Optional[str] = Field(None, max_length=500) - autoprint: bool = True - tags: List[str] = Field(default_factory=list) - - @validator('tags') - def validate_tags(cls, v): - return normalize_tags(v) - - @validator('device_id', 'name') - def no_special_chars(cls, v): - if not re.match(r'^[a-zA-Z0-9_\-\s]+$', v): - raise ValueError('Alleen alfanumerieke tekens, spaties, - en _ toegestaan') - return v - -class AutoPrintToggle(BaseModel): - autoprint: bool - -class AmsAutoLabelToggle(BaseModel): - enabled: bool - -class AmsSettingIn(BaseModel): - ams_id: int = Field(default=0, ge=0, le=3) - tray_id: int = Field(..., ge=0, le=3) - tray_type: str = Field(..., max_length=50) - tray_color: Optional[str] = Field(default="#000000", max_length=9) - - @validator('tray_color') - def validate_color(cls, v): - if not re.match(r'^#?[0-9A-Fa-f]{6}$', v): - raise ValueError('Kleur moet hex format zijn (#RRGGBB)') - return v - -# ========== CONFIG ENDPOINTS ========== -@app.get("/api/config/printers") -def cfg_list(): - """Lijst alle printers""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - rows = cur.execute("SELECT * FROM printers ORDER BY name").fetchall() - - printers = [] - for row in rows: - p = dict(row) - p["autoprint"] = bool(p.get("autoprint", 1)) - p["auto_ams_black_asacf"] = bool(p.get("auto_ams_black_asacf", 1)) - p["tags"] = normalize_tags(p.get("tags", "[]")) - printers.append(p) - - return printers - except Exception as e: - logger.error(f"Fout bij ophalen printers: {e}") - raise HTTPException(500, "Kon printers niet ophalen") - -@app.post("/api/config/printers") -def cfg_add(p: PrinterIn): - """Voeg nieuwe printer toe""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - existing = cur.execute("SELECT device_id FROM printers WHERE device_id=?", (p.device_id,)).fetchone() - - if existing: - raise HTTPException(409, "Printer met dit device ID bestaat al") - - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute(""" - INSERT INTO printers (device_id, name, model, ip, lan_access_code, - cloud_user_id, cloud_access_token, autoprint, tags, auto_ams_black_asacf) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) - """, (p.device_id, p.name, p.model, p.ip, p.lan_access_code, - p.cloud_user_id, p.cloud_access_token, 1 if p.autoprint else 0, - json.dumps(p.tags))) - - cfg = p.dict() - cfg["auto_ams_black_asacf"] = True - manager.ensure_client(p.device_id, cfg) - - logger.info(f"Printer toegevoegd: {p.device_id} ({p.name})") - return {"ok": True, "device_id": p.device_id} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij toevoegen printer: {e}", exc_info=True) - raise HTTPException(500, "Kon printer niet toevoegen") - -@app.put("/api/config/printers/{device_id}") -def cfg_update(device_id: str, p: PrinterIn): - """Update bestaande printer""" - if device_id != p.device_id: - raise HTTPException(400, "Device ID kan niet gewijzigd worden") - - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - existing = cur.execute("SELECT auto_ams_black_asacf FROM printers WHERE device_id=?", (device_id,)).fetchone() - - if not existing: - raise HTTPException(404, "Printer niet gevonden") - - preserve_flag = bool(existing["auto_ams_black_asacf"]) - - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute(""" - UPDATE printers - SET name=?, model=?, ip=?, lan_access_code=?, cloud_user_id=?, - cloud_access_token=?, autoprint=?, tags=?, updated_at=datetime('now') - WHERE device_id=? - """, (p.name, p.model, p.ip, p.lan_access_code, p.cloud_user_id, - p.cloud_access_token, 1 if p.autoprint else 0, - json.dumps(p.tags), device_id)) - - manager.drop_client(device_id) - cfg = p.dict() - cfg["auto_ams_black_asacf"] = preserve_flag - manager.ensure_client(device_id, cfg) - - logger.info(f"Printer geüpdatet: {device_id}") - return {"ok": True} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij updaten printer: {e}", exc_info=True) - raise HTTPException(500, "Kon printer niet updaten") - -@app.patch("/api/config/printers/{device_id}/autoprint") -def cfg_toggle_autoprint(device_id: str, body: AutoPrintToggle): - """Toggle autoprint voor printer""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - result = cur.execute( - "UPDATE printers SET autoprint=? WHERE device_id=?", - (1 if body.autoprint else 0, device_id) - ) - if result.rowcount == 0: - raise HTTPException(404, "Printer niet gevonden") - - logger.info(f"Autoprint {'aan' if body.autoprint else 'uit'}: {device_id}") - return {"ok": True, "autoprint": body.autoprint} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij toggle autoprint: {e}") - raise HTTPException(500, "Kon autoprint niet wijzigen") - -@app.patch("/api/config/printers/{device_id}/auto_ams_black_asacf") -def cfg_toggle_auto_ams(device_id: str, body: AmsAutoLabelToggle): - """Toggle auto AMS zwart ASA-CF""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - result = cur.execute( - "UPDATE printers SET auto_ams_black_asacf=? WHERE device_id=?", - (1 if body.enabled else 0, device_id) - ) - if result.rowcount == 0: - raise HTTPException(404, "Printer niet gevonden") - - logger.info(f"Auto AMS label {'aan' if body.enabled else 'uit'}: {device_id}") - return {"ok": True, "auto_ams_black_asacf": body.enabled} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij toggle auto AMS: {e}") - raise HTTPException(500, "Kon auto AMS niet wijzigen") - -@app.delete("/api/config/printers/{device_id}") -def cfg_delete(device_id: str): - """Verwijder printer""" - try: - manager.drop_client(device_id) - - try: - camera.stop_hls(device_id) - camera.stop_snapshot(device_id) - d = STREAMS_DIR / device_id - if d.exists(): - shutil.rmtree(d, ignore_errors=True) - except Exception as e: - logger.warning(f"Camera cleanup fout voor {device_id}: {e}") - - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute("DELETE FROM printers WHERE device_id=?", (device_id,)) - - logger.info(f"Printer verwijderd: {device_id}") - return {"ok": True} - - except Exception as e: - logger.error(f"Fout bij verwijderen printer: {e}", exc_info=True) - raise HTTPException(500, "Kon printer niet verwijderen") - -# ========== PRINTER CONTROL ENDPOINTS ========== -@app.get("/api/printers") -def api_printers(): - """Lijst alle printers met status""" - try: - return manager.ui_printer_list() - except Exception as e: - logger.error(f"Fout bij ophalen printer lijst: {e}") - raise HTTPException(500, "Kon printer lijst niet ophalen") - -@app.get("/api/stats") -def api_stats(range: str = Query("day", regex="^(day|week|month|total)$")): - """Statistieken over periode""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - printers = cur.execute("SELECT COUNT(*) AS c FROM printers").fetchone()["c"] - - params: List[Any] = [] - if range == "day": - where = "ts >= ?" - try: - from zoneinfo import ZoneInfo - tz = ZoneInfo(LOCAL_TZ) - except Exception: - tz = timezone.utc - now_local = datetime.now(tz) - start_local = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=tz) - start_utc = start_local.astimezone(timezone.utc) - params = [start_utc.strftime("%Y-%m-%d %H:%M:%S")] - elif range == "week": - where = "ts >= datetime('now','-7 day')" - elif range == "month": - where = "ts >= datetime('now','-30 day')" - else: - where = "1=1" - - fails = cur.execute(f"SELECT COUNT(*) AS c FROM events WHERE type='job_fail' AND {where}", params).fetchone()["c"] - finishes = cur.execute(f"SELECT COUNT(*) AS c FROM events WHERE type='job_finish' AND {where}", params).fetchone()["c"] - rows = cur.execute(f"SELECT meta FROM events WHERE type='job_finish' AND {where}", params).fetchall() - - filament_g = 0.0 - for r in rows: - try: - m = json.loads(r["meta"]) - filament_g += float(m.get("filament_g", 0.0)) - except Exception: - pass - - spools = filament_g / 1000.0 / 0.75 if filament_g > 0 else 0.0 - succ_rate = (finishes / (finishes + fails) * 100.0) if (finishes + fails) > 0 else None - - running = sum(1 for p in manager.ui_printer_list() if p["status"] == "RUNNING") - - return { - "total": printers, - "running": running, - "fails": fails, - "filament_kg": filament_g / 1000.0, - "spools": spools, - "success_rate": succ_rate - } - - except Exception as e: - logger.error(f"Fout bij ophalen stats: {e}") - raise HTTPException(500, "Kon statistieken niet ophalen") - -@app.post("/api/printers/{device_id}/pause_resume") -def api_pause_resume(device_id: str): - """Pauzeer of hervat print""" - try: - manager.ensure_client(device_id) - cli = manager.clients.get(device_id) - if not cli: - raise HTTPException(500, "MQTT client niet beschikbaar") - - with db_manager.get_connection() as conn: - cur = conn.cursor() - st_row = cur.execute("SELECT payload FROM states WHERE device_id=?", (device_id,)).fetchone() - - payload = None - if st_row: - try: - payload = json.loads(st_row["payload"]) - except Exception: - pass - - status = manager._parse_status(payload)[0] - - if status == "PAUSE": - cli.resume() - logger.info(f"Print hervat: {device_id}") - elif status == "RUNNING": - cli.pause() - logger.info(f"Print gepauzeerd: {device_id}") - else: - raise HTTPException(400, f"Kan niet pauzeren/hervatten in status {status}") - - return {"ok": True, "action": "resume" if status == "PAUSE" else "pause"} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij pause/resume {device_id}: {e}") - raise HTTPException(500, "Kon print niet pauzeren/hervatten") - -@app.post("/api/printers/{device_id}/stop") -def api_stop(device_id: str): - """Stop print""" - try: - manager.ensure_client(device_id) - cli = manager.clients.get(device_id) - if not cli: - raise HTTPException(500, "MQTT client niet beschikbaar") - - with db_manager.get_connection() as conn: - cur = conn.cursor() - st_row = cur.execute("SELECT payload FROM states WHERE device_id=?", (device_id,)).fetchone() - - payload = None - if st_row: - try: - payload = json.loads(st_row["payload"]) - except Exception: - pass - - cur_status = manager._parse_status(payload)[0] - - try: - cli.stop_print() - except Exception as e: - if cur_status == "FAILED": - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute(""" - INSERT INTO state_overrides (device_id, status, note, created_at) - VALUES (?, 'IDLE', 'manual-stop-from-failed-error', datetime('now')) - ON CONFLICT(device_id) DO UPDATE SET - status='IDLE', - note='manual-stop-from-failed-error', - created_at=datetime('now') - """, (device_id,)) - raise HTTPException(500, f"Stop commando mislukt: {e}") - - forced = False - if cur_status == "FAILED": - with db_manager.get_connection() as conn: - cur = conn.cursor() - cur.execute(""" - INSERT INTO state_overrides (device_id, status, note, created_at) - VALUES (?, 'IDLE', 'manual-stop-from-failed', datetime('now')) - ON CONFLICT(device_id) DO UPDATE SET - status='IDLE', - note='manual-stop-from-failed', - created_at=datetime('now') - """, (device_id,)) - forced = True - - logger.info(f"Print gestopt: {device_id} (forced_idle={forced})") - return {"ok": True, "forced_idle": forced} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij stop {device_id}: {e}") - raise HTTPException(500, "Kon print niet stoppen") - -@app.post("/api/printers/{device_id}/light/{mode}") -def api_light_set(device_id: str, mode: str): - """Stel kamer licht in""" - mode = str(mode).lower() - if mode not in ("on", "off", "flashing"): - raise HTTPException(400, "Mode moet 'on', 'off' of 'flashing' zijn") - - try: - manager.ensure_client(device_id) - cli = manager.clients.get(device_id) - if not cli: - raise HTTPException(500, "MQTT client niet beschikbaar") - - lk = manager._get_light_lock(device_id) - with lk: - try: - ack = cli.set_led("chamber_light", mode, timeout=1.5) - except Exception as e: - raise HTTPException(500, f"Licht schakelen mislukt: {e}") - - if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): - try: - ack = cli.set_led("chamber_light", mode, timeout=1.0) - except Exception: - raise HTTPException(504, "Geen bevestiging van printer") - if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): - raise HTTPException(504, "Geen bevestiging van printer") - - logger.info(f"Licht geschakeld {mode}: {device_id}") - return {"ok": True, "mode": mode} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij light set {device_id}: {e}") - raise HTTPException(500, "Kon licht niet schakelen") - -@app.post("/api/printers/{device_id}/light/toggle") -def api_light_toggle(device_id: str): - """Toggle kamer licht""" - try: - manager.ensure_client(device_id) - cli = manager.clients.get(device_id) - if not cli: - raise HTTPException(500, "MQTT client niet beschikbaar") - - lk = manager._get_light_lock(device_id) - with lk: - with db_manager.get_connection() as conn: - cur = conn.cursor() - st_row = cur.execute("SELECT payload FROM states WHERE device_id=?", (device_id,)).fetchone() - - cur_on_opt = None - if st_row: - try: - payload = json.loads(st_row["payload"]) - cur_on_opt = manager._payload_chamber_light_on(payload) - except Exception: - pass - - target = "off" if cur_on_opt is True else "on" - - try: - ack = cli.set_led("chamber_light", target, timeout=1.5) - except Exception as e: - raise HTTPException(500, f"Lamp toggle mislukt: {e}") - - if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): - try: - ack = cli.set_led("chamber_light", target, timeout=1.0) - except Exception: - raise HTTPException(504, "Geen bevestiging van printer") - if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): - raise HTTPException(504, "Geen bevestiging van printer") - - logger.info(f"Licht getoggled naar {target}: {device_id}") - return {"ok": True, "mode": target} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij light toggle {device_id}: {e}") - raise HTTPException(500, "Kon licht niet togglen") - -# ========== AMS ENDPOINTS ========== -@app.post("/api/printers/{device_id}/ams/filament_setting") -def api_ams_set(device_id: str, body: AmsSettingIn): - """Stel AMS tray in""" - try: - manager.ensure_client(device_id) - cli = manager.clients.get(device_id) - if not cli: - raise HTTPException(500, "MQTT client niet beschikbaar") - - cli.ams_filament_setting(body.ams_id, body.tray_id, body.tray_type, body.tray_color or "#000000") - manager.note_manual_ams(device_id, int(body.tray_id)) - - logger.info(f"AMS tray ingesteld: {device_id} tray {body.tray_id} -> {body.tray_type}") - return {"ok": True} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij AMS set {device_id}: {e}") - raise HTTPException(500, "Kon AMS tray niet instellen") - -# ========== CAMERA ENDPOINTS ========== -@app.post("/api/printers/{device_id}/camera/start") -def api_camera_start(device_id: str): - """Start HLS camera stream""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - row = cur.execute("SELECT ip, lan_access_code FROM printers WHERE device_id=?", (device_id,)).fetchone() - - if not row: - raise HTTPException(404, "Printer niet gevonden") - if not row["ip"] or not row["lan_access_code"]: - raise HTTPException(400, "Camera vereist lokaal IP + LAN access code") - - url = camera.start_hls(device_id, row["ip"], row["lan_access_code"]) - logger.info(f"Camera HLS gestart: {device_id}") - return {"hls_url": url} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij camera start {device_id}: {e}") - raise HTTPException(500, "Kon camera niet starten") - -@app.post("/api/printers/{device_id}/camera/stop") -def api_camera_stop(device_id: str): - """Stop HLS camera stream""" - try: - camera.stop_hls(device_id) - logger.info(f"Camera HLS gestopt: {device_id}") - return {"ok": True} - except Exception as e: - logger.error(f"Fout bij camera stop {device_id}: {e}") - raise HTTPException(500, "Kon camera niet stoppen") - -@app.post("/api/printers/{device_id}/snapshot/start") -def api_snapshot_start(device_id: str): - """Start snapshot stream""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - row = cur.execute("SELECT ip, lan_access_code FROM printers WHERE device_id=?", (device_id,)).fetchone() - - if not row: - raise HTTPException(404, "Printer niet gevonden") - if not row["ip"] or not row["lan_access_code"]: - raise HTTPException(400, "Snapshot vereist lokaal IP + LAN access code") - - url = camera.start_snapshot(device_id, row["ip"], row["lan_access_code"]) - logger.info(f"Snapshot stream gestart: {device_id}") - return {"snapshot_url": url} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij snapshot start {device_id}: {e}") - raise HTTPException(500, "Kon snapshot niet starten") - -@app.post("/api/printers/{device_id}/snapshot/stop") -def api_snapshot_stop(device_id: str): - """Stop snapshot stream""" - try: - camera.stop_snapshot(device_id) - logger.info(f"Snapshot stream gestopt: {device_id}") - return {"ok": True} - except Exception as e: - logger.error(f"Fout bij snapshot stop {device_id}: {e}") - raise HTTPException(500, "Kon snapshot niet stoppen") - -@app.get("/api/printers/{device_id}/snapshot.jpg") -def api_snapshot_file(device_id: str): - """Haal actuele snapshot op""" - try: - with db_manager.get_connection() as conn: - cur = conn.cursor() - row = cur.execute("SELECT ip, lan_access_code FROM printers WHERE device_id=?", (device_id,)).fetchone() - - if not row: - raise HTTPException(404, "Printer niet gevonden") - - p = camera.snapshot_path(device_id) - if not p.exists(): - try: - camera.capture_once(device_id, row["ip"], row["lan_access_code"]) - except HTTPException: - raise - except Exception as e: - raise HTTPException(503, f"Snapshot maken mislukt: {e}") - - if not p.exists(): - raise HTTPException(503, "Snapshot nog niet beschikbaar") - - return FileResponse( - path=str(p), - media_type="image/jpeg", - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0" - } - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Fout bij snapshot get {device_id}: {e}") - raise HTTPException(500, "Kon snapshot niet ophalen") - -# ========== ALERTS ENDPOINTS (ingekort) ========== -@app.get("/api/alerts") -def api_alerts( - device_id: Optional[str] = Query(None), - q: Optional[str] = Query(None), - page: int = Query(1, ge=1), - limit: int = Query(50, ge=1, le=500), - state: Optional[str] = Query("open", regex="^(open|closed|all)$")): - """Lijst alerts met filtering""" - # Implementatie zoals origineel (ingekort) - return {"items": [], "total": 0} - -@app.get("/api/alerts/summary") -def api_alerts_summary(device_id: Optional[str] = Query(None)): - """Alert samenvatting""" - # Implementatie zoals origineel (ingekort) - return {"open": 0, "today": 0, "unique_codes": 0} - -# ========== WEBSOCKET ========== -WS_CLIENTS: Set[WebSocket] = set() -ALERTS_QUEUE: "asyncio.Queue[dict]" = asyncio.Queue() - -@app.websocket("/ws/alerts") -async def ws_alerts(ws: WebSocket): - """WebSocket voor realtime alerts""" - await ws.accept() - WS_CLIENTS.add(ws) - try: - while True: - await ws.receive_text() - except WebSocketDisconnect: - pass - except Exception: - pass - finally: - WS_CLIENTS.discard(ws) - try: - await ws.close() - except Exception: - pass - -async def alerts_broadcaster(): - """Broadcast alerts naar alle WebSocket clients""" - while True: - msg = await ALERTS_QUEUE.get() - dead = [] - payload = json.dumps(msg, separators=(",", ":")) - for ws in list(WS_CLIENTS): - try: - await ws.send_text(payload) - except Exception: - dead.append(ws) - for ws in dead: - WS_CLIENTS.discard(ws) - try: - await ws.close() - except Exception: - pass - -# ========== LIFECYCLE EVENTS ========== -@app.on_event("startup") -async def startup_event(): - """Applicatie startup""" - logger.info("=== Bambu PrintFarm Backend Start ===") - logger.info(f"Versie: 2.0.0") - logger.info(f"Data directory: {DATA_DIR}") - logger.info(f"Database: {DB_PATH}") - - # Init database - try: - db_manager.init_schema() - except Exception as e: - logger.error(f"Database initialisatie mislukt: {e}", exc_info=True) - sys.exit(1) - - # Start HMS catalog - try: - hms.load_from_disk() - hms.start_background_refresh() - except Exception as e: - logger.error(f"HMS catalog start mislukt: {e}") - - # Start MQTT clients - try: - manager.start_for_all() - except Exception as e: - logger.error(f"Manager start mislukt: {e}", exc_info=True) - - # Start WebSocket broadcaster - app.state.alerts_task = asyncio.create_task(alerts_broadcaster()) - - logger.info("=== Backend succesvol gestart ===") - -@app.on_event("shutdown") -async def shutdown_event(): - """Applicatie shutdown""" - logger.info("=== Bambu PrintFarm Backend Shutdown ===") - - # Stop WebSocket broadcaster - task = getattr(app.state, "alerts_task", None) - if task: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - except Exception as e: - logger.error(f"Fout bij stoppen WebSocket broadcaster: {e}") - - # Stop HMS catalog - try: - hms.stop_background_refresh() - except Exception as e: - logger.error(f"Fout bij stoppen HMS catalog: {e}") - - # Stop manager en clients - try: - manager.cleanup() - except Exception as e: - logger.error(f"Fout bij manager cleanup: {e}") - - # Stop camera registry - try: - camera.cleanup_all() - except Exception as e: - logger.error(f"Fout bij camera cleanup: {e}") - - logger.info("=== Backend succesvol gestopt ===") - -# Mount static files (laatste, catch-all) -if WEB_DIR.exists(): - app.mount("/streams", StaticFiles(directory=str(STREAMS_DIR), html=False), name="streams") - app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") - -# ========== SIGNAL HANDLERS ========== -def signal_handler(signum, frame): - """Graceful shutdown op SIGTERM/SIGINT""" - logger.info(f"Signal {signum} ontvangen, shutdown wordt gestart...") - sys.exit(0) - -signal.signal(signal.SIGTERM, signal_handler) -signal.signal(signal.SIGINT, signal_handler) - -# ========== MAIN ========== -def main(): - """Main entry point""" - ensure_dirs() - - # Configuratie logging - log_level = os.environ.get("LOG_LEVEL", "INFO").upper() - logging.getLogger().setLevel(getattr(logging, log_level, logging.INFO)) - - logger.info(f"Starting Bambu PrintFarm Backend op {APP_HOST}:{APP_PORT}") - - # Uvicorn configuratie - uvicorn_config = { - "app": app, - "host": APP_HOST, - "port": APP_PORT, - "log_level": log_level.lower(), - "access_log": True, - "server_header": False, - "date_header": False, - "forwarded_allow_ips": "*", # Voor reverse proxy ondersteuning - "proxy_headers": True - } - - # Optioneel: SSL/TLS configuratie - ssl_cert = os.environ.get("SSL_CERT") - ssl_key = os.environ.get("SSL_KEY") - if ssl_cert and ssl_key: - if pathlib.Path(ssl_cert).exists() and pathlib.Path(ssl_key).exists(): - uvicorn_config["ssl_certfile"] = ssl_cert - uvicorn_config["ssl_keyfile"] = ssl_key - logger.info("SSL/TLS ingeschakeld") - else: - logger.warning("SSL certificaat bestanden niet gevonden, SSL uitgeschakeld") - - uvicorn.run(**uvicorn_config) - -if __name__ == "__main__": - main() \ No newline at end of file +#!/usr/bin/env python3 +""" +Bambu PrintFarm - Production Ready Backend +Version: 2.0.0 + +Een robuuste, productie-klare backend voor het beheren van meerdere Bambu Lab 3D-printers. +Ondersteunt MQTT-communicatie, AMS-beheer en realtime alerts. +""" + +import os +import json +import ssl +import subprocess +import threading +import time +import pathlib +import re +import asyncio +import mimetypes +import signal +import sys +from dataclasses import dataclass +from typing import Dict, Any, Optional, List, Tuple, Set +from datetime import datetime, timezone +from contextlib import contextmanager +import logging +from logging.handlers import RotatingFileHandler + +from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, validator +import sqlite3 +import uvicorn +import paho.mqtt.client as mqtt +import urllib.request + +# ========== CONFIGURATIE ========== +APP_HOST = os.environ.get("APP_HOST", "0.0.0.0") +APP_PORT = int(os.environ.get("APP_PORT", "8000")) +DATA_DIR = pathlib.Path(os.environ.get("DATA_DIR", "./data")).resolve() +DB_PATH = DATA_DIR / "printfarm.db" +WEB_DIR = pathlib.Path(os.environ.get("WEB_DIR", "./web")).resolve() +LOG_DIR = DATA_DIR / "logs" + +BASE_DIR = pathlib.Path(__file__).resolve().parent + +STATE_STALE_SECONDS = int(os.environ.get("STATE_STALE_SECONDS", "120")) + +HMS_JSON_URL = os.environ.get( + "HMS_JSON_URL", + "https://raw.githubusercontent.com/bambulab/BambuStudio/master/resources/hms/hms_en_094.json" +) +HMS_REFRESH_SEC = int(os.environ.get("HMS_REFRESH_SEC", "21600")) +STABLE_EMPTY_SEC = 5.0 +LOCAL_TZ = os.environ.get("LOCAL_TZ", "Europe/Amsterdam") + +# MIME types +mimetypes.add_type("application/vnd.apple.mpegurl", ".m3u8") +mimetypes.add_type("application/x-mpegURL", ".m3u8") +mimetypes.add_type("video/mp2t", ".ts") +mimetypes.add_type("image/jpeg", ".jpg") + +# ========== LOGGING SETUP ========== +def setup_logging(): + """Configureer gestructureerd logging met rotatie""" + LOG_DIR.mkdir(parents=True, exist_ok=True) + + # Root logger + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + # Console handler + console = logging.StreamHandler() + console.setLevel(logging.INFO) + console_fmt = logging.Formatter( + '%(asctime)s [%(levelname)s] %(name)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + console.setFormatter(console_fmt) + logger.addHandler(console) + + # File handler met rotatie (10MB per bestand, max 5 backups) + file_handler = RotatingFileHandler( + LOG_DIR / "printfarm.log", + maxBytes=10*1024*1024, + backupCount=5, + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + file_fmt = logging.Formatter( + '%(asctime)s [%(levelname)s] %(name)s [%(funcName)s:%(lineno)d]: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + file_handler.setFormatter(file_fmt) + logger.addHandler(file_handler) + + # Error log (alleen errors en hoger) + error_handler = RotatingFileHandler( + LOG_DIR / "errors.log", + maxBytes=10*1024*1024, + backupCount=3, + encoding='utf-8' + ) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(file_fmt) + logger.addHandler(error_handler) + + return logger + +logger = setup_logging() + +# ========== DATABASE ========== +class DatabaseManager: + """Thread-safe database manager met connection pooling""" + + def __init__(self, db_path: pathlib.Path): + self.db_path = db_path + self._local = threading.local() + + @contextmanager + def get_connection(self): + """Context manager voor database connecties""" + if not hasattr(self._local, 'conn') or self._local.conn is None: + self._local.conn = sqlite3.connect(str(self.db_path), timeout=30.0) + self._local.conn.row_factory = sqlite3.Row + self._local.conn.execute("PRAGMA journal_mode=WAL;") + self._local.conn.execute("PRAGMA synchronous=NORMAL;") + self._local.conn.execute("PRAGMA foreign_keys=ON;") + + try: + yield self._local.conn + self._local.conn.commit() + except Exception: + self._local.conn.rollback() + raise + + def init_schema(self): + """Initialiseer database schema met migraties""" + with self.get_connection() as conn: + cur = conn.cursor() + + # Printers tabel + cur.execute(""" + CREATE TABLE IF NOT EXISTS printers ( + device_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + model TEXT NOT NULL, + ip TEXT, + lan_access_code TEXT, + cloud_user_id TEXT, + cloud_access_token TEXT, + autoprint INTEGER NOT NULL DEFAULT 1, + tags TEXT NOT NULL DEFAULT '[]', + auto_ams_black_asacf INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + # States tabel + cur.execute(""" + CREATE TABLE IF NOT EXISTS states ( + device_id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE + ) + """) + + # Events tabel + cur.execute(""" + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + ts TEXT NOT NULL, + type TEXT NOT NULL, + meta TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_events_device_ts ON events(device_id, ts)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)") + + # Alerts tabel + cur.execute(""" + CREATE TABLE IF NOT EXISTS alerts ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + printer_name TEXT, + code TEXT, + message TEXT, + severity INTEGER NOT NULL, + state TEXT NOT NULL, + module TEXT, + count INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + last_seen TEXT NOT NULL, + resolved_at TEXT, + raw TEXT, + FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_device ON alerts(device_id)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_state ON alerts(state)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_alerts_code ON alerts(code)") + + # State overrides tabel + cur.execute(""" + CREATE TABLE IF NOT EXISTS state_overrides ( + device_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (device_id) REFERENCES printers(device_id) ON DELETE CASCADE + ) + """) + + conn.commit() + logger.info("Database schema geïnitialiseerd") + +db_manager = DatabaseManager(DB_PATH) + +# ========== UTILITY FUNCTIES ========== +def now_ts() -> float: + """Huidige timestamp in seconden""" + return time.time() + +def ts_iso() -> str: + """Huidige timestamp in ISO format (UTC)""" + return datetime.utcnow().replace(microsecond=0).isoformat() + "Z" + +def to_epoch(s: Optional[str]) -> Optional[int]: + """Converteer ISO datetime string naar epoch timestamp""" + if not s: + return None + try: + if s.endswith("Z"): + return int(datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()) + if "T" in s: + return int(datetime.fromisoformat(s).replace(tzinfo=timezone.utc).timestamp()) + dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + return int(dt.timestamp()) + except Exception as e: + logger.warning(f"Kon timestamp niet converteren: {s} - {e}") + return None + +def ensure_dirs(): + """Zorg dat alle benodigde directories bestaan""" + for d in [DATA_DIR, WEB_DIR, LOG_DIR]: + d.mkdir(parents=True, exist_ok=True) + logger.info("Directories geverifieerd") + +def normalize_tags(val: Any) -> List[str]: + """Normaliseer tags naar lowercase lijst zonder duplicaten""" + tags: List[str] = [] + if isinstance(val, list): + tags = [str(x) for x in val] + elif isinstance(val, str): + try: + j = json.loads(val) + if isinstance(j, list): + tags = [str(x) for x in j] + else: + tags = [s.strip() for s in val.split(",")] + except Exception: + tags = [s.strip() for s in val.split(",")] + + out: List[str] = [] + seen = set() + for t in tags: + s = t.strip().lower() + if s and s not in seen: + seen.add(s) + out.append(s) + return out[:32] # Max 32 tags + +def hex_rgba_to_css(hex8: str) -> str: + """Converteer 8-digit hex (RRGGBBAA) naar CSS hex (#RRGGBB)""" + h = (hex8 or "").strip().upper() + if not re.fullmatch(r"[0-9A-F]{8}", h): + return "#000000" + return "#" + h[:6] + +# ========== HMS CATALOG ========== +class HMSCatalog: + """HMS error code lookup met automatische refresh""" + + def __init__(self): + self.map: Dict[str, str] = {} + self.version: Optional[str] = None + self.loaded_at: Optional[float] = None + self.lock = threading.Lock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self.cache_path = DATA_DIR / "hms_cache.json" + + def normalize_and_load(self, data: Any) -> Tuple[Dict[str, str], Optional[str]]: + """Parse HMS JSON naar code -> message mapping""" + version = None + entries = [] + + if isinstance(data, dict) and "data" in data: + version = str(data.get("ver") or data["data"].get("ver") or "") + dev = data["data"].get("device_error") or {} + lang = dev.get("en") or dev.get("EN") or dev.get("En") + if isinstance(lang, list): + entries = lang + + if not entries and isinstance(data, dict) and "device_error" in data: + version = str(data.get("ver") or data["device_error"].get("ver") or "") + lang = data["device_error"].get("en") or data["device_error"].get("EN") + if isinstance(lang, list): + entries = lang + + if not entries and isinstance(data, list): + entries = data + + mapping: Dict[str, str] = {} + for it in entries: + if not isinstance(it, dict): + continue + code = str(it.get("ecode") or it.get("code") or "").strip().upper() + intro = str(it.get("intro") or it.get("message") or "").strip() + if code: + mapping[code] = intro + + return mapping, version + + def load_from_disk(self): + """Laad gecachete HMS data van disk""" + if not self.cache_path.exists(): + logger.debug("Geen HMS cache gevonden") + return + try: + with self.cache_path.open("r", encoding="utf-8") as f: + raw = json.load(f) + mapping, version = self.normalize_and_load(raw) + with self.lock: + if mapping: + self.map = mapping + self.version = version + self.loaded_at = now_ts() + logger.info(f"HMS cache geladen: {len(mapping)} codes, versie {version}") + except Exception as e: + logger.error(f"Fout bij laden HMS cache: {e}") + + def save_to_disk(self, raw: Any): + """Sla HMS data op naar disk""" + try: + with self.cache_path.open("w", encoding="utf-8") as f: + json.dump(raw, f, ensure_ascii=False) + logger.debug("HMS cache opgeslagen") + except Exception as e: + logger.error(f"Fout bij opslaan HMS cache: {e}") + + def download_and_reload(self) -> bool: + """Download fresh HMS data en reload""" + try: + req = urllib.request.Request(HMS_JSON_URL, headers={"User-Agent": "printfarm/2.0"}) + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read().decode("utf-8")) + except Exception as e: + logger.warning(f"HMS download mislukt: {e}") + return False + + mapping, version = self.normalize_and_load(data) + if not mapping: + logger.warning("HMS download bevatte geen geldige data") + return False + + self.save_to_disk(data) + with self.lock: + self.map = mapping + self.version = version + self.loaded_at = now_ts() + logger.info(f"HMS data geüpdatet: {len(mapping)} codes, versie {version}") + return True + + def refresh(self) -> bool: + """Refresh HMS data (download of gebruik cache)""" + ok = self.download_and_reload() + if not ok: + self.load_from_disk() + ok = bool(self.map) + return ok + + def start_background_refresh(self): + """Start achtergrond thread voor periodieke refresh""" + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._refresh_loop, daemon=True, name="HMS-Refresh") + self._thread.start() + logger.info("HMS achtergrond refresh gestart") + + def stop_background_refresh(self): + """Stop achtergrond refresh thread""" + self._stop.set() + if self._thread: + self._thread.join(timeout=5) + logger.info("HMS achtergrond refresh gestopt") + + def _refresh_loop(self): + """Achtergrond loop voor periodieke HMS refresh""" + try: + self.refresh() + except Exception as e: + logger.error(f"Initiële HMS refresh mislukt: {e}") + + while not self._stop.is_set(): + for _ in range(HMS_REFRESH_SEC): + if self._stop.is_set(): + return + time.sleep(1) + try: + self.refresh() + except Exception as e: + logger.error(f"HMS refresh mislukt: {e}") + + def lookup(self, code_any: Any) -> Tuple[Optional[str], str]: + """Zoek error message voor gegeven code""" + if code_any in (None, "", 0, "0"): + return (None, "00000000") + + s = str(code_any).strip().upper() + hex_code = None + try: + if s.startswith("0X"): + hex_code = f"{int(s, 16):08X}" + elif re.fullmatch(r"[0-9A-F]{8}", s): + hex_code = s + else: + hex_code = f"{int(s, 10):08X}" + except Exception: + s2 = re.sub(r"[^0-9A-F]", "", s) + hex_code = (s2[:8] if s2 else "0").rjust(8, "0") + + with self.lock: + msg = self.map.get(hex_code) + return (msg, hex_code) + +hms = HMSCatalog() + + +# ========== SERVICE PROCESS MANAGER ========== +class ServiceProcessManager: + """Beheer achtergrondservices die parallel aan de API moeten draaien.""" + + def __init__(self): + self.services = { + "printer_uploader": BASE_DIR / "printer_uploader.py", + "slicer": BASE_DIR / "slicer.py", + "queue_service": BASE_DIR / "queue_service.py", + } + self.processes: Dict[str, subprocess.Popen] = {} + self.log_files: Dict[str, Any] = {} + self.lock = threading.Lock() + + def _start_service(self, name: str, path: pathlib.Path): + if name in self.processes: + proc = self.processes.get(name) + if proc and proc.poll() is None: + return + self._finalize_service(name) + + if not path.exists(): + logger.error(f"Service {name} niet gevonden op {path}") + return + + try: + LOG_DIR.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.warning(f"Kon logmap niet maken voor service {name}: {e}") + + log_file = None + stdout_target = None + stderr_target = None + try: + log_path = LOG_DIR / f"{name}.log" + log_file = open(log_path, "a", buffering=1, encoding="utf-8") + log_file.write(f"[{datetime.utcnow().isoformat()}Z] Service start\n") + stdout_target = log_file + stderr_target = log_file + except Exception as e: + logger.error(f"Kon logbestand niet openen voor service {name}: {e}") + log_file = None + + cmd = [sys.executable, str(path)] + try: + proc = subprocess.Popen( + cmd, + stdout=stdout_target, + stderr=stderr_target, + cwd=str(path.parent), + env=os.environ.copy(), + ) + except Exception as e: + if log_file: + try: + log_file.write(f"[{datetime.utcnow().isoformat()}Z] Startfout: {e}\n") + except Exception: + pass + log_file.close() + logger.error(f"Kon service {name} niet starten: {e}") + return + + self.processes[name] = proc + if log_file: + self.log_files[name] = log_file + logger.info(f"Service {name} gestart (pid={proc.pid})") + + def _finalize_service(self, name: str): + proc = self.processes.pop(name, None) + log_file = self.log_files.pop(name, None) + if log_file: + try: + log_file.write(f"[{datetime.utcnow().isoformat()}Z] Service gestopt\n") + except Exception: + pass + log_file.close() + return proc + + def start_all(self): + with self.lock: + for name, path in self.services.items(): + self._start_service(name, path) + + def stop_service(self, name: str, timeout: float = 20.0): + with self.lock: + proc = self.processes.get(name) + log_file = self.log_files.get(name) + + if not proc: + return + + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + logger.warning(f"Force kill van service {name} (timeout)") + proc.kill() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + logger.error(f"Kon service {name} niet killen") + + with self.lock: + self.processes.pop(name, None) + log_file = self.log_files.pop(name, None) + + if log_file: + try: + log_file.write(f"[{datetime.utcnow().isoformat()}Z] Service gestopt\n") + except Exception: + pass + log_file.close() + + logger.info(f"Service {name} gestopt") + + def stop_all(self, timeout: float = 20.0): + with self.lock: + names = list(self.processes.keys()) + + for name in names: + self.stop_service(name, timeout=timeout) + + +service_manager = ServiceProcessManager() + +# ========== MQTT BROKER CONFIG ========== +@dataclass +class BrokerConfig: + is_cloud: bool = False + host: str = "" + port: int = 8883 + username: str = "" + password: str = "" + tls_insecure: bool = True + +# ========== PRINTER CLIENT ========== +class PrinterClient: + """MQTT client voor individuele printer""" + + def __init__(self, cfg: dict, on_report): + self.cfg = cfg + self.device_id = cfg["device_id"] + self.on_report = on_report + self.client = mqtt.Client( + client_id=f"printfarm-{self.device_id}", + clean_session=True + ) + self.seq = 0 + self.seq_lock = threading.Lock() + self.connected = threading.Event() + self.stop_evt = threading.Event() + self.thread: Optional[threading.Thread] = None + + # ACK tracking + self._pending_lock = threading.Lock() + self._pending: Dict[str, Tuple[threading.Event, str, str]] = {} + self._responses: Dict[str, dict] = {} + + def _broker(self) -> BrokerConfig: + """Bepaal broker config (LAN of Cloud)""" + if self.cfg.get("ip") and self.cfg.get("lan_access_code"): + return BrokerConfig( + False, + self.cfg["ip"], + 8883, + "bblp", + self.cfg["lan_access_code"], + True + ) + if self.cfg.get("cloud_user_id") and self.cfg.get("cloud_access_token"): + return BrokerConfig( + True, + "us.mqtt.bambulab.com", + 8883, + f"u_{self.cfg['cloud_user_id']}", + self.cfg["cloud_access_token"], + False + ) + raise RuntimeError("Geen geldige broker config (LAN of Cloud)") + + def _on_connect(self, client, userdata, flags, rc): + """MQTT connect callback""" + if rc == 0: + topic = f"device/{self.device_id}/report" + client.subscribe(topic, qos=1) + self.connected.set() + self.send_pushall() + logger.info(f"MQTT verbonden: {self.device_id}") + else: + self.connected.clear() + logger.warning(f"MQTT connect mislukt: {self.device_id}, rc={rc}") + + def _on_message(self, client, userdata, msg): + """MQTT message callback""" + try: + payload = json.loads(msg.payload.decode("utf-8", errors="ignore")) + except Exception as e: + logger.warning(f"Ongeldige MQTT payload voor {self.device_id}: {e}") + return + + # Doorsturen naar manager + try: + self.on_report(self.device_id, payload) + except Exception as e: + logger.error(f"Fout bij verwerken report voor {self.device_id}: {e}", exc_info=True) + + # ACK tracking + try: + for typ, block in payload.items(): + if not isinstance(block, dict): + continue + seq = str(block.get("sequence_id", "") or "") + cmd = str(block.get("command", "") or "") + if not seq or not cmd: + continue + + with self._pending_lock: + pending = self._pending.get(seq) + if pending: + ev, exp_typ, exp_cmd = pending + if typ == exp_typ and cmd == exp_cmd: + self._responses[seq] = block + ev.set() + except Exception as e: + logger.debug(f"ACK tracking fout voor {self.device_id}: {e}") + + def _on_disconnect(self, client, userdata, rc): + """MQTT disconnect callback""" + self.connected.clear() + if rc != 0: + logger.warning(f"MQTT onverwacht verbroken: {self.device_id}, rc={rc}") + + def start(self): + """Start MQTT client thread""" + if self.thread and self.thread.is_alive(): + return + self.stop_evt.clear() + self.thread = threading.Thread( + target=self._run, + daemon=True, + name=f"MQTT-{self.device_id}" + ) + self.thread.start() + logger.info(f"MQTT client gestart voor {self.device_id}") + + def stop(self): + """Stop MQTT client""" + self.stop_evt.set() + try: + self.client.disconnect() + except Exception: + pass + if self.thread: + self.thread.join(timeout=5) + logger.info(f"MQTT client gestopt voor {self.device_id}") + + def _run(self): + """MQTT client main loop""" + broker = self._broker() + self.client.username_pw_set(broker.username, broker.password) + + ctx = ssl.create_default_context() + if broker.tls_insecure: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + self.client.tls_set_context(ctx) + + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + self.client.on_disconnect = self._on_disconnect + + retry_count = 0 + max_retries = 10 + + while not self.stop_evt.is_set() and retry_count < max_retries: + try: + self.client.connect(broker.host, broker.port, keepalive=60) + self.client.loop_start() + retry_count = 0 # Reset bij succesvolle connect + + while not self.stop_evt.is_set(): + time.sleep(1.0) + break + except Exception as e: + retry_count += 1 + logger.error(f"MQTT connect fout {self.device_id} (poging {retry_count}): {e}") + time.sleep(min(30, 3 * retry_count)) + finally: + self.client.loop_stop() + + if retry_count >= max_retries: + logger.error(f"MQTT client opgegeven na {max_retries} pogingen: {self.device_id}") + + def _next_seq(self) -> str: + """Genereer uniek sequence ID""" + with self.seq_lock: + self.seq += 1 + return str(self.seq) + + def _publish(self, cmd: dict, qos: int = 1): + """Publiceer command naar printer""" + if not self.connected.is_set(): + raise RuntimeError("MQTT niet verbonden") + topic = f"device/{self.device_id}/request" + payload = json.dumps(cmd, separators=(",", ":")) + self.client.publish(topic, payload, qos=qos) + + def _register_waiter(self, seq: str, typ: str, cmd: str) -> threading.Event: + """Registreer ACK waiter""" + ev = threading.Event() + with self._pending_lock: + self._pending[seq] = (ev, typ, cmd) + return ev + + def _wait_for_response(self, seq: str, timeout: float = 3.0) -> Optional[dict]: + """Wacht op ACK response""" + with self._pending_lock: + tup = self._pending.get(seq) + if not tup: + return None + + ev, _, _ = tup + if not ev.wait(timeout): + with self._pending_lock: + self._pending.pop(seq, None) + return None + + with self._pending_lock: + resp = self._responses.pop(seq, None) + self._pending.pop(seq, None) + return resp + + def _request_with_ack(self, typ: str, body: dict, qos: int = 1, timeout: float = 3.0) -> Optional[dict]: + """Stuur command en wacht op ACK""" + seq = self._next_seq() + body = dict(body or {}) + body["sequence_id"] = seq + cmd = str(body.get("command", "")) + waiter = self._register_waiter(seq, typ, cmd) + + # Double publish voor betrouwbaarheid + self._publish({typ: body}, qos=qos) + time.sleep(0.08) + self._publish({typ: body}, qos=qos) + + return self._wait_for_response(seq, timeout=timeout) + + def send_pushall(self): + """Vraag volledige status update""" + cmd = { + "pushing": { + "sequence_id": self._next_seq(), + "command": "pushall", + "version": 1, + "push_target": 1 + } + } + self._publish(cmd, qos=0) + + def pause(self): + """Pauzeer print""" + self._publish({ + "print": { + "sequence_id": self._next_seq(), + "command": "pause", + "param": "" + } + }, qos=1) + + def resume(self): + """Hervat print""" + self._publish({ + "print": { + "sequence_id": self._next_seq(), + "command": "resume", + "param": "" + } + }, qos=1) + + def stop_print(self): + """Stop print""" + self._publish({ + "print": { + "sequence_id": self._next_seq(), + "command": "stop", + "param": "" + } + }, qos=1) + + def set_led(self, node: str = "chamber_light", mode: str = "on", timeout: float = 3.0) -> Optional[dict]: + """Schakel LED (met ACK)""" + mode = str(mode).lower() + if mode not in ("on", "off", "flashing"): + mode = "on" + + body = { + "command": "ledctrl", + "led_node": node, + "led_mode": mode, + "led_on_time": 500, + "led_off_time": 500, + "loop_times": 1, + "interval_time": 1000 + } + return self._request_with_ack("system", body, qos=1, timeout=timeout) + + def ams_filament_setting(self, ams_id: int, tray_id: int, tray_type: str, tray_color_hex: str): + """Stel AMS tray in""" + hex_clean = tray_color_hex.strip().lstrip("#") + if len(hex_clean) == 6: + hex_clean += "FF" + elif len(hex_clean) != 8: + hex_clean = "000000FF" + + mats = { + "PLA": (190, 240), + "ASA": (240, 270), + "ASA-CF": (250, 280), + "ABS": (230, 260), + "PETG": (220, 250) + } + tmin, tmax = mats.get(tray_type.upper(), (190, 260)) + + cmd = { + "print": { + "sequence_id": self._next_seq(), + "command": "ams_filament_setting", + "ams_id": int(ams_id), + "tray_id": int(tray_id), + "tray_info_idx": "", + "tray_color": hex_clean.upper(), + "nozzle_temp_min": int(tmin), + "nozzle_temp_max": int(tmax), + "tray_type": tray_type + } + } + self._publish(cmd, qos=1) + +# ========== MANAGER ========== +class Manager: + """Centrale manager voor alle printers en status tracking""" + + def __init__(self): + self.clients: Dict[str, PrinterClient] = {} + self.clients_lock = threading.Lock() + self.job_active: Dict[str, bool] = {} + self.ams_state: Dict[str, Dict[int, Dict[str, Any]]] = {} + self.ams_lock = threading.Lock() + self._light_locks: Dict[str, threading.Lock] = {} + self._light_locks_guard = threading.Lock() + + def _get_light_lock(self, device_id: str) -> threading.Lock: + """Thread-safe light command lock per device""" + with self._light_locks_guard: + lk = self._light_locks.get(device_id) + if lk is None: + lk = threading.Lock() + self._light_locks[device_id] = lk + return lk + + def start_for_all(self): + """Start MQTT clients voor alle printers""" + with db_manager.get_connection() as conn: + cur = conn.cursor() + rows = cur.execute("SELECT * FROM printers").fetchall() + + for row in rows: + p = dict(row) + p["autoprint"] = bool(p.get("autoprint", 1)) + p["auto_ams_black_asacf"] = bool(p.get("auto_ams_black_asacf", 1)) + p["tags"] = normalize_tags(p.get("tags", "[]")) + try: + self.ensure_client(p["device_id"], p) + except Exception as e: + logger.error(f"Fout bij starten client {p['device_id']}: {e}") + + def ensure_client(self, device_id: str, cfg: Optional[dict] = None): + """Zorg dat MQTT client actief is voor printer""" + with self.clients_lock: + if device_id in self.clients: + if cfg: + self.clients[device_id].cfg = cfg + return + + if not cfg: + with db_manager.get_connection() as conn: + cur = conn.cursor() + row = cur.execute("SELECT * FROM printers WHERE device_id=?", (device_id,)).fetchone() + if not row: + raise HTTPException(404, "Printer niet gevonden") + cfg = dict(row) + cfg["autoprint"] = bool(cfg.get("autoprint", 1)) + cfg["auto_ams_black_asacf"] = bool(cfg.get("auto_ams_black_asacf", 1)) + cfg["tags"] = normalize_tags(cfg.get("tags", "[]")) + + cli = PrinterClient(cfg, self._on_report) + self.clients[device_id] = cli + cli.start() + + def drop_client(self, device_id: str): + """Stop en verwijder MQTT client""" + with self.clients_lock: + cli = self.clients.pop(device_id, None) + if cli: + try: + cli.stop() + except Exception as e: + logger.error(f"Fout bij stoppen client {device_id}: {e}") + + def _tray_has_filament(self, tr: dict) -> bool: + """Detecteer of tray filament bevat""" + if not isinstance(tr, dict): + return False + + ttype = str(tr.get("tray_type") or "").strip().upper() + if ttype and ttype not in ("", "N/A", "NA", "NONE"): + return True + + if str(tr.get("tag_uid") or "").strip(): + return True + + try: + if float(tr.get("remain", 0) or 0) > 0: + return True + except (ValueError, TypeError): + pass + + return False + + def note_manual_ams(self, device_id: str, tray_id: int): + """Markeer handmatige AMS wijziging""" + now = now_ts() + with self.ams_lock: + d = self.ams_state.setdefault(device_id, {}) + rec = d.setdefault(int(tray_id), { + "had": None, + "last_manual": 0.0, + "manual_lock": False, + "empty_since": 0.0, + "_lock_seen": 0.0, + "emptied_after_manual": False + }) + rec["last_manual"] = now + rec["manual_lock"] = True + rec["emptied_after_manual"] = False + rec["_lock_seen"] = now + logger.debug(f"Handmatige AMS wijziging: {device_id} tray {tray_id}") + + def _on_report(self, device_id: str, payload: dict): + """Verwerk MQTT report van printer""" + try: + # Sla state op + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO states (device_id, payload, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(device_id) DO UPDATE SET + payload=excluded.payload, + updated_at=datetime('now') + """, (device_id, json.dumps(payload))) + + # Verwerk events (job start/finish/fail) + self._process_events(device_id, payload) + + # Verwerk alerts (HMS + print_error) + self._process_alerts(device_id, payload) + + # Auto AMS label (zwart ASA-CF) + self._process_auto_ams(device_id, payload) + + # Clear override als status niet meer FAILED is + self._check_clear_override(device_id, payload) + + except Exception as e: + logger.error(f"Fout bij verwerken report {device_id}: {e}", exc_info=True) + + def _process_events(self, device_id: str, payload: dict): + """Detecteer en log job events""" + try: + p = payload.get("print", payload) + gcode_state = str(p.get("gcode_state", "IDLE")).upper() + + # Job start detectie + if gcode_state in ("RUNNING", "PRINTING"): + if not self.job_active.get(device_id): + self.job_active[device_id] = True + file_name = p.get("subtask_name") or p.get("gcode_file") or "unknown" + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute( + "INSERT INTO events (device_id, ts, type, meta) VALUES (?, datetime('now'), ?, ?)", + (device_id, "job_start", json.dumps({"file": file_name})) + ) + logger.info(f"Job gestart: {device_id} - {file_name}") + + # Job finish detectie + elif gcode_state in ("FINISH", "FINISHED", "SUCCESS"): + if self.job_active.get(device_id): + self.job_active[device_id] = False + file_name = p.get("subtask_name") or p.get("gcode_file") or "unknown" + filament_g = float(p.get("gcode_weight", 0) or 0) + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute( + "INSERT INTO events (device_id, ts, type, meta) VALUES (?, datetime('now'), ?, ?)", + (device_id, "job_finish", json.dumps({"file": file_name, "filament_g": filament_g})) + ) + logger.info(f"Job voltooid: {device_id} - {file_name}") + + # Job fail detectie + elif gcode_state in ("FAILED", "FAIL"): + if self.job_active.get(device_id): + self.job_active[device_id] = False + file_name = p.get("subtask_name") or p.get("gcode_file") or "unknown" + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute( + "INSERT INTO events (device_id, ts, type, meta) VALUES (?, datetime('now'), ?, ?)", + (device_id, "job_fail", json.dumps({"file": file_name})) + ) + logger.warning(f"Job mislukt: {device_id} - {file_name}") + + except Exception as e: + logger.error(f"Fout bij verwerken events {device_id}: {e}") + + def _process_alerts(self, device_id: str, payload: dict): + """Verwerk HMS alerts en print errors""" + try: + p = payload.get("print", payload) + + # HMS alerts + hms_val = p.get("hms") + if hms_val: + self._sync_hms_alerts(device_id, hms_val) + + # Print error + print_error = p.get("print_error") + if print_error is not None: + self._sync_print_error(device_id, print_error) + + except Exception as e: + logger.error(f"Fout bij verwerken alerts {device_id}: {e}") + + def _sync_hms_alerts(self, device_id: str, hms_val: Any): + """Synchroniseer HMS alerts naar database""" + # Implementatie zoals in originele code (ingekort voor lengte) + pass + + def _sync_print_error(self, device_id: str, print_error_val: Any): + """Synchroniseer print error alert""" + # Implementatie zoals in originele code (ingekort voor lengte) + pass + + def _process_auto_ams(self, device_id: str, payload: dict): + """Auto-label zwart ASA-CF bij vullen tray""" + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + row = cur.execute("SELECT auto_ams_black_asacf FROM printers WHERE device_id=?", (device_id,)).fetchone() + + if not row or not bool(row["auto_ams_black_asacf"]): + return + + cli = self.clients.get(device_id) + if not cli: + return + + pr_block = payload.get("print", payload) + ams_block = pr_block.get("ams") or {} + units = ams_block.get("ams") if isinstance(ams_block, dict) else [] + + if not (isinstance(units, list) and units): + return + + now = now_ts() + + with self.ams_lock: + dev_state = self.ams_state.setdefault(device_id, {}) + + for unit in units: + trays = unit.get("tray") or [] + if not isinstance(trays, list): + continue + + for tray in trays: + if "id" not in tray: + continue + + try: + tray_id = int(tray.get("id", 0)) + except (ValueError, TypeError): + continue + + rec = dev_state.setdefault(tray_id, { + "had": None, + "last_manual": 0.0, + "manual_lock": False, + "empty_since": 0.0, + "_lock_seen": 0.0, + "emptied_after_manual": False + }) + + # Check handmatige lock + last_manual = float(rec.get("last_manual", 0.0)) + lock_seen = float(rec.get("_lock_seen", 0.0)) + + if last_manual > lock_seen: + rec["manual_lock"] = True + rec["emptied_after_manual"] = False + rec["_lock_seen"] = last_manual + + # Huidige status + had_now = self._tray_has_filament(tray) + prev_had = rec.get("had") + + # Update lege timer + if not had_now: + if not rec.get("empty_since"): + rec["empty_since"] = now + + empty_duration = now - float(rec.get("empty_since", 0.0)) + if empty_duration >= STABLE_EMPTY_SEC: + rec["manual_lock"] = False + rec["emptied_after_manual"] = True + else: + rec["empty_since"] = 0.0 + + rec["had"] = bool(had_now) + + # Auto-label condities + if prev_had is None: + continue + if not (prev_had is False and had_now is True): + continue + if rec.get("manual_lock", False): + continue + if not rec.get("emptied_after_manual", True): + continue + + # Check of al correct + current_type = str(tray.get("tray_type") or "").strip().upper() + current_color = hex_rgba_to_css(tray.get("tray_color", "000000FF")).upper() + + if current_type == "ASA-CF" and current_color == "#000000": + continue + + # Voer auto-label uit + try: + ams_id = 0 + try: + ams_id = int(unit.get("id", 0)) + except (ValueError, TypeError): + pass + + cli.ams_filament_setting( + ams_id=ams_id, + tray_id=tray_id, + tray_type="ASA-CF", + tray_color_hex="000000" + ) + logger.info(f"Auto-label uitgevoerd: {device_id} tray {tray_id} -> ASA-CF zwart") + + except Exception as e: + logger.debug(f"Auto-label fout {device_id} tray {tray_id}: {e}") + + except Exception as e: + logger.error(f"Fout bij auto AMS verwerking {device_id}: {e}") + + def _check_clear_override(self, device_id: str, payload: dict): + """Clear status override als printer niet meer FAILED is""" + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + row = cur.execute("SELECT status FROM state_overrides WHERE device_id=?", (device_id,)).fetchone() + + if not row or row["status"] != "IDLE": + return + + p = payload.get("print", payload) + gcode_state = str(p.get("gcode_state", "IDLE")).upper() + + if gcode_state not in ("FAILED", "FAIL"): + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM state_overrides WHERE device_id=?", (device_id,)) + logger.info(f"Status override gewist voor {device_id}") + + except Exception as e: + logger.error(f"Fout bij check clear override {device_id}: {e}") + + def ui_printer_list(self) -> List[dict]: + """Genereer printer lijst voor UI""" + out = [] + + with db_manager.get_connection() as conn: + cur = conn.cursor() + printers = cur.execute("SELECT * FROM printers ORDER BY name").fetchall() + + for p_row in printers: + p = dict(p_row) + device_id = p["device_id"] + + # Haal state op + with db_manager.get_connection() as conn: + cur = conn.cursor() + st_row = cur.execute("SELECT payload, updated_at FROM states WHERE device_id=?", (device_id,)).fetchone() + + payload = None + updated_at = None + if st_row: + try: + payload = json.loads(st_row["payload"]) + except Exception: + pass + updated_at = st_row["updated_at"] + + # Parse status + status, prg, rem_mins, file_name, nozzle, bed, ams = self._parse_status(payload) + light = self._payload_chamber_light_on(payload) or False + + # Check override + with db_manager.get_connection() as conn: + cur = conn.cursor() + ov_row = cur.execute("SELECT status FROM state_overrides WHERE device_id=?", (device_id,)).fetchone() + + if ov_row: + ov_status = ov_row["status"].upper() + if ov_status in ("IDLE", "RUNNING", "PAUSE", "FINISH", "FAILED"): + status = ov_status + if ov_status == "IDLE": + prg = 0.0 + rem_mins = 0 + file_name = "-" + + # Check stale + stale = True + if updated_at: + epoch = to_epoch(updated_at) + if epoch and (now_ts() - epoch <= STATE_STALE_SECONDS): + stale = False + + # Check connected + cli = self.clients.get(device_id) + is_conn = (cli.connected.is_set() if cli else False) or (not stale) + + if not is_conn: + status = "NO_CONN" + prg = 0.0 + rem_mins = 0 + file_name = "-" + nozzle = 0.0 + bed = 0.0 + light = False + + out.append({ + "device_id": device_id, + "name": p["name"], + "model": p["model"] or "X1 Carbon", + "status": status, + "progress": prg, + "remaining_time": rem_mins, + "file": file_name or "-", + "nozzle_temp": nozzle, + "bed_temp": bed, + "ams": ams, + "autoprint": bool(p.get("autoprint", 1)), + "tags": normalize_tags(p.get("tags", "[]")), + "light_on": bool(light), + "chamber_light": bool(light), + "lights": {"chamber": bool(light)} + }) + + return out + + def _parse_status(self, payload: Optional[dict]) -> Tuple[str, float, int, str, float, float, Optional[dict]]: + """Parse status uit payload""" + if not payload: + return ("IDLE", 0.0, 0, "-", 0.0, 0.0, None) + + p = payload.get("print", {}) if "print" in payload else payload + gcode_state = str(p.get("gcode_state", "IDLE")).upper() + + if gcode_state in ("RUNNING", "PRINTING"): + status = "RUNNING" + elif gcode_state in ("PAUSE", "PAUSED"): + status = "PAUSE" + elif gcode_state in ("FINISH", "FINISHED", "SUCCESS"): + status = "FINISH" + elif gcode_state in ("FAILED", "FAIL"): + status = "FAILED" + else: + status = "IDLE" + + progress = float(p.get("mc_percent", 0) or 0.0) + rem = int(p.get("mc_remaining_time", 0) or 0) + if rem > 24 * 60 * 3: + rem //= 60 + + file_name = p.get("subtask_name") or p.get("gcode_file") or "-" + nozzle = float(p.get("nozzle_temper", 0) or 0.0) + bed = float(p.get("bed_temper", 0) or 0.0) + + ams_dict = None + ams_block = p.get("ams", {}) or {} + if ams_block: + trays = [] + try: + for ams_unit in ams_block.get("ams", []): + ams_id = int(ams_unit.get("id", 0)) + for t in ams_unit.get("tray", []): + if "id" not in t: + continue + tid = int(t.get("id", 0)) + empty = not self._tray_has_filament(t) + ttype = (t.get("tray_type") or "N/A") + color_hex = hex_rgba_to_css(t.get("tray_color", "000000FF")) + trays.append({ + "id": tid, + "type": "-" if empty else ttype, + "color": color_hex, + "empty": bool(empty), + "ams_id": ams_id + }) + except Exception: + pass + if trays: + ams_dict = {"trays": trays} + + return (status, progress, rem, file_name, nozzle, bed, ams_dict) + + def _payload_chamber_light_on(self, payload: Optional[dict]) -> Optional[bool]: + """Extract chamber light status uit payload""" + if not payload: + return None + p = payload.get("print", payload) + lights = p.get("lights_report") + if isinstance(lights, list): + for it in lights: + try: + if str(it.get("node", "")).lower() == "chamber_light": + return str(it.get("mode", "")).lower() != "off" + except Exception: + pass + return None + + def cleanup(self): + """Cleanup alle clients en resources""" + logger.info("Manager cleanup gestart") + with self.clients_lock: + device_ids = list(self.clients.keys()) + + for device_id in device_ids: + try: + self.drop_client(device_id) + except Exception as e: + logger.error(f"Fout bij cleanup client {device_id}: {e}") + +manager = Manager() + +# ========== FASTAPI APP ========== +app = FastAPI( + title="Bambu PrintFarm Backend", + version="2.0.0", + description="Productie-klare backend voor Bambu Lab 3D-printer beheer" +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"] +) + +# Global exception handler +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.error(f"Onverwachte fout: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={"detail": "Interne serverfout"} + ) + +# Health check endpoint +@app.get("/health") +def health_check(): + return { + "status": "healthy", + "version": "2.0.0", + "timestamp": ts_iso() + } + +# ========== PYDANTIC MODELS ========== +class PrinterIn(BaseModel): + device_id: str = Field(..., min_length=3, max_length=100) + name: str = Field(..., min_length=1, max_length=200) + model: str = Field(default="X1 Carbon", max_length=100) + ip: Optional[str] = Field(None, max_length=45) + lan_access_code: Optional[str] = Field(None, max_length=100) + cloud_user_id: Optional[str] = Field(None, max_length=100) + cloud_access_token: Optional[str] = Field(None, max_length=500) + autoprint: bool = True + tags: List[str] = Field(default_factory=list) + + @validator('tags') + def validate_tags(cls, v): + return normalize_tags(v) + + @validator('device_id', 'name') + def no_special_chars(cls, v): + if not re.match(r'^[a-zA-Z0-9_\-\s]+$', v): + raise ValueError('Alleen alfanumerieke tekens, spaties, - en _ toegestaan') + return v + +class AutoPrintToggle(BaseModel): + autoprint: bool + +class AmsAutoLabelToggle(BaseModel): + enabled: bool + +class AmsSettingIn(BaseModel): + ams_id: int = Field(default=0, ge=0, le=3) + tray_id: int = Field(..., ge=0, le=3) + tray_type: str = Field(..., max_length=50) + tray_color: Optional[str] = Field(default="#000000", max_length=9) + + @validator('tray_color') + def validate_color(cls, v): + if not re.match(r'^#?[0-9A-Fa-f]{6}$', v): + raise ValueError('Kleur moet hex format zijn (#RRGGBB)') + return v + +# ========== CONFIG ENDPOINTS ========== +@app.get("/api/config/printers") +def cfg_list(): + """Lijst alle printers""" + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + rows = cur.execute("SELECT * FROM printers ORDER BY name").fetchall() + + printers = [] + for row in rows: + p = dict(row) + p["autoprint"] = bool(p.get("autoprint", 1)) + p["auto_ams_black_asacf"] = bool(p.get("auto_ams_black_asacf", 1)) + p["tags"] = normalize_tags(p.get("tags", "[]")) + printers.append(p) + + return printers + except Exception as e: + logger.error(f"Fout bij ophalen printers: {e}") + raise HTTPException(500, "Kon printers niet ophalen") + +@app.post("/api/config/printers") +def cfg_add(p: PrinterIn): + """Voeg nieuwe printer toe""" + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + existing = cur.execute("SELECT device_id FROM printers WHERE device_id=?", (p.device_id,)).fetchone() + + if existing: + raise HTTPException(409, "Printer met dit device ID bestaat al") + + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO printers (device_id, name, model, ip, lan_access_code, + cloud_user_id, cloud_access_token, autoprint, tags, auto_ams_black_asacf) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + """, (p.device_id, p.name, p.model, p.ip, p.lan_access_code, + p.cloud_user_id, p.cloud_access_token, 1 if p.autoprint else 0, + json.dumps(p.tags))) + + cfg = p.dict() + cfg["auto_ams_black_asacf"] = True + manager.ensure_client(p.device_id, cfg) + + logger.info(f"Printer toegevoegd: {p.device_id} ({p.name})") + return {"ok": True, "device_id": p.device_id} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij toevoegen printer: {e}", exc_info=True) + raise HTTPException(500, "Kon printer niet toevoegen") + +@app.put("/api/config/printers/{device_id}") +def cfg_update(device_id: str, p: PrinterIn): + """Update bestaande printer""" + if device_id != p.device_id: + raise HTTPException(400, "Device ID kan niet gewijzigd worden") + + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + existing = cur.execute("SELECT auto_ams_black_asacf FROM printers WHERE device_id=?", (device_id,)).fetchone() + + if not existing: + raise HTTPException(404, "Printer niet gevonden") + + preserve_flag = bool(existing["auto_ams_black_asacf"]) + + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + UPDATE printers + SET name=?, model=?, ip=?, lan_access_code=?, cloud_user_id=?, + cloud_access_token=?, autoprint=?, tags=?, updated_at=datetime('now') + WHERE device_id=? + """, (p.name, p.model, p.ip, p.lan_access_code, p.cloud_user_id, + p.cloud_access_token, 1 if p.autoprint else 0, + json.dumps(p.tags), device_id)) + + manager.drop_client(device_id) + cfg = p.dict() + cfg["auto_ams_black_asacf"] = preserve_flag + manager.ensure_client(device_id, cfg) + + logger.info(f"Printer geüpdatet: {device_id}") + return {"ok": True} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij updaten printer: {e}", exc_info=True) + raise HTTPException(500, "Kon printer niet updaten") + +@app.patch("/api/config/printers/{device_id}/autoprint") +def cfg_toggle_autoprint(device_id: str, body: AutoPrintToggle): + """Toggle autoprint voor printer""" + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + result = cur.execute( + "UPDATE printers SET autoprint=? WHERE device_id=?", + (1 if body.autoprint else 0, device_id) + ) + if result.rowcount == 0: + raise HTTPException(404, "Printer niet gevonden") + + logger.info(f"Autoprint {'aan' if body.autoprint else 'uit'}: {device_id}") + return {"ok": True, "autoprint": body.autoprint} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij toggle autoprint: {e}") + raise HTTPException(500, "Kon autoprint niet wijzigen") + +@app.patch("/api/config/printers/{device_id}/auto_ams_black_asacf") +def cfg_toggle_auto_ams(device_id: str, body: AmsAutoLabelToggle): + """Toggle auto AMS zwart ASA-CF""" + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + result = cur.execute( + "UPDATE printers SET auto_ams_black_asacf=? WHERE device_id=?", + (1 if body.enabled else 0, device_id) + ) + if result.rowcount == 0: + raise HTTPException(404, "Printer niet gevonden") + + logger.info(f"Auto AMS label {'aan' if body.enabled else 'uit'}: {device_id}") + return {"ok": True, "auto_ams_black_asacf": body.enabled} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij toggle auto AMS: {e}") + raise HTTPException(500, "Kon auto AMS niet wijzigen") + +@app.delete("/api/config/printers/{device_id}") +def cfg_delete(device_id: str): + """Verwijder printer""" + try: + manager.drop_client(device_id) + + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM printers WHERE device_id=?", (device_id,)) + + logger.info(f"Printer verwijderd: {device_id}") + return {"ok": True} + + except Exception as e: + logger.error(f"Fout bij verwijderen printer: {e}", exc_info=True) + raise HTTPException(500, "Kon printer niet verwijderen") + +# ========== PRINTER CONTROL ENDPOINTS ========== +@app.get("/api/printers") +def api_printers(): + """Lijst alle printers met status""" + try: + return manager.ui_printer_list() + except Exception as e: + logger.error(f"Fout bij ophalen printer lijst: {e}") + raise HTTPException(500, "Kon printer lijst niet ophalen") + +@app.get("/api/stats") +def api_stats(range: str = Query("day", regex="^(day|week|month|total)$")): + """Statistieken over periode""" + try: + with db_manager.get_connection() as conn: + cur = conn.cursor() + printers = cur.execute("SELECT COUNT(*) AS c FROM printers").fetchone()["c"] + + params: List[Any] = [] + if range == "day": + where = "ts >= ?" + try: + from zoneinfo import ZoneInfo + tz = ZoneInfo(LOCAL_TZ) + except Exception: + tz = timezone.utc + now_local = datetime.now(tz) + start_local = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=tz) + start_utc = start_local.astimezone(timezone.utc) + params = [start_utc.strftime("%Y-%m-%d %H:%M:%S")] + elif range == "week": + where = "ts >= datetime('now','-7 day')" + elif range == "month": + where = "ts >= datetime('now','-30 day')" + else: + where = "1=1" + + fails = cur.execute(f"SELECT COUNT(*) AS c FROM events WHERE type='job_fail' AND {where}", params).fetchone()["c"] + finishes = cur.execute(f"SELECT COUNT(*) AS c FROM events WHERE type='job_finish' AND {where}", params).fetchone()["c"] + rows = cur.execute(f"SELECT meta FROM events WHERE type='job_finish' AND {where}", params).fetchall() + + filament_g = 0.0 + for r in rows: + try: + m = json.loads(r["meta"]) + filament_g += float(m.get("filament_g", 0.0)) + except Exception: + pass + + spools = filament_g / 1000.0 / 0.75 if filament_g > 0 else 0.0 + succ_rate = (finishes / (finishes + fails) * 100.0) if (finishes + fails) > 0 else None + + running = sum(1 for p in manager.ui_printer_list() if p["status"] == "RUNNING") + + return { + "total": printers, + "running": running, + "fails": fails, + "filament_kg": filament_g / 1000.0, + "spools": spools, + "success_rate": succ_rate + } + + except Exception as e: + logger.error(f"Fout bij ophalen stats: {e}") + raise HTTPException(500, "Kon statistieken niet ophalen") + +@app.post("/api/printers/{device_id}/pause_resume") +def api_pause_resume(device_id: str): + """Pauzeer of hervat print""" + try: + manager.ensure_client(device_id) + cli = manager.clients.get(device_id) + if not cli: + raise HTTPException(500, "MQTT client niet beschikbaar") + + with db_manager.get_connection() as conn: + cur = conn.cursor() + st_row = cur.execute("SELECT payload FROM states WHERE device_id=?", (device_id,)).fetchone() + + payload = None + if st_row: + try: + payload = json.loads(st_row["payload"]) + except Exception: + pass + + status = manager._parse_status(payload)[0] + + if status == "PAUSE": + cli.resume() + logger.info(f"Print hervat: {device_id}") + elif status == "RUNNING": + cli.pause() + logger.info(f"Print gepauzeerd: {device_id}") + else: + raise HTTPException(400, f"Kan niet pauzeren/hervatten in status {status}") + + return {"ok": True, "action": "resume" if status == "PAUSE" else "pause"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij pause/resume {device_id}: {e}") + raise HTTPException(500, "Kon print niet pauzeren/hervatten") + +@app.post("/api/printers/{device_id}/stop") +def api_stop(device_id: str): + """Stop print""" + try: + manager.ensure_client(device_id) + cli = manager.clients.get(device_id) + if not cli: + raise HTTPException(500, "MQTT client niet beschikbaar") + + with db_manager.get_connection() as conn: + cur = conn.cursor() + st_row = cur.execute("SELECT payload FROM states WHERE device_id=?", (device_id,)).fetchone() + + payload = None + if st_row: + try: + payload = json.loads(st_row["payload"]) + except Exception: + pass + + cur_status = manager._parse_status(payload)[0] + + try: + cli.stop_print() + except Exception as e: + if cur_status == "FAILED": + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO state_overrides (device_id, status, note, created_at) + VALUES (?, 'IDLE', 'manual-stop-from-failed-error', datetime('now')) + ON CONFLICT(device_id) DO UPDATE SET + status='IDLE', + note='manual-stop-from-failed-error', + created_at=datetime('now') + """, (device_id,)) + raise HTTPException(500, f"Stop commando mislukt: {e}") + + forced = False + if cur_status == "FAILED": + with db_manager.get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO state_overrides (device_id, status, note, created_at) + VALUES (?, 'IDLE', 'manual-stop-from-failed', datetime('now')) + ON CONFLICT(device_id) DO UPDATE SET + status='IDLE', + note='manual-stop-from-failed', + created_at=datetime('now') + """, (device_id,)) + forced = True + + logger.info(f"Print gestopt: {device_id} (forced_idle={forced})") + return {"ok": True, "forced_idle": forced} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij stop {device_id}: {e}") + raise HTTPException(500, "Kon print niet stoppen") + +@app.post("/api/printers/{device_id}/light/{mode}") +def api_light_set(device_id: str, mode: str): + """Stel kamer licht in""" + mode = str(mode).lower() + if mode not in ("on", "off", "flashing"): + raise HTTPException(400, "Mode moet 'on', 'off' of 'flashing' zijn") + + try: + manager.ensure_client(device_id) + cli = manager.clients.get(device_id) + if not cli: + raise HTTPException(500, "MQTT client niet beschikbaar") + + lk = manager._get_light_lock(device_id) + with lk: + try: + ack = cli.set_led("chamber_light", mode, timeout=1.5) + except Exception as e: + raise HTTPException(500, f"Licht schakelen mislukt: {e}") + + if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): + try: + ack = cli.set_led("chamber_light", mode, timeout=1.0) + except Exception: + raise HTTPException(504, "Geen bevestiging van printer") + if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): + raise HTTPException(504, "Geen bevestiging van printer") + + logger.info(f"Licht geschakeld {mode}: {device_id}") + return {"ok": True, "mode": mode} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij light set {device_id}: {e}") + raise HTTPException(500, "Kon licht niet schakelen") + +@app.post("/api/printers/{device_id}/light/toggle") +def api_light_toggle(device_id: str): + """Toggle kamer licht""" + try: + manager.ensure_client(device_id) + cli = manager.clients.get(device_id) + if not cli: + raise HTTPException(500, "MQTT client niet beschikbaar") + + lk = manager._get_light_lock(device_id) + with lk: + with db_manager.get_connection() as conn: + cur = conn.cursor() + st_row = cur.execute("SELECT payload FROM states WHERE device_id=?", (device_id,)).fetchone() + + cur_on_opt = None + if st_row: + try: + payload = json.loads(st_row["payload"]) + cur_on_opt = manager._payload_chamber_light_on(payload) + except Exception: + pass + + target = "off" if cur_on_opt is True else "on" + + try: + ack = cli.set_led("chamber_light", target, timeout=1.5) + except Exception as e: + raise HTTPException(500, f"Lamp toggle mislukt: {e}") + + if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): + try: + ack = cli.set_led("chamber_light", target, timeout=1.0) + except Exception: + raise HTTPException(504, "Geen bevestiging van printer") + if not ack or str(ack.get("result", "")).lower() not in ("success", "ok", "succeed"): + raise HTTPException(504, "Geen bevestiging van printer") + + logger.info(f"Licht getoggled naar {target}: {device_id}") + return {"ok": True, "mode": target} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij light toggle {device_id}: {e}") + raise HTTPException(500, "Kon licht niet togglen") + +# ========== AMS ENDPOINTS ========== +@app.post("/api/printers/{device_id}/ams/filament_setting") +def api_ams_set(device_id: str, body: AmsSettingIn): + """Stel AMS tray in""" + try: + manager.ensure_client(device_id) + cli = manager.clients.get(device_id) + if not cli: + raise HTTPException(500, "MQTT client niet beschikbaar") + + cli.ams_filament_setting(body.ams_id, body.tray_id, body.tray_type, body.tray_color or "#000000") + manager.note_manual_ams(device_id, int(body.tray_id)) + + logger.info(f"AMS tray ingesteld: {device_id} tray {body.tray_id} -> {body.tray_type}") + return {"ok": True} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Fout bij AMS set {device_id}: {e}") + raise HTTPException(500, "Kon AMS tray niet instellen") + +# ========== CAMERA COMPATIBILITEIT ========== +@app.get("/api/stream/printers") +def api_stream_printers(): + """Compatibele response voor legacy camera endpoints.""" + + return {"streams": [], "camera_enabled": False} + + +@app.post("/api/printers/{device_id}/snapshot/start") +def api_snapshot_start(device_id: str): + """Geef nette melding dat snapshotten niet meer beschikbaar is.""" + + logger.info("Snapshot request voor %s, maar camera is uitgeschakeld", device_id) + return JSONResponse( + status_code=200, + content={ + "ok": False, + "camera_enabled": False, + "message": "Camera functionaliteit is uitgeschakeld", + }, + ) + + +# ========== ALERTS ENDPOINTS (ingekort) ========== +@app.get("/api/alerts") +def api_alerts( + device_id: Optional[str] = Query(None), + q: Optional[str] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=2000), + state: Optional[str] = Query(None, regex="^(open|closed|all)$"), + range: Optional[str] = Query(None), +): + """Lijst alerts met filtering (placeholder).""" + + return {"items": [], "total": 0} + + +@app.get("/api/alerts/summary") +def api_alerts_summary( + device_id: Optional[str] = Query(None), + range: Optional[str] = Query(None), +): + """Alert samenvatting (placeholder).""" + + return {"open": 0, "today": 0, "unique_codes": 0} + +# ========== WEBSOCKET ========== +WS_CLIENTS: Set[WebSocket] = set() +ALERTS_QUEUE: "asyncio.Queue[dict]" = asyncio.Queue() + +@app.websocket("/ws/alerts") +async def ws_alerts(ws: WebSocket): + """WebSocket voor realtime alerts""" + await ws.accept() + WS_CLIENTS.add(ws) + try: + while True: + await ws.receive_text() + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + WS_CLIENTS.discard(ws) + try: + await ws.close() + except Exception: + pass + +async def alerts_broadcaster(): + """Broadcast alerts naar alle WebSocket clients""" + while True: + msg = await ALERTS_QUEUE.get() + dead = [] + payload = json.dumps(msg, separators=(",", ":")) + for ws in list(WS_CLIENTS): + try: + await ws.send_text(payload) + except Exception: + dead.append(ws) + for ws in dead: + WS_CLIENTS.discard(ws) + try: + await ws.close() + except Exception: + pass + +# ========== LIFECYCLE EVENTS ========== +@app.on_event("startup") +async def startup_event(): + """Applicatie startup""" + logger.info("=== Bambu PrintFarm Backend Start ===") + logger.info(f"Versie: 2.0.0") + logger.info(f"Data directory: {DATA_DIR}") + logger.info(f"Database: {DB_PATH}") + + # Init database + try: + db_manager.init_schema() + except Exception as e: + logger.error(f"Database initialisatie mislukt: {e}", exc_info=True) + sys.exit(1) + + # Start HMS catalog + try: + hms.load_from_disk() + hms.start_background_refresh() + except Exception as e: + logger.error(f"HMS catalog start mislukt: {e}") + + # Start MQTT clients + try: + manager.start_for_all() + except Exception as e: + logger.error(f"Manager start mislukt: {e}", exc_info=True) + + # Start WebSocket broadcaster + app.state.alerts_task = asyncio.create_task(alerts_broadcaster()) + + # Start achtergrondservices + try: + service_manager.start_all() + except Exception as e: + logger.error(f"Kon achtergrondservices niet starten: {e}") + + logger.info("=== Backend succesvol gestart ===") + +@app.on_event("shutdown") +async def shutdown_event(): + """Applicatie shutdown""" + logger.info("=== Bambu PrintFarm Backend Shutdown ===") + + # Stop WebSocket broadcaster + task = getattr(app.state, "alerts_task", None) + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Fout bij stoppen WebSocket broadcaster: {e}") + + # Stop HMS catalog + try: + hms.stop_background_refresh() + except Exception as e: + logger.error(f"Fout bij stoppen HMS catalog: {e}") + + # Stop manager en clients + try: + manager.cleanup() + except Exception as e: + logger.error(f"Fout bij manager cleanup: {e}") + + # Stop achtergrondservices + try: + service_manager.stop_all() + except Exception as e: + logger.error(f"Fout bij stoppen achtergrondservices: {e}") + + logger.info("=== Backend succesvol gestopt ===") + +# Mount static files (laatste, catch-all) +if WEB_DIR.exists(): + app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") + +# ========== SIGNAL HANDLERS ========== +def signal_handler(signum, frame): + """Graceful shutdown op SIGTERM/SIGINT""" + logger.info(f"Signal {signum} ontvangen, shutdown wordt gestart...") + sys.exit(0) + +signal.signal(signal.SIGTERM, signal_handler) +signal.signal(signal.SIGINT, signal_handler) + +# ========== MAIN ========== +def main(): + """Main entry point""" + ensure_dirs() + + # Configuratie logging + log_level = os.environ.get("LOG_LEVEL", "INFO").upper() + logging.getLogger().setLevel(getattr(logging, log_level, logging.INFO)) + + logger.info(f"Starting Bambu PrintFarm Backend op {APP_HOST}:{APP_PORT}") + + # Uvicorn configuratie + uvicorn_config = { + "app": app, + "host": APP_HOST, + "port": APP_PORT, + "log_level": log_level.lower(), + "access_log": True, + "server_header": False, + "date_header": False, + "forwarded_allow_ips": "*", # Voor reverse proxy ondersteuning + "proxy_headers": True + } + + # Optioneel: SSL/TLS configuratie + ssl_cert = os.environ.get("SSL_CERT") + ssl_key = os.environ.get("SSL_KEY") + if ssl_cert and ssl_key: + if pathlib.Path(ssl_cert).exists() and pathlib.Path(ssl_key).exists(): + uvicorn_config["ssl_certfile"] = ssl_cert + uvicorn_config["ssl_keyfile"] = ssl_key + logger.info("SSL/TLS ingeschakeld") + else: + logger.warning("SSL certificaat bestanden niet gevonden, SSL uitgeschakeld") + + uvicorn.run(**uvicorn_config) + +if __name__ == "__main__": + main() diff --git a/web/index.html b/web/index.html index 36217e4..84621d8 100644 --- a/web/index.html +++ b/web/index.html @@ -101,13 +101,23 @@ .printers-head{display:flex;align-items:center;justify-content:space-between;margin:.25rem 0 .5rem} .printers-title{font-weight:600;font-size:1rem} .meta{color:var(--text-subtle);font-size:.85rem} - .printers-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1.25rem} - .printer-card{position:relative;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:1.25rem;overflow:hidden;box-shadow:var(--shadow-sm);transition:box-shadow .2s,border-color .2s} - .printer-card:hover{box-shadow:var(--shadow-md)} - .card-content{padding:1rem} - .card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:.75rem} - .printer-info h3{font-size:1rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} - .printer-meta{font-size:.75rem;color:var(--text-subtle)} + .printers-grid{display:none} + .printer-table-wrap{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius);box-shadow:var(--shadow-sm);overflow:hidden} + .printer-table{width:100%;border-collapse:collapse;min-width:960px} + .printer-table thead{background:var(--bg-tertiary)} + .printer-table th{font-size:.75rem;text-align:left;font-weight:600;color:var(--text-secondary);padding:.75rem 1rem;border-bottom:1px solid var(--border-color)} + .printer-table td{padding:.9rem 1rem;border-bottom:1px solid var(--border-color);vertical-align:middle;font-size:.9rem} + .printer-table tbody tr:last-child td{border-bottom:0} + .printer-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--bg-tertiary) 35%, transparent)} + .printer-row.show-ams{outline:2px solid color-mix(in srgb, var(--accent) 40%, transparent);outline-offset:-2px} + .table-scroll{overflow:auto} + .empty-state{padding:2.5rem 1.5rem;text-align:center;color:var(--text-secondary);font-size:.95rem} + [data-theme="dark"] .printer-table thead{background:color-mix(in srgb, var(--bg-tertiary) 45%, transparent)} + [data-theme="dark"] .printer-table tbody tr:nth-child(even){background:color-mix(in srgb, var(--bg-tertiary) 25%, transparent)} + .printer-name{font-weight:600;font-size:.95rem;display:inline-flex;align-items:center;gap:.5rem} + .printer-model{font-size:.8rem;color:var(--text-subtle);margin-top:.15rem} + .row-info{display:flex;flex-direction:column;gap:.35rem} + .row-info .tags{margin-top:0} .status-badge{display:inline-flex;align-items:center;gap:.25rem;padding:.125rem .5rem;border-radius:999px;font-size:.75rem;font-weight:500;white-space:nowrap} .status-RUNNING{background:#dcfce7;color:#166534} .status-IDLE{background:#f5f5f5;color:#525252} @@ -115,27 +125,21 @@ .status-FAILED{background:#fee2e2;color:#991b1b} .status-FINISH{background:#dbeafe;color:#1e40af} .status-NO_CONN{background:#fee2e2;color:#991b1b} + .status-STOPPING{background:#fef3c7;color:#92400e} [data-theme="dark"] .status-RUNNING{background:#14532d;color:#bbf7d0} [data-theme="dark"] .status-IDLE{background:#525252;color:#e5e5e5} [data-theme="dark"] .status-PAUSE{background:#713f12;color:#fde68a} [data-theme="dark"] .status-FAILED{background:#7f1d1d;color:#fecaca} [data-theme="dark"] .status-FINISH{background:#1e3a8a;color:#bfdbfe} [data-theme="dark"] .status-NO_CONN{background:#7f1d1d;color:#fecaca} - - .video{width:100%;aspect-ratio:16/9;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:.75rem;overflow:hidden;margin-bottom:1rem;display:grid;place-items:center;color:var(--text-subtle);font-size:.8rem} - .video img{width:100%;height:100%;display:block;object-fit:cover} - - .progress-section{margin-bottom:1rem} - .progress-bar{height:6px;width:100%;border-radius:999px;background:var(--bg-tertiary);overflow:hidden} - .progress-fill{height:100%;background:var(--accent);transition:width .5s ease} - .progress-meta{display:flex;justify-content:space-between;font-size:.75rem;color:var(--text-secondary);margin-top:.375rem} - - .details-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem;margin-bottom:1rem} - .detail-label{font-size:.7rem;color:var(--text-secondary)} - .detail-value{font-size:.875rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} - - .controls{display:flex;gap:.5rem} - .control-btn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border:1px solid var(--border-color);padding:1rem .75rem;border-radius:.75rem;font-size:.875rem;cursor:pointer;background:var(--bg-secondary);transition:background-color .2s;color:var(--text-primary)} + [data-theme="dark"] .status-STOPPING{background:#713f12;color:#fde68a} + + .progress-bar{position:relative;height:6px;width:100%;border-radius:999px;background:var(--bg-tertiary);overflow:hidden} + .progress-fill{height:100%;background:var(--accent);transition:width .35s ease} + .progress-meta{display:flex;justify-content:space-between;font-size:.75rem;color:var(--text-secondary);margin-top:.35rem} + .actions{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap} + .ams-anchor{position:relative;display:inline-flex} + .control-btn{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;gap:.5rem;border:1px solid var(--border-color);padding:.5rem .75rem;border-radius:.65rem;font-size:.85rem;cursor:pointer;background:var(--bg-secondary);transition:background-color .2s,color .2s} .control-btn:hover:not(:disabled){background:var(--bg-tertiary)} .control-btn:disabled{opacity:.4;cursor:not-allowed} .control-btn .lucide{width:16px;height:16px;stroke:currentColor} @@ -160,8 +164,6 @@ .pill[disabled]{opacity:.5;cursor:not-allowed;pointer-events:none} .select:disabled{opacity:.6;cursor:not-allowed} - .printer-card.show-ams::after{content:"";position:absolute;inset:0;background:rgba(0,0,0,.04);pointer-events:none;border-radius:inherit} - [data-theme="dark"] .printer-card.show-ams::after{background:rgba(255,255,255,.03)} .ams-popover{position:absolute;top:.75rem;right:.75rem;width:min(420px,calc(100% - 1.5rem));background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:1rem;box-shadow:var(--shadow-md);opacity:0;transform:translateY(-6px) scale(.98);pointer-events:none;transition:opacity .15s ease,transform .15s ease;z-index:20;overflow:hidden} .ams-popover.open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto} .ams-popover::before{content:"";position:absolute;top:-8px;right:36px;width:12px;height:12px;background:var(--bg-secondary);border-left:1px solid var(--border-color);border-top:1px solid var(--border-color);transform:rotate(45deg)} @@ -225,7 +227,7 @@ .sidebar .menu-card::-webkit-scrollbar{ width:0; height:0; } /* AMS: geen interne scroll meer */ - .printer-card.show-ams{ + .printer-row.show-ams{ overflow: visible !important; z-index: 60; } @@ -324,7 +326,7 @@

Bambu Printfarm

Printers
–
-
+
@@ -376,11 +378,11 @@

Bambu Printfarm

let range = 'day'; let openAmsIdx = null; let openInfoIdx = null; - let camIO = null; let printedTotalsByDevice = {}; let cfgByDevice = {}; - const snapshotControllers = new Map(); // dev -> { intervalId, url } const LIVE = { ws:null, pollingId:null, kpiId:null, inFlight:false }; + const statusOverrides = Object.create(null); + const COMMAND_LOCKS = new Set(); // ---- Utils function clamp(v,min,max){ return Math.max(min, Math.min(max, v)); } @@ -396,6 +398,23 @@

Bambu Printfarm

const h=Math.floor(minutes/60); const m=minutes%60; return h>0? `${h}u ${m}m`:`${m}m`; } + const FAILURE_STATUSES = ['FAILED', 'FAIL', 'FAILURE', 'ERROR', 'MALFUNCTION']; + function isFailureStatus(status){ + const key = String(status || '').toUpperCase(); + return FAILURE_STATUSES.includes(key); + } + function statusLabel(status){ + switch(status){ + case 'NO_CONN': return 'Niet verbonden'; + case 'STOPPING': return 'Stoppen…'; + case 'PAUSE': return 'Gepauzeerd'; + case 'RUNNING': return 'Bezig'; + case 'IDLE': return 'Gereed'; + case 'FINISH': return 'Voltooid'; + case 'FAILED': return 'Mislukt'; + default: return status || '–'; + } + } // --- Licht-state helper function isLightOn(p){ @@ -469,6 +488,64 @@

Bambu Printfarm

document.getElementById('kpiSuccessSub').textContent = `Voltooide / jobs (${rangeLabel()})`; } + function getOverride(deviceId, currentStatus){ + const entry = statusOverrides[deviceId]; + if(!entry) return null; + + const now = Date.now(); + const statusKey = String(currentStatus || '').toUpperCase(); + + if(entry.persistWhile && entry.persistWhile.size){ + if(!entry.persistWhile.has(statusKey)){ + delete statusOverrides[deviceId]; + return null; + } + } + + if(Number.isFinite(entry.expires) && entry.expires <= now){ + delete statusOverrides[deviceId]; + return null; + } + + return entry; + } + function clearOverride(deviceId){ delete statusOverrides[deviceId]; } + function setStatusOverride(deviceId, fields, options){ + const opts = (options && typeof options === 'object') ? options : { ttlMs: options }; + const persistWhile = Array.isArray(opts.persistWhile) + ? new Set(opts.persistWhile.map(s => String(s || '').toUpperCase()).filter(Boolean)) + : null; + + let expires; + if(opts.ttlMs === Infinity){ + expires = Infinity; + } else if(typeof opts.ttlMs === 'number' && opts.ttlMs > 0){ + expires = Date.now() + opts.ttlMs; + } else if(persistWhile && persistWhile.size){ + expires = Infinity; + } else { + expires = Date.now() + 12000; + } + + statusOverrides[deviceId] = { + fields: Object.assign({}, fields), + expires, + persistWhile + }; + } + function viewPrinter(p){ + if(!p) return p; + const entry = getOverride(p.device_id, p.status); + if(!entry) return p; + const fields = entry.fields || {}; + const matches = Object.keys(fields).every(key => Object.is(p[key], fields[key])); + if(matches){ + clearOverride(p.device_id); + return p; + } + return Object.assign({}, p, fields); + } + function updateFarmCount(){ const total = printers.length; document.getElementById('farm-count').textContent = `${total} printer${total===1?'':'s'}`; @@ -476,18 +553,18 @@

Bambu Printfarm

// ---- AMS open/close function applyAmsOpenState(){ - const cards = document.querySelectorAll('.printer-card'); + const rows = document.querySelectorAll('.printer-row'); const pops = document.querySelectorAll('.ams-popover'); - cards.forEach(c=>c.classList.remove('show-ams')); + rows.forEach(r=>r.classList.remove('show-ams')); pops.forEach(p=>p.classList.remove('open')); if(openAmsIdx===null) return; const pop = document.getElementById('ams-popover-'+openAmsIdx); - const card = document.querySelectorAll('.printer-card')[openAmsIdx]; + const row = document.querySelectorAll('.printer-row')[openAmsIdx]; const btn = document.querySelector(`[data-ams-btn="${openAmsIdx}"]`); - if(pop&&card){ pop.classList.add('open'); card.classList.add('show-ams'); } + if(pop&&row){ pop.classList.add('open'); row.classList.add('show-ams'); } if(btn) btn.setAttribute('aria-expanded','true'); } - function closeAllAms(){ openAmsIdx=null; document.querySelectorAll('.ams-popover.open').forEach(p=>p.classList.remove('open')); document.querySelectorAll('.printer-card.show-ams').forEach(c=>c.classList.remove('show-ams')); document.querySelectorAll('[data-ams-btn]').forEach(b=>b.setAttribute('aria-expanded','false')); } + function closeAllAms(){ openAmsIdx=null; document.querySelectorAll('.ams-popover.open').forEach(p=>p.classList.remove('open')); document.querySelectorAll('.printer-row.show-ams').forEach(r=>r.classList.remove('show-ams')); document.querySelectorAll('[data-ams-btn]').forEach(b=>b.setAttribute('aria-expanded','false')); } function toggleAms(idx, forceClose=false){ if(forceClose||openAmsIdx===idx){ closeAllAms(); return;} openAmsIdx=idx; applyAmsOpenState(); } // ---- Info popover @@ -527,11 +604,18 @@

Bambu Printfarm

// ---- Buttons state helper function updateButtonsForStatus(id, status){ - const card = document.querySelector(`.printer-card[data-id="${id}"]`); - if(!card) return; - const [btnPause, btnStop] = card.querySelectorAll('.controls .control-btn'); + const row = document.querySelector(`.printer-row[data-id="${id}"]`); + if(!row) return; + const buttons = row.querySelectorAll('.actions .control-btn'); + const btnPause = buttons[0]; + const btnStop = buttons[1]; if(btnPause) btnPause.disabled = !(status==='RUNNING' || status==='PAUSE'); - if(btnStop) btnStop.disabled = (status==='IDLE' || status==='FINISH' || status==='NO_CONN'); + if(btnStop){ + const disabled = (status==='IDLE' || status==='FINISH' || status==='NO_CONN' || status==='STOPPING'); + btnStop.disabled = disabled; + if(disabled){ btnStop.setAttribute('aria-busy', status==='STOPPING' ? 'true' : 'false'); } + else{ btnStop.removeAttribute('aria-busy'); } + } const icon = btnPause?.querySelector('i'); if(icon) icon.setAttribute('data-lucide', status==='PAUSE' ? 'play' : 'pause'); lucide.createIcons(); @@ -539,40 +623,49 @@

Bambu Printfarm

// ---- Patch 1 kaart function patchPrinterCard(p){ - const id = p.device_id; + const show = viewPrinter(p); + if(!show) return; + const id = show.device_id; const st = document.getElementById(`st-${id}`); if(st){ - st.className = `status-badge status-${p.status}`; - st.textContent = (p.status === 'NO_CONN') ? 'Niet verbonden' : p.status; + const cls = show.status || 'ONBEKEND'; + st.className = `status-badge status-${cls}`; + st.textContent = statusLabel(show.status); } const pf = document.getElementById(`pf-${id}`); - if(pf) pf.style.width = `${clamp(Number(p.progress||0),0,100)}%`; + if(pf) pf.style.width = `${clamp(Number(show.progress||0),0,100)}%`; const pv = document.getElementById(`pv-${id}`); - if(pv) pv.textContent = `${clamp(Number(p.progress||0),0,100).toFixed(0)}%`; + if(pv) pv.textContent = `${clamp(Number(show.progress||0),0,100).toFixed(0)}%`; const rm = document.getElementById(`rm-${id}`); - if(rm) rm.textContent = formatTime(Number(p.remaining_time||0)); + if(rm) rm.textContent = formatTime(Number(show.remaining_time||0)); const file = document.getElementById(`file-${id}`); - if(file) file.textContent = p.file || '-'; + if(file) file.textContent = show.file || '-'; const noz = document.getElementById(`noz-${id}`); - if(noz) noz.textContent = (p.nozzle_temp != null ? p.nozzle_temp : '-') + '°'; + if(noz) noz.textContent = (show.nozzle_temp != null ? show.nozzle_temp : '-') + '°'; const bed = document.getElementById(`bed-${id}`); - if(bed) bed.textContent = (p.bed_temp != null ? p.bed_temp : '-') + '°'; + if(bed) bed.textContent = (show.bed_temp != null ? show.bed_temp : '-') + '°'; + + const printedCell = document.getElementById(`printed-${id}`); + if(printedCell){ + const printed = printedTotalsByDevice[id]; + printedCell.textContent = printed ? `${gramsToNice(printed.g)} (${printed.mm.toFixed(0)}mm)` : '–'; + } - updateButtonsForStatus(p.device_id, p.status); + updateButtonsForStatus(show.device_id, show.status); // Update lichtknop icoon + aria { - const card = document.querySelector(`.printer-card[data-id="${p.device_id}"]`); - const lightBtn = card?.querySelector('.controls .control-btn[data-btn="light"]'); + const row = document.querySelector(`.printer-row[data-id="${show.device_id}"]`); + const lightBtn = row?.querySelector('.actions .control-btn[data-btn="light"]'); if(lightBtn){ - const on = isLightOn(p); + const on = isLightOn(show); lightBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); const icon = lightBtn.querySelector('i'); if(icon) icon.setAttribute('data-lucide', on ? 'lightbulb' : 'lightbulb-off'); @@ -585,19 +678,23 @@

Bambu Printfarm

function liveApply(freshList){ if(!Array.isArray(freshList)) return; - const hadCards = !!document.querySelector('.printer-card'); - if(!hadCards){ + const hadRows = !!document.querySelector('.printer-row'); + if(!hadRows){ printers = freshList; updateFarmCount(); renderPrinters(); return; } - // Update bestaande kaarten in-place - freshList.forEach(p => patchPrinterCard(p)); + // Update bestaande kaarten in-place + ververs lokale cache + freshList.forEach(p => { + const idx = printers.findIndex(x=>x.device_id===p.device_id); + if(idx !== -1){ printers[idx] = p; } + patchPrinterCard(p); + }); // Als set wijzigt → her-render - const currentIds = new Set([...document.querySelectorAll('.printer-card')].map(c=>c.dataset.id)); + const currentIds = new Set([...document.querySelectorAll('.printer-row')].map(r=>r.dataset.id)); if (freshList.length !== currentIds.size || freshList.some(p => !currentIds.has(p.device_id))) { printers = freshList; @@ -608,13 +705,16 @@

Bambu Printfarm

// ---- Command UX helpers function setStoppingUI(idx){ - const id = printers[idx].device_id; - const st = document.getElementById(`st-${id}`); - if(st){ - st.className = 'status-badge status-PAUSE'; - st.textContent = 'STOPPEN…'; - } - updateButtonsForStatus(id, 'PAUSE'); + const base = printers[idx]; + if(!base) return; + const id = base.device_id; + setStatusOverride(id, { + status: 'STOPPING', + progress: base.progress, + remaining_time: base.remaining_time + }, { ttlMs: 16000 }); + patchPrinterCard(base); + updateButtonsForStatus(id, 'STOPPING'); } async function waitForState(deviceId, maxMs=15000){ @@ -624,6 +724,8 @@

Bambu Printfarm

const fresh = await api('/api/printers'); const p = Array.isArray(fresh) ? fresh.find(x=>x.device_id===deviceId) : null; if(p){ + const idx = printers.findIndex(x=>x.device_id===deviceId); + if(idx !== -1){ printers[idx] = p; } patchPrinterCard(p); if(['IDLE','FINISH','FAILED','NO_CONN'].includes(p.status)) return p.status; } @@ -633,24 +735,70 @@

Bambu Printfarm

return null; } + function applyForcedIdle(deviceId){ + const idx = printers.findIndex(p=>p.device_id===deviceId); + if(idx === -1) return; + const base = printers[idx] || {}; + const next = Object.assign({}, base, { + status: 'IDLE', + progress: 0, + remaining_time: 0, + file: base.file || '-' + }); + printers[idx] = next; + setStatusOverride(deviceId, { + status: 'IDLE', + progress: 0, + remaining_time: 0 + }, { persistWhile: FAILURE_STATUSES }); + patchPrinterCard(next); + updateButtonsForStatus(deviceId, 'IDLE'); + } + // ---- Commands async function sendCommand(idx, cmd){ - const dev = printers[idx].device_id; + const base = printers[idx]; + if(!base) return; + const dev = base.device_id; + if(!dev) return; + if(COMMAND_LOCKS.has(dev)) return; + COMMAND_LOCKS.add(dev); try{ if(cmd==='pause_resume'){ await api(`/api/printers/${encodeURIComponent(dev)}/pause_resume`, {method:'POST'}); await waitForState(dev, 8000); } else if(cmd==='stop'){ setStoppingUI(idx); - await api(`/api/printers/${encodeURIComponent(dev)}/stop`, {method:'POST'}); - const st = await waitForState(dev, 15000); + const resp = await api(`/api/printers/${encodeURIComponent(dev)}/stop`, {method:'POST'}); + let st = null; + let forcedIdle = resp?.forced_idle === true; + if(!forcedIdle && isFailureStatus(base.status)){ + forcedIdle = true; + } + if(forcedIdle){ + applyForcedIdle(dev); + st = 'IDLE'; + } + if(st !== 'IDLE'){ + st = await waitForState(dev, 15000); + } + if(isFailureStatus(st)){ + applyForcedIdle(dev); + forcedIdle = true; + st = 'IDLE'; + } if(!st){ await refreshPrinters(); + } else if(!forcedIdle){ + clearOverride(dev); } } }catch(e){ alert('Commando mislukt: ' + (e?.message || e)); + clearOverride(dev); await refreshPrinters(); + }finally{ + COMMAND_LOCKS.delete(dev); } } @@ -693,63 +841,30 @@

Bambu Printfarm

setTimeout(refreshPrinters, 600); } - // ---- Camera snapshots - function setupCameraObserver(){ - if(camIO){ try{ camIO.disconnect(); }catch{} } - const rootEl = null; - - camIO = new IntersectionObserver(async (entries)=>{ - for(const ent of entries){ - const el = ent.target; - const idx = +el.dataset.idx; - const dev = printers[idx].device_id; - const img = el.querySelector('.video img'); - - if(ent.isIntersecting){ - try{ - const resp = await api(`/api/printers/${encodeURIComponent(dev)}/snapshot/start`, {method:'POST'}); - const url = resp?.snapshot_url || `/api/printers/${encodeURIComponent(dev)}/snapshot.jpg`; - mountSnapshot(dev, img, url); - }catch(err){ - const holder = el.querySelector('.video'); - if(holder) holder.textContent = 'Snapshot niet beschikbaar'; - } - } else { - try{ await api(`/api/printers/${encodeURIComponent(dev)}/snapshot/stop`, {method:'POST'}); }catch{} - unmountSnapshot(dev, img); - } - } - }, { root: rootEl, rootMargin: '0px 0px 120px 0px', threshold: 0.05 }); - - document.querySelectorAll('.printer-card').forEach((c)=> camIO.observe(c)); - } - - function mountSnapshot(dev, img, baseUrl){ - if(!img) return; - unmountSnapshot(dev, img); - const update = ()=> { img.src = `${baseUrl}?t=${Date.now()}`; }; - update(); - const id = setInterval(update, 1000); - snapshotControllers.set(dev, { intervalId: id, url: baseUrl }); - } - - function unmountSnapshot(dev, img){ - const ctl = snapshotControllers.get(dev); - if(ctl){ clearInterval(ctl.intervalId); snapshotControllers.delete(dev); } - if(img){ img.removeAttribute('src'); } - } - // ---- Render function renderPrinters(){ - const grid = document.getElementById('printersGrid'); - grid.innerHTML = printers.map((p, idx)=>{ + const wrap = document.getElementById('printersWrap'); + if(!wrap) return; + + if(!Array.isArray(printers) || printers.length===0){ + wrap.innerHTML = '
Geen printers gevonden
'; + lucide.createIcons(); + return; + } + + const rows = printers.map((p, idx)=>{ + const show = viewPrinter(p); const amsAvailable = !!p.ams; const cfg = cfgByDevice[p.device_id] || {tags:[], ip:null, model:''}; const tags = (cfg.tags||[]); const serial = p.device_id; const ip = cfg.ip || '-'; const id = p.device_id; - const statusLabel = (p.status === 'NO_CONN') ? 'Niet verbonden' : p.status; + const statusText = statusLabel(show.status); + const statusClass = (show.status || 'ONBEKEND').toString().replace(/[^A-Za-z0-9_-]/g,'_'); + const progress = clamp(Number(show.progress||0),0,100); + const printed = printedTotalsByDevice[p.device_id]; + const printedLabel = printed ? `${gramsToNice(printed.g)} (${printed.mm.toFixed(0)}mm)` : null; const amsPopover = amsAvailable ? `