diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 973a812..f3f3667 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,8 +1,4 @@ ## Changes in this build -- Fix: the poll interval setting was saved to config.ini but never actually applied — the poll loop always used a hardcoded 3s wait -- Fix: Spoolman status showed a green "connected" dot even when the server was unreachable — reachability is now rechecked periodically and shown accurately (green/red) -- Fix: **settings could silently revert after saving** — the bridge restart that applies settings didn't clean up all the relevant environment variables, so the old value could win over the one just saved (affected poll interval, resonance compensation, Docker host IP, and the new HTTP log toggle below). Fixed at the root so this class of bug can't recur for future settings. -- Feat: new "Log every HTTP request (verbose)" toggle in Settings — off by default, since aiohttp's per-request access log was drowning out the bridge's own logs with the frontend's 2s polling - -**A stable release is coming soon** with the fixes and features from the last several nightly builds. As usual it will be published as ready-to-run binaries (Linux amd64/arm64, Windows) alongside the Docker image. +- Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs) +- Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 5e10af3..bf321da 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -588,15 +588,23 @@ class GCodeStore: class CameraCache: """Zentraler Kamera-Demuxer. - Keeps ONE ffmpeg process open that reads the FLV stream from the printer - and produces two outputs in parallel: + Keeps ONE ffmpeg process per output type open that reads the FLV stream + from the printer and produces: - MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot - MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers + - 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,40 +618,77 @@ 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"] if url.lower().startswith("rtsp://"): args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] else: - args += ["-probesize", "500000", "-analyzeduration", "500000"] + # The printer's FLV source occasionally emits non-monotonic container + # timestamps (PTS jumps of days) while the video data itself stays + # valid. Without this flag ffmpeg's realtime pacing breaks on such a + # jump and the stream stalls after ~15-30 min (Issue #90). + args += ["-use_wallclock_as_timestamps", "1", + "-probesize", "500000", "-analyzeduration", "500000"] return args async def _run_jpeg_loop(self): @@ -790,6 +835,98 @@ 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: + 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 + 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 +1025,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 @@ -1390,6 +1529,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: @@ -1648,7 +1798,10 @@ class KobraXBridge: Modes: - toolhead: only toolhead slots - - ace_direct: ACE channels directly mapped (no toolhead box present) + - ace_direct: ACE channels directly mapped, no toolhead box present. + Covers one unit (Kobra X) as well as multiple daisy-chained units + (Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a + block of 4 global slots at box_id * 4. - ace_hub: toolhead + ACE via hub (slot 4 as hub path) """ toolhead = any(b.get("id") == -1 for b in boxes) @@ -1684,19 +1837,22 @@ class KobraXBridge: return global_slots, global_loaded if mode == "ace_direct": - # ace_direct exposes exactly 4 channels total. - # If firmware reports multiple ACE boxes, keep only the first one. - if ace_boxes: - ace = ace_boxes[0] - ace_id = ace["id"] - for local_idx, s in enumerate((ace.get("slots") or [])[:4]): - s = dict(s) - s["global_index"] = local_idx - s["box_id"] = ace_id - global_slots.append(s) - ace_loaded = ace.get("loaded_slot", -1) - if 0 <= ace_loaded < 4: - global_loaded = ace_loaded + # One or more ACE units, no toolhead buffer (Kobra X: 1 unit, + # Kobra S1: up to 2+ units, Issue #95). Global index = + # box_id * 4 + local slot, so the numbering matches + # _global_to_box_slot's //4-%4 fallback and stays stable + # regardless of report order. + for ace in ace_boxes: + ace_id = int(ace["id"]) + base = ace_id * 4 + for local_idx, s in enumerate((ace.get("slots") or [])[:4]): + s = dict(s) + s["global_index"] = base + local_idx + s["box_id"] = ace_id + global_slots.append(s) + ace_loaded = ace.get("loaded_slot", -1) + if 0 <= ace_loaded < 4: + global_loaded = base + ace_loaded return global_slots, global_loaded # ace_hub @@ -1887,20 +2043,18 @@ class KobraXBridge: if box_id == -1: return local_slot if self._filament_mode == "ace_direct": - return local_slot + # Multi-ACE (Issue #95): each unit occupies its own block of 4. + # Identical to the old `return local_slot` for a single unit (id 0). + return box_id * 4 + local_slot return 3 + box_id * 4 + local_slot def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict: """Build {global_slot_index: loading|unloading} from feed_status data.""" + # Note: all boxes are considered — the old primary_ace_id filter (skip + # every ACE box except the first in ace_direct mode) is gone since the + # slot aggregation now handles multiple ACE units (Issue #95). activity: dict = {} - primary_ace_id = -1 - if self._filament_mode == "ace_direct": - ace_ids = sorted(int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0) - if ace_ids: - primary_ace_id = ace_ids[0] for box in boxes: - if self._filament_mode == "ace_direct" and primary_ace_id >= 0 and int(box.get("id", -1)) != primary_ace_id: - continue fs = box.get("feed_status") or {} current_status = int(fs.get("current_status", -1)) local_slot = int(fs.get("slot_index", -1)) @@ -3842,7 +3996,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: @@ -3972,21 +4126,33 @@ class KobraXBridge: # (only the bare HTML) -> without inlining neither # a single button works there (Issue #29). It is equally correct in a normal browser. base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme) - try: - css = pathlib.Path(os.path.join(base, "style.css")).read_text(encoding="utf-8") - page = page.replace( - '', - "") - except OSError: - pass - try: - js = pathlib.Path(os.path.join(base, "app.js")).read_text(encoding="utf-8") - js = js.replace("__VERSION__", self._read_version()) - page = page.replace( - '', - "") - except OSError: - pass + + # Inline vendored lib CSS/JS too — the OrcaSlicer webview loads no + # external /") + except OSError: + pass + + _inline_css("lib/gridstack.min.css", '') + _inline_js("lib/gridstack-all.min.js", '') + _inline_css("style.css", '') + _inline_js("app.js", '', version_sub=True) return web.Response(text=page, content_type="text/html", headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) @@ -4334,10 +4500,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, @@ -4345,10 +4516,10 @@ class KobraXBridge: printer and is ~1 s faster).""" url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + return web.Response(status=503, text="No camera URL known") self.camera_cache.set_url(url) await self.camera_cache.ensure_running() - # Initialer Warmup: bis zu 5 s auf ersten Frame warten + # Initial warmup: wait up to 5s for the first frame deadline = time.time() + 5.0 while not self.camera_cache.latest_jpeg and time.time() < deadline: await asyncio.sleep(0.1) @@ -4364,43 +4535,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") + return web.Response(status=503, text="No camera URL known") + 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={ @@ -4409,46 +4563,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 @@ -4458,7 +4590,7 @@ class KobraXBridge: additional FLV connection to the printer (single-client limit).""" url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + return web.Response(status=503, text="No camera URL known") self.camera_cache.set_url(url) await self.camera_cache.ensure_running() @@ -4553,6 +4685,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): diff --git a/tests/conftest.py b/tests/conftest.py index efa71fc..25bd6a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,13 @@ Shared fixtures für KX-Bridge Tests. Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig). """ -import sys, types, argparse, pytest, pytest_asyncio +import sys, types, argparse, tempfile, pytest, pytest_asyncio from unittest.mock import MagicMock from aiohttp.test_utils import TestClient, TestServer # ── Pfad ────────────────────────────────────────────────────────────────────── -sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge")) +# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root. +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent)) # ── env_loader mocken (keine .env nötig) ────────────────────────────────────── env_mod = types.ModuleType("env_loader") @@ -41,6 +42,7 @@ def make_args(**overrides): device_id = "", host = "127.0.0.1", port = 7125, + data_dir = tempfile.mkdtemp(prefix="kxtest-"), ) for k, v in overrides.items(): setattr(args, k, v) diff --git a/tests/test_multi_ace_slots.py b/tests/test_multi_ace_slots.py new file mode 100644 index 0000000..fcba238 --- /dev/null +++ b/tests/test_multi_ace_slots.py @@ -0,0 +1,117 @@ +"""Multi-ACE aggregation in ace_direct mode (Issue #95, Kobra S1). + +A Kobra S1 with two daisy-chained ACE Pro units reports +multi_color_box = [{id:0, slots:[4]}, {id:1, slots:[4]}] with NO toolhead +entry (id:-1). The old ace_direct branch kept only ace_boxes[0], silently +dropping the second unit — the dashboard and the OrcaSlicer sync only ever +saw 4 of the 8 slots. Payloads below are trimmed from the real log attached +to the issue. +""" +from kobrax_moonraker_bridge import KobraXBridge + + +def _slot(index, type_="PLA", color=(1, 2, 3), status=5): + return { + "index": index, "sku": "", "type": type_, "color": list(color), + "edit_status": 0, "status": status, + "color_group": [list(color) + [255]], "icon_type": 0, + "consumables_percent": 50, + } + + +def _ace_box(box_id, loaded_slot=-1, n_slots=4): + return { + "id": box_id, "status": 1, "model_id": 0, "auto_feed": 1, + "loaded_slot": loaded_slot, + "feed_status": {"code": 200, "type": -1, "current_status": -1, "slot_index": -1}, + "temp": 30, "humidity": 0, + "drying_status": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, + "slots": [_slot(i) for i in range(n_slots)], + } + + +def _toolhead_box(n_slots=4): + box = _ace_box(-1, n_slots=n_slots) + return box + + +# ── mode detection ─────────────────────────────────────────────────────────── + +def test_two_ace_units_no_toolhead_is_ace_direct(): + boxes = [_ace_box(0), _ace_box(1)] + assert KobraXBridge._detect_filament_mode(boxes) == "ace_direct" + + +# ── ace_direct aggregation ─────────────────────────────────────────────────── + +def test_single_ace_unit_yields_4_slots(): + """Kobra X regression: one unit, global indices 0-3 exactly as before.""" + slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0)], "ace_direct") + assert len(slots) == 4 + assert [s["global_index"] for s in slots] == [0, 1, 2, 3] + assert all(s["box_id"] == 0 for s in slots) + assert loaded == -1 + + +def test_two_ace_units_yield_8_slots(): + """Issue #95: the second unit's slots must appear as global 4-7.""" + slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct") + assert len(slots) == 8 + assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7] + assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1] + + +def test_two_ace_units_report_order_does_not_matter(): + """Boxes sorted by id — global numbering stays stable if the firmware + reports unit 1 before unit 0.""" + slots, _ = KobraXBridge._aggregate_slots([_ace_box(1), _ace_box(0)], "ace_direct") + assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7] + assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1] + + +def test_loaded_slot_on_second_unit_maps_to_global(): + slots, loaded = KobraXBridge._aggregate_slots( + [_ace_box(0), _ace_box(1, loaded_slot=2)], "ace_direct") + assert loaded == 6 # 1*4 + 2 + + +def test_loaded_slot_on_first_unit_unchanged(): + slots, loaded = KobraXBridge._aggregate_slots( + [_ace_box(0, loaded_slot=3), _ace_box(1)], "ace_direct") + assert loaded == 3 + + +# ── ace_hub regression (unchanged behavior) ────────────────────────────────── + +def test_ace_hub_numbering_unchanged(): + boxes = [_toolhead_box(), _ace_box(0), _ace_box(1)] + assert KobraXBridge._detect_filament_mode(boxes) == "ace_hub" + slots, _ = KobraXBridge._aggregate_slots(boxes, "ace_hub") + # 3 toolhead + 4 + 4 ACE + assert len(slots) == 11 + assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + assert [s["box_id"] for s in slots][:3] == [-1, -1, -1] + + +# ── _box_local_to_global / _global_to_box_slot round-trip ──────────────────── + +def _bridge_with_mode(mode, slots): + b = object.__new__(KobraXBridge) + b._filament_mode = mode + b._ams_slots = slots + return b + + +def test_box_local_to_global_ace_direct_second_unit(): + b = _bridge_with_mode("ace_direct", []) + assert b._box_local_to_global(0, 2, []) == 2 + assert b._box_local_to_global(1, 2, []) == 6 + + +def test_global_to_box_slot_round_trip_two_units(): + slots, _ = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct") + b = _bridge_with_mode("ace_direct", slots) + for g in range(8): + box_id, local = b._global_to_box_slot(g) + assert (box_id, local) == (g // 4, g % 4) + assert b._box_local_to_global(box_id, local, []) == g diff --git a/tests/test_settings.py b/tests/test_settings.py index ef0b149..f0f16e8 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured): @pytest.mark.asyncio -async def test_settings_post_writes_env(client): - """POST /api/settings schreibt Werte in .env-Datei.""" +async def test_settings_post_writes_config_ini(client): + """POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x).""" c, bridge = client with tempfile.TemporaryDirectory() as tmpdir: - env_path = pathlib.Path(tmpdir) / ".env" - env_path.write_text("") - bridge._find_env_path = lambda: env_path + config_path = pathlib.Path(tmpdir) / "config.ini" + bridge._find_config_path = lambda: config_path + bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process resp = await c.post("/api/settings", json={ "printer_ip": "10.0.0.5", @@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client): }) assert resp.status == 200 - content = env_path.read_text() - assert "PRINTER_IP=10.0.0.5" in content - assert "MQTT_USERNAME=userABCD" in content - assert "DEVICE_ID=deadbeef01234567" in content + content = config_path.read_text() + assert "printer_ip = 10.0.0.5" in content + assert "username = userABCD" in content + assert "device_id = deadbeef01234567" in content @pytest.mark.asyncio async def test_settings_post_preserves_existing_keys(client): - """POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN).""" + """POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server).""" c, bridge = client with tempfile.TemporaryDirectory() as tmpdir: - env_path = pathlib.Path(tmpdir) / ".env" - env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n") - bridge._find_env_path = lambda: env_path + config_path = pathlib.Path(tmpdir) / "config.ini" + config_path.write_text( + "[spoolman]\nserver = http://192.168.1.50:7912\n\n" + "[connection]\nprinter_ip = old\n" + ) + bridge._find_config_path = lambda: config_path + bridge._restart_bridge = lambda: None await c.post("/api/settings", json={"printer_ip": "10.0.0.99"}) - content = env_path.read_text() - assert "GITEA_TOKEN=mytoken" in content - assert "PRINTER_IP=10.0.0.99" in content + content = config_path.read_text() + assert "server = http://192.168.1.50:7912" in content + assert "printer_ip = 10.0.0.99" in content diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 67c6bf2..dbbbe61 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -394,41 +394,36 @@ function applyLang() { bnb = document.getElementById("bnb-store"); if (bnb) bnb.lastChild.textContent = T.nav_browser; // Browser panel - setText("printers-panel-title", "🖨 " + T.nav_printers); - setText("add-printer-btn-label", T.add_printer); - setText("apd-title", T.add_printer); - setText("skip-title", T.skip_title); - setText("skip-hint", T.skip_hint); - setText("d-btn-skip-label", T.skip_btn_label); - setText("fd-objects-toggle-lbl", T.fd_objects_toggle); - setText("apd-lbl-ip", T.apd_lbl_ip); - setText("apd-lbl-name", T.apd_lbl_name); - var apn = document.getElementById("apd-name"); - if (apn) apn.setAttribute("placeholder", T.apd_placeholder_name); - setText("apd-cancel", T.apd_cancel); - setText("apd-confirm", T.apd_confirm); - setText("fd-slots-hint", T.fd_slots_hint); - setText("fd-cancel", T.fd_cancel); - setText("fd-print", T.fd_print); - setText("store-panel-title", "🗂 " + T.panel_browser_title); - var srb = document.getElementById("store-refresh-btn"); - if (srb) srb.textContent = T.store_refresh; - var ssp = document.getElementById("store-search"); - if (ssp) ssp.setAttribute("placeholder", T.store_search_placeholder); - setText("store-upload-label-prefix", T.store_upload_label_prefix); - setText("store-upload-label-browse", T.store_upload_label_browse); - setText("store-empty", T.store_empty); - setText("sf-all", T.sf_all); - setText("sf-ok", T.sf_ok); - setText("sf-err", T.sf_err); - setText("sf-new", T.sf_new); - setText("ss-date", T.ss_date); - setText("ss-name", T.ss_name); - setText("ss-dur", T.ss_dur); - setText("store-web-verify-title", T.store_web_verify_title); - setText("store-web-verify-msg", T.store_web_verify_msg); - setText("store-web-verify-confirm", T.store_web_verify_confirm); - setText("store-web-verify-abort", T.store_web_verify_abort); + setText('printers-panel-title','🖨 '+T.nav_printers); + setText('add-printer-btn-label',T.add_printer); + setText('apd-title',T.add_printer); + setText('skip-title',T.skip_title); + setText('skip-hint',T.skip_hint); + setText('d-btn-skip-label',T.skip_btn_label); + setText('fd-objects-toggle-lbl',T.fd_objects_toggle); + setText('apd-lbl-ip',T.apd_lbl_ip); + setText('apd-lbl-name',T.apd_lbl_name); + var apn=document.getElementById('apd-name');if(apn)apn.setAttribute('placeholder',T.apd_placeholder_name); + setText('apd-cancel',T.apd_cancel); + setText('apd-confirm',T.apd_confirm); + setText('fd-slots-hint',T.fd_slots_hint); + setText('fd-cancel',T.fd_cancel); + setText('fd-print',T.fd_print); + setText('store-panel-title','🗂 '+T.panel_browser_title); + var srb=document.getElementById('store-refresh-btn');if(srb)srb.textContent=T.store_refresh; + var ssp=document.getElementById('store-search');if(ssp)ssp.setAttribute('placeholder',T.store_search_placeholder); + setText('store-upload-label-prefix',T.store_upload_label_prefix); + setText('store-upload-label-browse',T.store_upload_label_browse); + setText('store-empty',T.store_empty); + setText('sf-all',T.sf_all);setText('sf-ok',T.sf_ok);setText('sf-err',T.sf_err);setText('sf-new',T.sf_new); + setText('ss-date',T.ss_date);setText('ss-name',T.ss_name);setText('ss-dur',T.ss_dur); + setText('store-web-verify-title',T.store_web_verify_title); + setText('store-web-verify-msg',T.store_web_verify_msg); + setText('store-web-verify-confirm',T.store_web_verify_confirm); + setText('store-web-verify-abort',T.store_web_verify_abort); + setText('store-lbl-select-all',T.store_select_all||'Select All'); + setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); + setText('store-lbl-exit-select',T.store_exit_select||'Cancel'); // Dashboard card titles setText("d-card-progress", T.card_progress); setText("d-card-temps", T.card_temps); @@ -511,29 +506,20 @@ function applyLang() { pollHint.textContent = T.settings_poll_interval_hint || "Wie oft die Bridge den Drucker-Status abfragt"; setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)'); - setText( - "lbl-filament-mapping", - T.settings_filament_mapping || "Filament-Profil-Mapping (pro Slot)" - ); - var fmHint = document.getElementById("filament-mapping-hint"); - if (fmHint) - fmHint.textContent = - T.settings_filament_mapping_hint || - 'Festes Orca-Profil pro AMS-Slot. Beim Slicer-Sync sendet die Bridge dieses Profil statt „Generic".'; - setText("lbl-filament-mapping-save", T.settings_filament_mapping_save || "Mapping speichern"); - setText( - "lbl-visible-vendors", - T.settings_visible_vendors || "Sichtbare Hersteller (Profil-Dropdown)" - ); - var vfs = document.getElementById("vendor-filter-search"); - if (vfs) - vfs.setAttribute("placeholder", T.settings_vendor_filter_placeholder || "Hersteller suchen…"); - setText( - "visible-vendors-hint", - T.settings_visible_vendors_hint || - 'Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.' - ); - setText("lbl-visible-vendors-save", T.settings_visible_vendors_save || "Auswahl speichern"); + var dashLbl=document.getElementById('dash-lbl-edit'); + if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard'); + setText('dash-lbl-reset',T.dash_reset||'Reset'); + setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset'); + var dashPresetStd=document.getElementById('dash-preset-standard'); + if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard'; + var dashPresetWide=document.getElementById('dash-preset-wide89'); + if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop'; + if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar(); + setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)'); + setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern'); + setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)'); + setText('visible-vendors-hint',T.settings_visible_vendors_hint||'Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.'); + setText('lbl-visible-vendors-save',T.settings_visible_vendors_save||'Auswahl speichern'); // Custom-Profile-Import (Issue #41) setText("modal-sec-orca-profiles", T.orca_profile_section); setText("orca-profiles-hint", T.orca_profile_hint); @@ -1003,21 +989,25 @@ function applyState() { var s = S; _syncAceDryPresetsFromServer(s.ace_dry_presets); // connection error banner – nur wenn überhaupt ein Drucker konfiguriert ist - var banner = document.getElementById("conn-error-banner"); - if (banner) { - if (s.connection_error && _printers.length > 0) { - banner.textContent = "⚠ " + tr("lbl_conn_error") + " " + s.connection_error; - banner.style.display = "block"; - } else { - banner.style.display = "none"; + var banner=document.getElementById('conn-error-banner'); + if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}} + var pauseBanner=document.getElementById('pause-msg-banner'); + if(pauseBanner){ + if(s.pause_msg && s.print_state==='paused'){ + var codePart = (s.error_code==0) ? ' ' : + ' ['+s.error_code+'] '; + pauseBanner.innerHTML='⏸ '+tr('lbl_pause_reason')+codePart+s.pause_msg; + pauseBanner.style.display='block'; + }else{ + pauseBanner.style.display='none'; } } - var bannerVisible = false; - var frb = document.getElementById("file-ready-banner"); - if (frb) { - var shouldAutoOpen = s.print_start_dialog === undefined ? true : !!s.print_start_dialog; - if (s.file_ready && s.print_state === "standby") { - document.getElementById("file-ready-name").textContent = s.file_ready; + var bannerVisible=false; + var frb=document.getElementById('file-ready-banner'); + if(frb){ + var shouldAutoOpen=(s.print_start_dialog===undefined?true:!!s.print_start_dialog); + if(s.file_ready&&s.print_state==='standby'){ + document.getElementById('file-ready-name').textContent=s.file_ready; // Neue Datei → Abbruch-Sperre aufheben if (_fdAutoOpenedFile && _fdAutoOpenedFile !== s.file_ready) { _fdUserCancelled = false; @@ -2860,8 +2850,313 @@ var pollTimer; }).catch(function(){}); poll();pollTimer=setInterval(poll,ms); setInterval(_loadSpoolmanStatus,30000); + // initDashGrid() is called at the very end of the file, after all the + // DASH_* var declarations below have executed (they are hoisted but not yet + // assigned at this point in the IIFE). })(); +// ── Dashboard Free Grid (Issue #89) ──────────────────────────────────────── +// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js). +// Cards move + resize on a 12-column snap grid; layout persisted per browser +// in localStorage. Built on documented APIs only: +// - float:false (default) -> compact packing, no holes between cards +// - staticGrid:true -> locked by default, setStatic(false) in edit mode +// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id +// - draggable.cancel -> inputs/buttons/img excluded from drag start +// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware) +var DASH_STORAGE_KEY='dashLayout'; +var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states +var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams']; +// Layout arrays in GridStack save()/load() format. cellHeight=60px. +var DASH_DEFAULT_LAYOUT=[ + {id:'camera', x:0,y:0, w:12,h:7}, + {id:'progress',x:0,y:7, w:12,h:6}, + {id:'temps', x:0,y:13,w:6, h:7}, + {id:'motion', x:6,y:13,w:6, h:7}, + {id:'speed', x:0,y:20,w:6, h:3}, + {id:'fan', x:6,y:20,w:6, h:3}, + {id:'ams', x:0,y:23,w:12,h:4}, +]; +// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center, +// motion right. +var DASH_PRESET_WIDE=[ + {id:'progress',x:0, y:0, w:12,h:4}, + {id:'camera', x:0, y:4, w:6, h:11}, + {id:'temps', x:6, y:4, w:3, h:7}, + {id:'speed', x:6, y:11,w:3, h:2}, + {id:'fan', x:6, y:13,w:3, h:2}, + {id:'motion', x:9, y:4, w:3, h:11}, + {id:'ams', x:0, y:15,w:12,h:4}, +]; +var _dashGrid=null; +var _dashEditing=false; +var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show) + +// GridStack requires .grid-stack-item > .grid-stack-item-content around each +// card — wrap the existing .card elements in place (IDs/event handlers survive, +// appendChild moves nodes without recreating them). +function _dashWrapCards(){ + var grid=document.getElementById('dash-grid'); + if(!grid)return; + DASH_CARD_KEYS.forEach(function(key){ + var card=grid.querySelector('[data-card="'+key+'"]'); + if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return; + var item=document.createElement('div'); + item.className='grid-stack-item'; + item.setAttribute('gs-id',key); + var content=document.createElement('div'); + content.className='grid-stack-item-content'; + grid.insertBefore(item,card); + item.appendChild(content); + content.appendChild(card); + }); +} + +function _dashItem(key){ + return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]'); +} + +function _dashLoadState(){ + try{ + var raw=localStorage.getItem(DASH_STORAGE_KEY); + if(!raw)return null; + var s=JSON.parse(raw); + if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null; + return s; + }catch(e){return null;} +} +function _dashSaveState(){ + if(!_dashGrid)return; + localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({ + version:DASH_LAYOUT_VERSION, + layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only + hidden:_dashHidden.slice(), + })); +} + +function initDashGrid(){ + var el=document.getElementById('dash-grid'); + if(!el||typeof GridStack==='undefined')return; + _dashWrapCards(); + _dashGrid=GridStack.init({ + cellHeight:60, + margin:8, + staticGrid:true, // locked by default; setStatic(false) enables drag+resize + columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware) + draggable:{cancel:'input,textarea,button,select,option,img,.slider'}, + },el); + var state=_dashLoadState(); + if(state){ + _dashHidden=Array.isArray(state.hidden)?state.hidden:[]; + _dashGrid.load(state.layout,false); // update matching ids, no add/remove + _dashHidden.forEach(function(n){ + var item=_dashItem(n.id); + if(item){_dashGrid.removeWidget(item,false);item.style.display='none';} + }); + }else{ + _dashGrid.load(DASH_DEFAULT_LAYOUT,false); + } + _dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();}); + _dashRefreshPresetDropdown(); +} + +function toggleDashEdit(){ + if(!_dashGrid)return; + _dashEditing=!_dashEditing; + _dashGrid.setStatic(!_dashEditing); + document.getElementById('dash-grid').classList.toggle('editing',_dashEditing); + document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard'); + document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none'; + document.getElementById('dash-preset').style.display=_dashEditing?'':'none'; + document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none'; + _dashUpdatePresetDeleteBtn(); + if(_dashEditing)_dashInjectControls(); else _dashRemoveControls(); + _dashRenderHiddenBar(); + if(!_dashEditing)_dashSaveState(); +} + +// ── Custom presets (user-saved layouts) ──────────────────────────────────── +var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets'; +var DASH_BUILTIN_PRESET_IDS=['standard','wide89']; + +function _dashLoadCustomPresets(){ + try{ + var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY); + var obj=raw?JSON.parse(raw):{}; + return (obj&&typeof obj==='object')?obj:{}; + }catch(e){return {};} +} +function _dashSaveCustomPresets(presets){ + localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets)); +} + +function _dashRefreshPresetDropdown(){ + var sel=document.getElementById('dash-preset'); + if(!sel)return; + var current=sel.value; + var customs=_dashLoadCustomPresets(); + // Remove previously-rendered custom