From 4322b065c7859de5cec6bd963e56297e2933d64c Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 13 Jul 2026 22:01:55 +0200 Subject: [PATCH] lots of fixes and features --- config_loader.py | 3 + docker-compose.yml | 6 +- env_loader.py | 1 + kobrax_moonraker_bridge.py | 455 +++++++++++++++++++++++++++------- web/themes/default/app.js | 96 +++++-- web/themes/default/index.html | 5 + web/translations/de.json | 2 + web/translations/en.json | 2 + web/translations/es.json | 2 + web/translations/fr.json | 2 + web/translations/it.json | 2 + web/translations/zh-cn.json | 2 + 12 files changed, 471 insertions(+), 107 deletions(-) diff --git a/config_loader.py b/config_loader.py index 459942e..2682928 100644 --- a/config_loader.py +++ b/config_loader.py @@ -62,6 +62,7 @@ CONFIG_ENV_MAPPING = { "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"), + "FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"), "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), @@ -126,6 +127,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), "vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"), + "flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"), "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), "web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), } @@ -451,6 +453,7 @@ DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) diff --git a/docker-compose.yml b/docker-compose.yml index 773bebf..dca2276 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,15 @@ services: kx-bridge: - image: gitea.it-drui.de/viewit/kx-bridge:latest + # image: gitea.it-drui.de/viewit/kx-bridge:latest # Selbst bauen statt das Registry-Image zu pullen? # Dann image-Zeile auskommentieren und folgende aktivieren: - # build: . + build: . volumes: - ./config:/app/config - ./data:/app/data - ./.env:/app/.env:ro ports: - - "7125-7130:7125-7130" + - "7145:7125" # environment: # - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige) restart: unless-stopped diff --git a/env_loader.py b/env_loader.py index 97f37af..95df569 100644 --- a/env_loader.py +++ b/env_loader.py @@ -49,6 +49,7 @@ DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 8d3da7b..d00d0f3 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -593,10 +593,20 @@ class CameraCache: - MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot - MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers + A third, independent ffmpeg process produces: + - MJPEG @ 15fps/640px -> fanout to all /api/camera/stream subscribers + (the live-view used by the dashboard AND by every Moonraker-compatible + client, since server.webcams.list advertises this same stream_url) + Damit: - * Only ONE FLV connection to the printer (solves the single-client limit / 429) + * Only ONE FLV connection to the printer per output type (solves the + single-client limit / 429) - previously /api/camera/stream opened a + brand-new, uncached ffmpeg + printer connection per HTTP client, + which competed with the cached jpeg/h264 connections for the + printer's very limited number of concurrent camera clients and + caused intermittent "stream unavailable" failures. * Snapshots are instant (memory read, no ffmpeg spawn per request) - * Multiple parallel H.264 consumers possible (plugin + web UI + ...) + * Multiple parallel H.264/MJPEG consumers possible (plugin + web UI + ...) Lazy start on the first consumer, auto-restart on ffmpeg crash. """ @@ -610,33 +620,65 @@ class CameraCache: self.latest_jpeg: bytes = b"" self.latest_jpeg_ts: float = 0.0 self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set() + self.mjpeg_subscribers: "set[asyncio.Queue[bytes]]" = set() self._proc_jpeg: "asyncio.subprocess.Process | None" = None self._proc_h264: "asyncio.subprocess.Process | None" = None + self._proc_mjpeg: "asyncio.subprocess.Process | None" = None self._task_jpeg: "asyncio.Task | None" = None self._task_h264: "asyncio.Task | None" = None + self._task_mjpeg: "asyncio.Task | None" = None self._lock = asyncio.Lock() self._fail_count_jpeg: int = 0 self._fail_count_h264: int = 0 + self._fail_count_mjpeg: int = 0 def set_url(self, url: str): self._url = url def reset(self): - """Reset backoff counters and kill running ffmpeg processes.""" + """Reset backoff counters and forcefully tear down any running + ffmpeg loops - including cancelling their background tasks. + + Only killing the ffmpeg subprocess is not enough: the owning task + might currently be sitting in `await asyncio.sleep(delay)` from a + previous exponential backoff (up to 300s) after an earlier failure. + Resetting the fail-count doesn't wake it up early, so a user + clicking "reset" could see nothing happen for minutes. Cancelling + the task guarantees an immediate, clean restart on the next + ensure_running() call. + """ self._fail_count_jpeg = 0 self._fail_count_h264 = 0 - for proc in (self._proc_jpeg, self._proc_h264): + self._fail_count_mjpeg = 0 + for task in (self._task_jpeg, self._task_h264, self._task_mjpeg): + if task is not None and not task.done(): + task.cancel() + for proc in (self._proc_jpeg, self._proc_h264, self._proc_mjpeg): if proc is not None: try: proc.kill() except Exception: pass + self._task_jpeg = self._task_h264 = self._task_mjpeg = None + self._proc_jpeg = self._proc_h264 = self._proc_mjpeg = None async def ensure_running(self): - if self._proc_jpeg is None or self._proc_jpeg.returncode is not None: + # NOTE: we check the *task* state, not self._proc_* - the process + # handle is only assigned later, inside the task body, once ffmpeg + # has actually been spawned. Checking self._proc_* here left a race + # window: two callers arriving before the newly-created task got a + # chance to run would both see "no process yet" and each spawn a + # duplicate ffmpeg + duplicate printer connection, silently + # orphaning the older one (whichever task's coroutine runs last + # overwrites the shared self._proc_* reference, so nobody keeps a + # handle to kill the earlier orphaned process). Task creation is + # synchronous, so checking self._task_* here is race-free. + if self._task_jpeg is None or self._task_jpeg.done(): self._task_jpeg = asyncio.create_task(self._run_jpeg_loop()) - if self._proc_h264 is None or self._proc_h264.returncode is not None: + if self._task_h264 is None or self._task_h264.done(): self._task_h264 = asyncio.create_task(self._run_h264_loop()) + if self._task_mjpeg is None or self._task_mjpeg.done(): + self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop()) def _input_args(self, url: str) -> list[str]: args = ["-fflags", "nobuffer", "-flags", "low_delay"] @@ -654,6 +696,7 @@ class CameraCache: await asyncio.sleep(2.0) continue try: + log.info(f"CameraCache: ffmpeg-jpeg created for {url}") self._proc_jpeg = await asyncio.create_subprocess_exec( _find_ffmpeg(), "-loglevel", "warning", *self._input_args(url), "-i", url, @@ -729,6 +772,7 @@ class CameraCache: await asyncio.sleep(2.0) continue try: + log.info(f"CameraCache: ffmpeg-h264 created for {url}") self._proc_h264 = await asyncio.create_subprocess_exec( _find_ffmpeg(), "-loglevel", "warning", *self._input_args(url), "-i", url, @@ -790,6 +834,100 @@ class CameraCache: self._fail_count_h264 = 0 await asyncio.sleep(2.0) + async def _run_mjpeg_loop(self): + """Keeps an ffmpeg process alive that fans out MJPEG@15fps/640px + (complete JPEG frames) to all /api/camera/stream subscribers.""" + while True: + url = self._url + if not url: + await asyncio.sleep(2.0) + continue + try: + log.info(f"CameraCache: ffmpeg-mjpeg created for {url}") + proc = await asyncio.create_subprocess_exec( + _find_ffmpeg(), "-loglevel", "warning", + *self._input_args(url), "-i", url, + "-vf", "fps=15,scale=640:-1", + "-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3", + "-flush_packets", "1", "pipe:1", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + self._proc_mjpeg = proc + except Exception as e: + log.warning(f"CameraCache: ffmpeg-mjpeg start failed: {e}") + await asyncio.sleep(3.0) + continue + + buf = b"" + rc = None + try: + while True: + chunk = await proc.stdout.read(self.TS_CHUNK) + if not chunk: + break + buf += chunk + # extract complete JPEG frames and fan them out whole + # (so every subscriber gets clean multipart boundaries, + # not arbitrary byte chunks like the h264/mpegts fanout) + while True: + start = buf.find(self.JPEG_SOI) + if start == -1: + buf = b"" + break + end = buf.find(self.JPEG_EOI, start + 2) + if end == -1: + buf = buf[start:] + break + frame = buf[start:end + 2] + buf = buf[end + 2:] + for q in list(self.mjpeg_subscribers): + if q.full(): + try: + q.get_nowait() + except Exception: + pass + try: + q.put_nowait(frame) + except Exception: + pass + except Exception as e: + log.debug(f"CameraCache: mjpeg-loop unterbrochen: {e}") + finally: + # NOTE: cleanup operates on the local `proc` reference, not + # on self._proc_mjpeg. If this task got cancelled (e.g. by + # reset()) a new task may already have started and assigned + # its own process to self._proc_mjpeg by the time we reach + # here - killing that shared attribute instead of our own + # local proc would kill the WRONG (newer) process. + try: + proc.kill() + except Exception: + pass + try: + await proc.wait() + except Exception: + pass + rc = proc.returncode + if rc: + try: + err = await proc.stderr.read(500) + if err: + log.warning(f"CameraCache: ffmpeg-mjpeg stderr: {err.decode(errors='replace').strip()}") + except Exception: + pass + # Only clear the shared handle if it's still ours. + if self._proc_mjpeg is proc: + self._proc_mjpeg = None + if rc: + self._fail_count_mjpeg += 1 + delay = min(2.0 * (2 ** self._fail_count_mjpeg), 300.0) + log.warning(f"CameraCache: ffmpeg-mjpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_mjpeg})") + await asyncio.sleep(delay) + else: + self._fail_count_mjpeg = 0 + await asyncio.sleep(2.0) + class SpoolmanClient: """Thin synchronous HTTP client for Spoolman filament tracking. @@ -888,6 +1026,8 @@ class KobraXBridge: "filament_mode": "toolhead", "supplies_usage": 0, "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None}, + "error_code": 0, + "pause_msg": "", } self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot @@ -1180,8 +1320,10 @@ class KobraXBridge: def _on_print(self, payload: dict): d = payload.get("data") or {} kobra_state = payload.get("state", "") - self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing") + if kobra_state: + self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing") + if kobra_state != "" and kobra_state != "updated": self._state["kobra_state"] = kobra_state # Automatically switch on the camera at print start (settings option). @@ -1200,6 +1342,17 @@ class KobraXBridge: elif kobra_state in ("free", "finished", "stoped", "canceled"): self._camera_autostarted = False self._camera_user_stopped = False # release for the next print + + if kobra_state in ("pause", "paused"): + pause_msg = payload.get("msg", "") + if pause_msg: + error_code = payload.get("code", 0) + self._state["error_code"] = error_code + self._state["pause_msg"] = pause_msg + log.warning(f"Printer paused: [{error_code}] {pause_msg}") + elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"): + self._state["error_code"] = 0 + self._state["pause_msg"] = "" # Job-History: Druckstart erkennen if kobra_state == "printing" and not self._current_job_id: @@ -1279,7 +1432,8 @@ class KobraXBridge: kobra_state = proj_state or d.get("state", "") if kobra_state: self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby") - self._state["kobra_state"] = kobra_state + if kobra_state != "" and kobra_state != "updated": + self._state["kobra_state"] = kobra_state # Hide the upload banner after the print ends (Issue #29) - the state also # arrives via info/report (project.state) depending on the printer, not only print/report. if kobra_state in ("finished", "stoped", "canceled"): @@ -1591,7 +1745,41 @@ class KobraXBridge: return [int(color[0]), int(color[1]), int(color[2]), 255] return [255, 255, 255, 255] - def _build_auto_ams_box_mapping( + @staticmethod + def _hex_to_rgba(hex_color: str | None) -> list[int]: + """Convert a '#RRGGBB' (or 'RRGGBB') string from GCode metadata to [r,g,b,255].""" + if not hex_color: + return [255, 255, 255, 255] + h = str(hex_color).strip().lstrip("#") + if len(h) < 6: + return [255, 255, 255, 255] + try: + return [int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255] + except ValueError: + return [255, 255, 255, 255] + + @staticmethod + def _gcode_filament_by_index(gcode_filaments: list[dict] | None) -> dict[int, dict]: + """Index parsed GCode filament metadata (_extract_filament_info) by paint/slot index.""" + by_index: dict[int, dict] = {} + if not gcode_filaments: + return by_index + for f in gcode_filaments: + try: + by_index[int(f.get("slot_index"))] = f + except (TypeError, ValueError): + continue + return by_index + + def _color_distance(self, rgb1: list[int], rgb2: list[int]) -> float: + """Calculate RGB Euclidean distance.""" + return ( + (rgb1[0] - rgb2[0]) ** 2 + + (rgb1[1] - rgb2[1]) ** 2 + + (rgb1[2] - rgb2[2]) ** 2 + ) ** 0.5 + + def _build_auto_ams_box_mapping_old( self, warn_on_empty_default: bool = False, loaded_slots: list[tuple[int, dict]] | None = None, @@ -1627,6 +1815,128 @@ class KobraXBridge: }) return result + + def _build_auto_ams_box_mapping( + self, + warn_on_empty_default: bool = False, + loaded_slots: list[tuple[int, dict]] | None = None, + gcode_filaments: list[dict] | None = None, + ) -> list[dict]: + """Build print mapping by matching G-code filament colors + with currently loaded AMS slots using RGB distance. + """ + + # IMPORTANT: loaded_slots is intentionally ignored whenever gcode_filaments + # is provided. Color matching needs access to ALL currently loaded slots, + # not just the ones whose index happens to coincide with the gcode's + # paint_index - otherwise a black filament physically loaded in slot 3 + # could never be matched to gcode paint_index 1 just because slot 1 + # holds a different color. loaded_slots is only used as a fallback pool + # when the caller has no gcode filament info to match colors against + # (see _build_auto_ams_box_mapping_old for that plain, index-based case). + loaded = loaded_slots if ( gcode_filaments is None or len(gcode_filaments) == 0 ) else None + if loaded is None: + loaded = self._select_loaded_slots_for_print( warn_on_empty_default=warn_on_empty_default ) + + if not loaded: + return [] + + loaded_map = {gidx: s for gidx, s in loaded} + log.debug(f"Loaded map: {loaded_map}") + + gcode_by_index = self._gcode_filament_by_index(gcode_filaments) + log.debug(f"GCode filaments by index: {gcode_by_index}") + + # Slots available for assignment. + available_slots = list(loaded_map.items()) + + result = [] + + max_idx = max(gcode_by_index.keys(), default=-1) + + # Maximum RGB distance: + # sqrt(255² + 255² + 255²) = 441 + # A threshold of 80–100 is reasonable for visually similar colors. + MAX_COLOR_DISTANCE = 100 + + for paint_index in range(max_idx + 1): + req = gcode_by_index.get(paint_index) + + paint_color = ( + self._hex_to_rgba(req.get("color_hex")) + if req else + [255, 255, 255, 255] + ) + + default_material = ( + req.get("material", "PLA") + if req else + "PLA" + ) + + best_match = None + best_distance = float("inf") + + if req: + requested_rgb = paint_color[:3] + + for slot_idx, slot in available_slots: + slot_color = slot.get("color") + + if not slot_color or len(slot_color) < 3: + continue + + # Skip slots with a different material type. + if slot.get("type") != default_material: + continue + + distance = self._color_distance( + requested_rgb, + slot_color[:3], + ) + + if distance < best_distance: + best_distance = distance + best_match = (slot_idx, slot) + + if best_match and best_distance <= MAX_COLOR_DISTANCE: + slot_idx, slot = best_match + + available_slots.remove(best_match) + + log.debug( + f"Matched paint index {paint_index}: " + f"GCODE={paint_color[:3]} " + f"AMS={slot.get('color')} " + f"distance={best_distance:.2f} " + f"slot={slot_idx}" + ) + + result.append({ + "paint_index": paint_index, + "ams_index": self._slot_to_print_ams_index(slot_idx), + "paint_color": paint_color, + "ams_color": self._slot_color_rgba(slot), + "material_type": slot.get("type", default_material), + }) + + else: + log.debug( + f"No AMS match for paint index {paint_index}: " + f"color={paint_color[:3]} " + f"best_distance={best_distance:.2f}" + ) + + result.append({ + "paint_index": paint_index, + "ams_index": self._slot_to_print_ams_index(paint_index), + "paint_color": paint_color, + "ams_color": [255, 255, 255, 255], + "material_type": default_material, + }) + + return result + def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: """Build print mapping from UI filament assignments. @@ -2958,7 +3268,7 @@ class KobraXBridge: return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) else: # No dialog -> all occupied slots as with a normal upload print - ams_box_mapping = self._build_auto_ams_box_mapping() + ams_box_mapping = self._build_auto_ams_box_mapping_old() auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) filename = gcode_file["filename"] @@ -3453,7 +3763,7 @@ class KobraXBridge: "task_settings": { "auto_leveling": auto_leveling, "vibration_compensation": getattr(self._args, "vibration_compensation", 0), - "flow_calibration": 0, + "flow_calibration": getattr(self._args, "flow_calibration", 0), "dry_mode": 0, "ai_settings": {"status": 0, "count": 0, "type": ai_type}, "timelapse": {"status": 0, "count": 0, "type": timelapse_type}, @@ -3496,7 +3806,12 @@ class KobraXBridge: # used slots; used-but-unloaded -> a warning may follow later. loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] - ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) + used_gcode_filaments = [ + f for f in (gcode_filaments or []) + if used_paint_indices is not None and f.get("slot_index") in used_paint_indices + ] + + ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded, gcode_filaments=used_gcode_filaments) log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}") payload = self._build_print_payload( filename, url, md5, filesize, @@ -3573,7 +3888,7 @@ class KobraXBridge: # filter applies and empty/shifted slots are not mapped incorrectly. gcode_filaments = None try: - db_file = self._db.get_file_by_name(filename) + db_file = self._store.get_file_by_name(filename) if db_file and db_file.get("gcode_filaments"): gcode_filaments = json.loads(db_file["gcode_filaments"]) except Exception as e: @@ -4065,10 +4380,15 @@ class KobraXBridge: Useful after a 429 lock (Retry-After expired) or after a printer restart.""" self.camera_cache.reset() url = self._state.get("camera_url", "") - if url: - self.camera_cache.set_url(url) - await self.camera_cache.ensure_running() - return web.json_response({"result": "ok"}) + if not url: + log.warning("Camera reset requested but no camera_url is known yet (waiting for printer status)") + return web.json_response({ + "result": "no_url", + "message": "No camera URL known yet - wait for the next printer status update, or start a print/enable the camera first.", + }) + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() + return web.json_response({"result": "ok", "url": url}) async def handle_api_camera_snapshot(self, request): """Last JPEG frame from the CameraCache - instant from RAM, @@ -4095,43 +4415,26 @@ class KobraXBridge: return web.Response(body=jpeg, content_type="image/jpeg", headers=headers) async def handle_camera_stream(self, request): - """MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace.""" + """MJPEG live view, served as multipart/x-mixed-replace. + + Fed from the central CameraCache fanout (same pattern as + handle_camera_h264) instead of spawning a dedicated ffmpeg process + per HTTP client. The printer's camera server only tolerates a very + limited number of concurrent connections (see CameraCache docstring) + - previously every consumer of this endpoint (dashboard, OrcaSlicer, + moonraker-obico, a second browser tab, ...) opened its own separate + connection, so two simultaneous viewers could already exhaust the + printer's connection limit and cause intermittent "stream + unavailable" failures. Now all consumers share one connection. + """ url = self._state.get("camera_url", "") if not url: return web.Response(status=503, text="Keine Kamera-URL bekannt") + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() - is_rtsp = url.lower().startswith("rtsp://") - ffmpeg_input_args = [ - "-fflags", "nobuffer", - "-flags", "low_delay", - ] - if is_rtsp: - ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] - else: - ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"] - - # Start ffmpeg BEFORE the StreamResponse is opened - # (so we can still send a normal HTTP response on error) - try: - proc = await asyncio.create_subprocess_exec( - _find_ffmpeg(), "-loglevel", "quiet", - *ffmpeg_input_args, - "-i", url, - "-vf", "fps=15,scale=640:-1", - "-f", "image2pipe", - "-vcodec", "mjpeg", - "-q:v", "3", - "-flush_packets", "1", - "pipe:1", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) - except (FileNotFoundError, OSError) as e: - log.warning("Camera: ffmpeg not found – camera stream unavailable") - return web.Response(status=503, text="ffmpeg not found") - except Exception as e: - log.warning(f"Camera: could not start ffmpeg: {e}") - return web.Response(status=503, text=str(e)) + q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8) + self.camera_cache.mjpeg_subscribers.add(q) boundary = "kobraxframe" resp = web.StreamResponse(headers={ @@ -4140,46 +4443,24 @@ class KobraXBridge: "Connection": "keep-alive", }) await resp.prepare(request) - - buf = b"" try: while True: - chunk = await proc.stdout.read(65536) - if not chunk: + frame = await q.get() + header = ( + f"--{boundary}\r\n" + f"Content-Type: image/jpeg\r\n" + f"Content-Length: {len(frame)}\r\n\r\n" + ).encode() + try: + await resp.write(header + frame + b"\r\n") + except (ConnectionResetError, asyncio.CancelledError): + break + except Exception: break - buf += chunk - # Extract complete JPEG frames (SOI=FFD8, EOI=FFD9) - while True: - start = buf.find(b"\xff\xd8") - if start == -1: - buf = b"" - break - end = buf.find(b"\xff\xd9", start + 2) - if end == -1: - buf = buf[start:] - break - frame = buf[start:end + 2] - buf = buf[end + 2:] - header = ( - f"--{boundary}\r\n" - f"Content-Type: image/jpeg\r\n" - f"Content-Length: {len(frame)}\r\n\r\n" - ).encode() - try: - await resp.write(header + frame + b"\r\n") - except Exception: - return resp except Exception as e: log.warning(f"Camera stream interrupted: {e}") finally: - try: - proc.kill() - except Exception: - pass - try: - await proc.wait() - except Exception: - pass + self.camera_cache.mjpeg_subscribers.discard(q) return resp @@ -4268,6 +4549,7 @@ class KobraXBridge: "print_speed_mode": s["print_speed_mode"], "auto_leveling": getattr(self._args, "auto_leveling", 1), "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "flow_calibration": getattr(self._args, "flow_calibration", 0), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), "light_on": s["light_on"], @@ -4284,6 +4566,8 @@ class KobraXBridge: "file_ready": s["file_ready"], "print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)), "version": self._read_version(), + "pause_msg": s.get("pause_msg", ""), + "error_code": s.get("error_code", 0), }) async def handle_moonraker_database(self, request): @@ -4420,6 +4704,7 @@ class KobraXBridge: "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), "auto_leveling": getattr(self._args, "auto_leveling", 1), "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "flow_calibration": getattr(self._args, "flow_calibration", 0), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), "print_start_dialog": getattr(self._args, "print_start_dialog", 1), @@ -4458,6 +4743,7 @@ class KobraXBridge: cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto")))) cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) + cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 0)))))) cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) @@ -5602,6 +5888,7 @@ def main(): parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT) parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION) + parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION) parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING) parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG) diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 7d11be4..921426b 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -2915,29 +2915,85 @@ function openFilamentDialog(slots){ var dr=ar[0]-br[0], dg=ar[1]-br[1], db=ar[2]-br[2]; return (dr*dr + dg*dg + db*db); } - var defaultSlotByPaint={}; - var usedDefaultSlot={}; - channels.forEach(function(gc,i){ + + // Globale, farboptimale Zuordnung Kanal -> Slot. + // Anforderungen: 1) Material muss passen (harter Filter, wie bisher) + // 2) Farbe so nah wie möglich + // 3) Position spielt praktisch keine Rolle mehr + // Ein rein gieriger Ansatz (Kanal für Kanal den "nächsten" Slot wählen) + // kann bei knappen/mehrdeutigen Slots suboptimale Gesamtlösungen liefern + // (z.B. Kanal A "verbraucht" den Slot, der eigentlich viel besser zu + // Kanal B gepasst hätte). Da ein AMS in der Praxis nur wenige Slots hat + // (üblich: bis zu 4), lohnt sich Brute-Force über alle möglichen + // Zuordnungen mit Pruning - liefert garantiert das global beste Ergebnis, + // ganz ohne einen vollen Hungarian-Algorithmus implementieren zu müssen. + function _bestAssignment(slotsPerChannel){ + var n=slotsPerChannel.length; + var assign=new Array(n).fill(-1); + + // Sicherheitsnetz für unrealistisch viele Kanäle: gieriger Fallback + // (kleinster Farbabstand zuerst bedient), um exponentielle Laufzeit zu + // vermeiden. Greift in der Praxis nie, ein AMS hat max. wenige Slots. + if(n>8){ + var used={}; + var order=slotsPerChannel.map(function(_,idx){return idx;}).sort(function(a,b){ + var da=slotsPerChannel[a].length?slotsPerChannel[a][0].dist:Infinity; + var db=slotsPerChannel[b].length?slotsPerChannel[b][0].dist:Infinity; + return da-db; + }); + order.forEach(function(i){ + var cands=slotsPerChannel[i]; + if(!cands.length)return; + var pick=cands.find(function(c){return !used[c.slot_index];})||cands[0]; + assign[i]=pick.slot_index; + used[pick.slot_index]=true; + }); + return assign; + } + + var bestCost=Infinity, bestAssign=assign.slice(), used={}; + (function backtrack(idx,cost){ + if(cost>=bestCost)return; // Pruning: kann eh nicht mehr besser werden + if(idx===n){ bestCost=cost; bestAssign=assign.slice(); return; } + var candidates=slotsPerChannel[idx]; + if(!candidates.length){ + assign[idx]=-1; + backtrack(idx+1,cost); + return; + } + var placedFree=false; + for(var k=0;k Mehrfachbelegung des + // farblich besten kompatiblen Slots zulassen (besser als leer) + var best=candidates[0]; + assign[idx]=best.slot_index; + backtrack(idx+1,cost+best.dist); + } + assign[idx]=-1; + })(0,0); + return bestAssign; + } + + var slotsPerChannel=channels.map(function(gc){ var compatible=_amsSlots.filter(function(s){ return _materialsCompatible(gc.material, s.material); }); - if(!compatible.length){ - defaultSlotByPaint[i]=-1; - return; - } - - var ranked=compatible.slice().sort(function(a,b){ - var da=Math.abs((a.slot_index||0)-i), db=Math.abs((b.slot_index||0)-i); - if(da!==db)return da-db; - var ca=_colorDist(gc.color_hex, a.color_hex), cb=_colorDist(gc.color_hex, b.color_hex); - if(ca!==cb)return ca-cb; - return (a.slot_index||0)-(b.slot_index||0); - }); - - var chosen=ranked.find(function(s){return !usedDefaultSlot[s.slot_index];}) || ranked[0]; - defaultSlotByPaint[i]=chosen?chosen.slot_index:-1; - if(chosen) usedDefaultSlot[chosen.slot_index]=1; + return compatible.map(function(s){ + return {slot_index:s.slot_index, dist:_colorDist(gc.color_hex, s.color_hex)}; + }).sort(function(a,b){ return a.dist-b.dist; }); }); + var _assignResult=_bestAssignment(slotsPerChannel); + var defaultSlotByPaint={}; + channels.forEach(function(gc,i){ defaultSlotByPaint[i]=_assignResult[i]; }); if(!_amsSlots.length){ body.innerHTML='

'+T.fd_no_slots_msg.replace('{br}','
')+'

'; @@ -3391,4 +3447,4 @@ function loadPrinterTab(){ }).catch(function(e){ if(grid)grid.innerHTML='
Fehler: '+e+'
'; }); -} +} \ No newline at end of file diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 3df8fcd..52656a1 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -10,6 +10,7 @@ + +