Compare commits

..

6 Commits

Author SHA1 Message Date
6e72346129 fix(ams): support multiple daisy-chained ACE units (Issue #95)
All checks were successful
Nightly Build / build (push) Successful in 10m12s
ace_direct mode (no toolhead buffer) only kept the first reported ACE
unit and silently dropped any others. Affects e.g. the Kobra S1 with two
ACE Pro units - the dashboard and OrcaSlicer sync only ever saw 4 of 8
slots. Global slot index is now box_id * 4 + local slot across all
units, matching the existing //4-%4 fallback in _global_to_box_slot.
Single-ACE (Kobra X) behavior is unchanged (unit id 0 -> same indices
as before).

feat(store): multi-select + bulk delete in the GCode browser (Issue #94)

Checkbox on every file card enters select mode; clicking anywhere on a
selected-mode card toggles it, existing per-file actions keep working via
stopPropagation. 'Select All' only affects the currently filtered/visible
files. Bulk delete is N parallel calls to the existing single-file DELETE
endpoint (no new backend route) with one confirmation dialog.
2026-07-19 18:44:46 +02:00
99bc1797c8 fix(camera): share one ffmpeg connection for /api/camera/stream
All checks were successful
Nightly Build / build (push) Successful in 9m34s
Manually integrated from PR #91 (Pavulon87) - the PR branch itself had
unresolved git conflict markers in handle_camera_stream from a stale
rebase against nightly, so it couldn't be merged directly.

/api/camera/stream previously spawned a dedicated ffmpeg process and
printer connection per HTTP client. The printer only tolerates a very
limited number of concurrent camera connections, so two simultaneous
viewers (dashboard + a Moonraker client, two browser tabs, ...) could
already exhaust that limit and cause intermittent 429/"stream
unavailable" failures.

Adds a third CameraCache fanout channel (mjpeg_subscribers), mirroring
the existing h264 pattern, so all /api/camera/stream consumers share one
ffmpeg process. Also carries over two related fixes found in the PR:
ensure_running() checked self._proc_* instead of self._task_*, leaving a
race window where two callers could each spawn a duplicate ffmpeg
process before the first task got scheduled; and reset() didn't cancel
the owning task, so a loop stuck in its exponential backoff sleep (up to
300s) wouldn't restart immediately.
2026-07-16 22:33:50 +02:00
e1b9480098 Merge branch 'pr92' into nightly
Some checks failed
Nightly Build / build (push) Has been cancelled
2026-07-16 21:37:09 +02:00
Pavulon87
03089db6af fix: self._db did not exist, use self._store
Some checks failed
Nightly Build / build (push) Has been cancelled
PR Check / lint-and-test (pull_request) Has been cancelled
2026-07-16 14:48:23 +02:00
Pavulon87
cd4a8ce48e feat(dashboard): show printer pause reason on the dashboard
Some checks failed
PR Check / lint-and-test (pull_request) Failing after 2s
2026-07-16 14:42:48 +02:00
bf3f043888 chore: add missing PR #88 entry to nightly changelog
The stale-slot-profile-guard fix (walterioo, merged as bf94a8f) was never
mentioned in a nightly release body — it landed between two changelog
overwrites.
2026-07-16 11:36:09 +02:00
11 changed files with 519 additions and 138 deletions

View File

@@ -1,6 +1,4 @@
## Changes in this build
- Feat: the dashboard is now fully customizable — enter edit mode to freely drag, resize (via GridStack.js), hide, and rearrange every card on a 12-column snap grid
- Feat: two built-in layout presets ("Standard", "Wide desktop" per Issue #89's original proposal) plus the ability to save your own layout as a named custom preset, switch between them, and delete presets you no longer need
- Fix: camera stream would freeze after ~15-30 minutes — non-monotonic timestamps from the printer's FLV stream broke ffmpeg's realtime pacing; now handled with `-use_wallclock_as_timestamps` (Issue #90)
- Fix: the camera card's height was previously capped at 320px and its image intercepted drag events — camera can now be resized freely like any other dashboard card
- 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)

View File

@@ -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,33 +618,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"]
@@ -795,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.
@@ -893,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
@@ -1205,6 +1339,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:
@@ -1440,7 +1585,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)
@@ -1476,19 +1624,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
@@ -1679,20 +1830,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))
@@ -3634,7 +3783,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:
@@ -4138,10 +4287,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,
@@ -4168,45 +4322,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="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:
# See CameraCache._input_args - same non-monotonic-timestamp fix (Issue #90).
ffmpeg_input_args += ["-use_wallclock_as_timestamps", "1",
"-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={
@@ -4215,46 +4350,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
@@ -4359,6 +4472,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):

View File

@@ -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

View File

@@ -354,6 +354,9 @@ function applyLang(){
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);
@@ -746,6 +749,17 @@ function applyState(){
// 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 pauseBanner=document.getElementById('pause-msg-banner');
if(pauseBanner){
if(s.pause_msg && s.print_state==='paused'){
var codePart = (s.error_code==0) ? ' ' :
' [<a href="https://wiki.anycubic.com/en/error-codes/'+s.error_code+'-code" target="_blank">'+s.error_code+'</a>] ';
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){
@@ -2745,10 +2759,42 @@ function aceDryStop(aceId){
function loadStore(){
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
storeFiles=d.result||[];
// Reset any pending selection - stale ids from a deleted/renamed file
// should never carry over into a fresh file list.
storeExitSelectMode();
renderStore();
}).catch(function(e){clog('Store-Fehler: '+e,'msg-err')});
}
// Applies the store's search/filter/sort controls to storeFiles. Shared by
// renderStore() and storeToggleSelectAll() so "Select All" only selects what
// the user can currently see, not the entire (possibly filtered-out) list.
function _storeFilteredFiles(){
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
return files;
}
function uploadGcode(file){
if(!file) return;
var zone=document.getElementById('store-upload-zone');
@@ -2793,32 +2839,7 @@ function uploadGcode(file){
function renderStore(){
var grid=document.getElementById('store-grid');
var empty=document.getElementById('store-empty');
// Suche
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
// Filter
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
// Sortierung
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
var files=_storeFilteredFiles();
if(!storeFiles.length){
empty.textContent=T.store_empty;
@@ -2851,22 +2872,56 @@ function renderStore(){
} else if(!f.last_print_status){
lastInfo='<div style="font-size:11px;color:var(--txt2);margin-bottom:2px;opacity:.6">'+T.store_never+'</div>';
}
return '<div style="background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:10px;display:flex;flex-direction:column">'+
var isSelected=!!_storeSelected[f.id];
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
// Always visible (not just once select mode is on) - otherwise there is
// no way to ever enter select mode, since it's normally entered by
// clicking this very checkbox. White circle behind it so it stays
// legible against any thumbnail.
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
'align-items:center;justify-content:center">'+
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
' onclick="event.stopPropagation();storeToggleSelect(\''+f.id+'\')" '+
'style="width:16px;height:16px;margin:0"></span>';
var cardClick=_storeSelectMode?'onclick="storeToggleSelect(\''+f.id+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
return '<div '+cardClick+'>'+
checkbox+
thumb+
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+statusBadge+'</div>'+
lastInfo+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">⏱ '+T.store_estimate+': '+est+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
'<div style="display:flex;gap:6px;margin-top:auto">'+
'<button onclick="storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'<button onclick="event.stopPropagation();storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="flex:1;font-size:12px;padding:5px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer">'+T.store_print+'</button>'+
'<button onclick="storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
'<button onclick="event.stopPropagation();storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">⬇</button>'+
'<button onclick="storeDelete(\''+f.id+'\')" '+
'<button onclick="event.stopPropagation();storeDelete(\''+f.id+'\')" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
'</div>'+
'</div>';
}).join('');
_storeUpdateSelectBar(files);
}
// Reflects the current selection onto the select-all checkbox (incl.
// indeterminate state), the selected-count label, and the delete button's
// disabled state. `files` is the currently-filtered/visible list.
function _storeUpdateSelectBar(files){
var selectAll=document.getElementById('store-select-all');
var countEl=document.getElementById('store-selected-count');
var delBtn=document.getElementById('store-delete-selected-btn');
if(!selectAll||!countEl||!delBtn)return;
var visibleIds=files.map(function(f){return f.id;});
var selectedVisible=visibleIds.filter(function(id){return _storeSelected[id];});
var n=selectedVisible.length;
selectAll.checked=n>0&&n===visibleIds.length;
selectAll.indeterminate=n>0&&n<visibleIds.length;
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
delBtn.disabled=n===0;
}
function formatDur(sec){
@@ -2886,6 +2941,52 @@ var _pendingWebVerifyAutoOpen=false;
// wurde (Issue #29 / Theme-Auslagerung PR #27).
var storeFiles=[];
// Multi-select state for the GCode browser (Issue #94).
var _storeSelectMode=false;
var _storeSelected={}; // file.id -> true
function storeToggleSelect(id){
if(!_storeSelectMode){
_storeSelectMode=true;
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='flex';
}
if(_storeSelected[id])delete _storeSelected[id];
else _storeSelected[id]=true;
renderStore();
}
function storeToggleSelectAll(checked){
// Only the currently filtered/visible files are affected, so search/filter
// never causes "Select All" to silently pick up hidden files too.
var visible=_storeFilteredFiles();
_storeSelected={};
if(checked)visible.forEach(function(f){_storeSelected[f.id]=true;});
renderStore();
}
function storeExitSelectMode(){
_storeSelectMode=false;
_storeSelected={};
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='none';
renderStore();
}
function storeDeleteSelected(){
var ids=Object.keys(_storeSelected);
if(!ids.length)return;
if(!confirm((T.store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',ids.length)))return;
Promise.all(ids.map(function(id){
return fetch(_apiUrl('/kx/files/'+id),{method:'DELETE'}).then(function(r){return{id:id,ok:r.ok};});
})).then(function(results){
var failed=results.filter(function(r){return !r.ok;});
if(failed.length)clog((T.log_delete_failed||'Delete failed')+': '+failed.length,'msg-err');
storeExitSelectMode();
loadStore();
});
}
var _gcodeFilaments=[];
function _setGcodeFilamentsFromFileObj(fileObj){

View File

@@ -12,6 +12,7 @@
<body>
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
<div id="pause-msg-banner" style="display:none;background:#b8860b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:997;"></div>
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
<span>📄 <span id="file-ready-name"></span></span>
<button id="file-ready-btn" onclick="startReadyFile()"
@@ -416,6 +417,19 @@
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
<span id="store-lbl-select-all">Alle auswählen</span>
</label>
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="storeExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Einziehen",
"lbl_layers": "Layer",
"lbl_light": "💡 Licht",
"lbl_pause_reason": "Druck pausiert:",
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
@@ -310,6 +311,8 @@
"ss_dur": "⏱ Druckzeit",
"ss_name": "AZ Name",
"store_delete_confirm": "Datei löschen?",
"store_delete_selected": "Auswahl löschen",
"store_delete_selected_confirm": "{n} ausgewählte Dateien löschen?",
"store_download": "⬇ Download",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_estimate": "Schätzung",
@@ -318,6 +321,9 @@
"store_print": "▶ Drucken",
"store_print_confirm": "Datei drucken?",
"store_refresh": "↻ Aktualisieren",
"store_select_all": "Alle auswählen",
"store_selected_count": "{n} ausgewählt",
"store_exit_select": "Abbrechen",
"store_search_placeholder": "🔍 Suche…",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_error": "✗ {error}",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Load",
"lbl_layers": "Layer",
"lbl_light": "💡 Light",
"lbl_pause_reason": "Print paused:",
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
@@ -310,6 +311,8 @@
"ss_dur": "⏱ Print time",
"ss_name": "AZ Name",
"store_delete_confirm": "Delete file?",
"store_delete_selected": "Delete Selected",
"store_delete_selected_confirm": "Delete {n} selected files?",
"store_download": "⬇ Download",
"store_empty": "No files uploaded yet.",
"store_estimate": "Estimate",
@@ -318,6 +321,9 @@
"store_print": "▶ Print",
"store_print_confirm": "Print file?",
"store_refresh": "↻ Refresh",
"store_select_all": "Select All",
"store_selected_count": "{n} selected",
"store_exit_select": "Cancel",
"store_search_placeholder": "🔍 Search…",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Cargar",
"lbl_layers": "Capa",
"lbl_light": "💡 Luz",
"lbl_pause_reason": "Impresión pausada:",
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
@@ -310,6 +311,8 @@
"ss_dur": "⏱ Tiempo de impresión",
"ss_name": "AZ Nombre",
"store_delete_confirm": "¿Eliminar archivo?",
"store_delete_selected": "Eliminar seleccionados",
"store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados?",
"store_download": "⬇ Descargar",
"store_empty": "Aún no hay archivos subidos.",
"store_estimate": "Estimación",
@@ -318,6 +321,9 @@
"store_print": "▶ Imprimir",
"store_print_confirm": "¿Imprimir archivo?",
"store_refresh": "↻ Actualizar",
"store_select_all": "Seleccionar todo",
"store_selected_count": "{n} seleccionados",
"store_exit_select": "Cancelar",
"store_search_placeholder": "🔍 Buscar…",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Charger",
"lbl_layers": "Couche",
"lbl_light": "💡 Lumière",
"lbl_pause_reason": "Impression en pause :",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
@@ -296,6 +297,8 @@
"ss_dur": "⏱ Durée d'impression",
"ss_name": "AZ Nom",
"store_delete_confirm": "Supprimer le fichier ?",
"store_delete_selected": "Supprimer la sélection",
"store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés ?",
"store_download": "⬇ Télécharger",
"store_empty": "Aucun fichier uploadé.",
"store_estimate": "Estimation",
@@ -304,6 +307,9 @@
"store_print": "▶ Imprimer",
"store_print_confirm": "Imprimer le fichier ?",
"store_refresh": "↻ Actualiser",
"store_select_all": "Tout sélectionner",
"store_selected_count": "{n} sélectionné(s)",
"store_exit_select": "Annuler",
"store_search_placeholder": "🔍 Rechercher…",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Carica",
"lbl_layers": "Layer",
"lbl_light": "💡 Luce",
"lbl_pause_reason": "Stampa in pausa:",
"lbl_remaining": "Rimanente:",
"lbl_slicer_time": "Stima slicer:",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
@@ -296,6 +297,8 @@
"ss_dur": "⏱ Tempo di stampa",
"ss_name": "Nome AZ",
"store_delete_confirm": "Eliminare il file?",
"store_delete_selected": "Elimina selezionati",
"store_delete_selected_confirm": "Eliminare {n} file selezionati?",
"store_download": "⬇ Scarica",
"store_empty": "Nessun file caricato.",
"store_estimate": "Stima",
@@ -304,6 +307,9 @@
"store_print": "▶ Stampa",
"store_print_confirm": "Stampare il file?",
"store_refresh": "↻ Aggiorna",
"store_select_all": "Seleziona tutto",
"store_selected_count": "{n} selezionati",
"store_exit_select": "Annulla",
"store_search_placeholder": "🔍 Cerca…",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "进料",
"lbl_layers": "层",
"lbl_light": "💡 灯光",
"lbl_pause_reason": "打印已暂停:",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_spoolman_sync_rate": "同步频率0=打印结束)",
@@ -310,6 +311,8 @@
"ss_dur": "⏱ 打印时间",
"ss_name": "AZ 名称",
"store_delete_confirm": "删除文件?",
"store_delete_selected": "删除所选",
"store_delete_selected_confirm": "删除已选择的 {n} 个文件?",
"store_download": "⬇ 下载",
"store_empty": "尚未上传文件。",
"store_estimate": "估算",
@@ -318,6 +321,9 @@
"store_print": "▶ 打印",
"store_print_confirm": "打印文件?",
"store_refresh": "↻ 刷新",
"store_select_all": "全选",
"store_selected_count": "已选择 {n} 个",
"store_exit_select": "取消",
"store_search_placeholder": "🔍 搜索…",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",