Compare commits

..

6 Commits

Author SHA1 Message Date
33b42e64cc fix(dashboard): tile resize clipping and phantom scrollbars (Issue #97)
All checks were successful
Nightly Build / build (push) Successful in 6m59s
Two dashboard tile CSS issues reported after resizing tiles in the
GridStack-based dashboard:

1. Shrinking the Progress tile's height clipped the lower content
   (time grid, filename, buttons) out of the visible area instead of
   making it scale or scroll - #card-progress had no flex layout, so
   its fixed-size children just overflowed silently under the generic
   overflow:auto rule. Applied the same display:flex;flex-direction:
   column pattern already used for #card-camera, with flex-shrink:0
   on the direct children so the container can properly scroll instead
   of hiding content off-screen.

2. Tiles could show a scrollbar even with nothing to scroll, from
   sub-pixel horizontal overflow under the blanket
   overflow-y:auto/overflow-x:hidden rule (was overflow:auto covering
   both axes) - split it so only vertical scroll is offered, which is
   the only axis dashboard content actually needs.

Verified visually via Playwright: resizing the progress tile to
h=2/h=4 no longer clips content, and a fan-card resize with
scrollHeight==clientHeight now shows no scrollbar.
2026-07-24 15:13:19 +02:00
16c1a8ee73 fix(camera): recover automatically after printer reboot rotates stream token (Issue #99)
The printer rotates its FLV/RTSP stream token on reboot. CameraCache.set_url()
was a bare assignment, so the three running ffmpeg loops never noticed - they
only re-read self._url at the top of their outer loop, which they never reach
while permanently blocked in a stdout read on the now-silent, stale-token
connection (TCP stays ESTABLISHED with no data and no FIN, so a passive
reader can't tell "peer is quiet" from "peer is gone").

Three-part fix, all per the excellent root-cause analysis and reproduction
in the issue (thanks @fmontagna):

1. set_url() now detects a URL change and calls reset() to tear down the
   stale ffmpeg loops, so the next ensure_running() respawns them against
   the new URL.
2. ffmpeg's -timeout option (10s, applies to both RTSP and HTTP-FLW since
   both share _input_args) as a second line of defense for a source that
   goes silent while the URL stays the same (network loss, camera hang).
3. handle_camera_stream now waits up to 5s for the first frame BEFORE
   calling resp.prepare() and returns 503 on timeout. Previously it had no
   first-frame timeout at all, and prepare() commits the response status
   to 200 - so a stalled source could never surface as an error to the
   client, only as an infinite hang.
2026-07-24 14:10:54 +02:00
884bdfc0c3 fix(ams): log rejected multiColorBox setInfo with the triggering request
The printer's failure payload for a rejected slot assignment carries
no slot/type/color info of its own, only an opaque
["multi_color_box", [{"filaments": {"id": N}, "id": box_id}]] shape -
useless for diagnosing why an assignment was rejected.

Remember the last setInfo request (_last_ams_set_request) and log it
alongside the failure so bridge logs alone can answer "what exact
type/color was sent, and to which slot" without asking the user to
dig it out manually or run extra reproduction steps.
2026-07-24 14:01:04 +02:00
8f14580e30 fix(ams): don't crash on rejected multiColorBox setInfo (Issue #100)
The printer replies with state="failed" and data as a 2-element list
(["multi_color_box", [...]]) instead of the usual dict when it rejects
a manual ACE slot filament assignment (e.g. custom-RFID/third-party
material). _on_multicolor_box called data.get(...) unconditionally,
crashing with AttributeError and silently dropping the report -
including the regular slot-state update that would otherwise follow.

Add a state="failed" guard plus a defensive isinstance check, and
surface the rejection via _state["last_ams_set_error"] so the caller
of handle_api_ams_set_slot's optimistic cache update isn't left
showing a false success.

Note: this fixes the crash, not necessarily the underlying rejection
itself - the printer/ACE may still refuse assignments for material
types it doesn't recognize.
2026-07-24 13:57:17 +02:00
20daa6b6b8 Merge PR #98: fix(ams) don't map AMS placeholders to empty trays
Printing via OrcaSlicer "Upload and Print" failed (or fed the wrong
spool) when a slot below the used filament was empty. Gap placeholders
in _build_auto_ams_box_mapping now point their ams_index at a loaded
tray instead of the gap's own empty index. All-slots-full path
unchanged.
2026-07-24 13:48:08 +02:00
Walter Almada B
2f222a0b93 fix(ams): point gap placeholders at a loaded tray, not an empty one
Some checks failed
PR Check / lint-and-test (pull_request) Failing after 1s
Printing via OrcaSlicer "Upload and print" failed or fed the wrong spool
when the slot below the used filament was empty (e.g. Filament 4 with slot
3 empty); all-slots-full worked.

_start_print -> _build_auto_ams_box_mapping keeps the AMS mapping positional
(entry N = TN) by inserting a placeholder at each gap. The placeholder's
ams_index pointed at the gap's own index, which for an empty slot references
a physically empty tray. The printer rejects a mapping entry aimed at an
empty tray even for a tool the GCode never calls, so the print broke.

Point gap placeholders (ams_index/color/material) at a definitely-loaded
fallback tray (the highest loaded slot) instead. Positional alignment is
preserved; the all-full path is unchanged.

Adds tests/test_auto_ams_box_mapping_empty_slot.py (invariant: every mapping
entry references a loaded/status-5 tray).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:31:10 -07:00
6 changed files with 282 additions and 6 deletions

View File

@@ -2,3 +2,7 @@
- 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)
- Fix: **printing via OrcaSlicer "Upload and print" failed (or fed the wrong spool) when a slot below the used filament was empty** — e.g. printing with Filament 4 while slot 3 was empty. The generated AMS slot mapping inserted a placeholder pointing at the physically empty tray, which the printer rejects. Placeholders now point at a loaded tray instead. Printing with all slots full already worked and is unchanged.
- Fix: manually assigning a filament profile to an ACE slot could crash the MQTT callback (`'list' object has no attribute 'get'`) when the printer rejected the assignment, e.g. for custom-RFID/third-party filament. The bridge now handles the printer's failure response cleanly instead of crashing, and the log now correlates a rejected assignment with the request that triggered it (slot/type/color) so a rejection reason can be diagnosed from bridge logs alone (Issue #100, thanks @Blaim)
- Fix: **the camera stream could hang forever after a printer reboot** — the printer rotates its stream token on reboot, but the running ffmpeg processes kept reading from the stale, now-silent connection and never noticed. The bridge now detects the URL change and automatically restarts the camera pipeline, ffmpeg itself now times out on a stalled read as a second line of defense, and `/api/camera/stream` now returns a proper 503 instead of hanging if no frame arrives within 5s (Issue #99, thanks @fmontagna for the excellent root-cause analysis and reproduction)
- Fix: shrinking the Progress dashboard tile below its default height clipped the lower content (time grid, filename, buttons) out of view instead of scaling with it; dashboard tiles in general could show a scrollbar even when there was nothing to scroll. Both fixed (Issue #97, thanks @Blaim)

View File

@@ -631,7 +631,16 @@ class CameraCache:
self._fail_count_mjpeg: int = 0
def set_url(self, url: str):
# A changed URL means the printer rotated its stream token (typically
# after a reboot). Running ffmpeg processes still hold the stale URL
# and will never pick it up on their own - they only re-read self._url
# at the top of their outer loop, which they never reach while blocked
# in a stdout read on the old, now-silent connection. Tear them down;
# the next ensure_running() respawns them against the new URL.
changed = bool(url and self._url and url != self._url)
self._url = url
if changed:
self.reset()
def reset(self):
"""Reset backoff counters and forcefully tear down any running
@@ -679,7 +688,12 @@ class CameraCache:
self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop())
def _input_args(self, url: str) -> list[str]:
args = ["-fflags", "nobuffer", "-flags", "low_delay"]
args = ["-fflags", "nobuffer", "-flags", "low_delay",
# Bail out if the source goes silent. A printer reboot or
# network loss leaves the TCP connection ESTABLISHED with no
# data and no FIN, so a passive stdout read blocks forever
# without this (Issue #99). Value is microseconds.
"-timeout", "10000000"]
if url.lower().startswith("rtsp://"):
args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else:
@@ -989,6 +1003,7 @@ class KobraXBridge:
except Exception:
self._visible_vendors = []
self._last_state: dict = {}
self._last_ams_set_request: dict | None = None
self._state = {
"nozzle_temp": 0.0,
"nozzle_target": 0.0,
@@ -1762,6 +1777,14 @@ class KobraXBridge:
max_idx = max(loaded_map.keys())
# The printer interprets ams_box_mapping as an ordered list (entry N = TN).
# Missing slots must be inserted as placeholders, otherwise everything shifts.
# A placeholder must NOT reference a physically empty tray: the printer
# rejects such an entry even for a tool the GCode never calls (printing
# Filament 4 with the slot below it empty fails; all-full works). Point
# gap placeholders at a definitely-loaded tray instead of the gap's own
# (empty) index.
fallback_gidx = max_idx # highest loaded slot -> loaded + printable
fallback_slot = loaded_map[fallback_gidx]
fallback_ams = self._slot_to_print_ams_index(fallback_gidx)
result = []
for i in range(max_idx + 1):
if i in loaded_map:
@@ -1776,10 +1799,10 @@ class KobraXBridge:
else:
result.append({
"paint_index": i,
"ams_index": self._slot_to_print_ams_index(i),
"ams_index": fallback_ams,
"paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255],
"material_type": "PLA",
"ams_color": self._slot_color_rgba(fallback_slot),
"material_type": fallback_slot.get("type", "PLA"),
})
return result
@@ -1871,10 +1894,21 @@ class KobraXBridge:
return activity
def _on_multicolor_box(self, payload: dict):
if payload.get("state") == "failed":
req = getattr(self, "_last_ams_set_request", None)
log.warning(
f"multiColorBox setInfo rejected by printer: request={req} raw_response={payload.get('data')}"
)
self._state["last_ams_set_error"] = True
return
data = payload.get("data") or {}
if not isinstance(data, dict):
log.warning(f"multiColorBox/report: unexpected data shape: {data!r}")
return
boxes = data.get("multi_color_box") or []
if not boxes:
return
self._state["last_ams_set_error"] = False
self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model))
self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model)
self._state["filament_mode"] = self._filament_mode
@@ -4029,6 +4063,11 @@ class KobraXBridge:
return web.json_response({"error": "color must be [r,g,b]"}, status=400)
box_id, local_slot = self._global_to_box_slot(index)
loop = asyncio.get_event_loop()
self._state["last_ams_set_error"] = False
# Remembered so a later state="failed" report (which carries no slot
# info of its own, see _on_multicolor_box) can be logged alongside the
# request that triggered it - otherwise the failure is unattributable.
self._last_ams_set_request = {"global": index, "box": box_id, "local_slot": local_slot, "type": mat, "color": color}
# setInfo goes via the web/printer topic (like tempature/set). Verified via
# Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden
# slot changes are ignored by the printer and overwritten with the old
@@ -4343,6 +4382,16 @@ class KobraXBridge:
q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8)
self.camera_cache.mjpeg_subscribers.add(q)
# Wait for the first frame BEFORE resp.prepare() - once prepare() sends
# the response headers the status is committed to 200, so a stalled
# source (Issue #99) must be caught here to actually return a 503
# instead of hanging the client forever with no frame ever arriving.
try:
first_frame = await asyncio.wait_for(q.get(), timeout=5.0)
except asyncio.TimeoutError:
self.camera_cache.mjpeg_subscribers.discard(q)
return web.Response(status=503, text="No frame in cache yet")
boundary = "kobraxframe"
resp = web.StreamResponse(headers={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
@@ -4351,8 +4400,8 @@ class KobraXBridge:
})
await resp.prepare(request)
try:
frame = first_frame
while True:
frame = await q.get()
header = (
f"--{boundary}\r\n"
f"Content-Type: image/jpeg\r\n"
@@ -4364,6 +4413,7 @@ class KobraXBridge:
break
except Exception:
break
frame = await q.get()
except Exception as e:
log.warning(f"Camera stream interrupted: {e}")
finally:

View File

@@ -0,0 +1,85 @@
"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path.
Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it
EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping`
inserts a positional placeholder at each gap whose `ams_index` points at the
gap's own (physically EMPTY) tray. The printer rejects a mapping entry that
references an empty tray, even for a tool the GCode never calls.
Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray.
Positional alignment (entry N = TN, from a16062f) must be preserved.
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3).
AMS_SLOTS = [
{"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]},
{"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]},
{"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY
{"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]},
]
def _bridge(slots=AMS_SLOTS):
c = MagicMock(); c.callbacks = {}; c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxauto-"),
)
b = KobraXBridge(c, args=args)
b._filament_mode = "toolhead"
b._ams_slots = [dict(s) for s in slots]
return b
def _loaded_ams_indices(b):
return {b._slot_to_print_ams_index(int(s["global_index"]))
for s in b._ams_slots if s["status"] == 5}
def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded():
"""_start_print filters loaded to the used slot only: loaded=[(3, slot3)].
Positions 0..2 are placeholders and must NOT reference empty tray idx 2."""
b = _bridge()
loaded = [(3, b._ams_slots[3])]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept
loaded_ams = _loaded_ams_indices(b)
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set():
"""All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at
position 2 must not reference the empty tray idx 2."""
b = _bridge()
loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3]
loaded_ams = _loaded_ams_indices(b)
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
# Real loaded slots keep their own ams_index.
assert mapping[0]["ams_index"] == 0
assert mapping[1]["ams_index"] == 1
assert mapping[3]["ams_index"] == 3
def test_all_full_is_unchanged():
"""All slots loaded -> no placeholders, identity mapping (the working case)."""
slots = [dict(s, status=5) for s in AMS_SLOTS]
b = _bridge(slots)
loaded = [(i, b._ams_slots[i]) for i in range(4)]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3]

View File

@@ -0,0 +1,51 @@
"""Camera stream hangs forever after printer reboot (Issue #99).
The printer rotates its stream token on reboot, changing the camera URL.
CameraCache.set_url() used to be a bare assignment - the running ffmpeg loops
never noticed since they only re-read self._url at the top of their outer
loop, which they never reach while permanently blocked reading stdout from
the now-silent, stale-token connection. set_url() must detect the change and
tear the loops down so the next ensure_running() respawns them against the
new URL.
"""
from kobrax_moonraker_bridge import CameraCache
def test_set_url_first_time_does_not_reset():
"""No prior URL - nothing stale to tear down."""
c = CameraCache()
calls = []
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert c._url == "http://printer/live/tokenA"
assert not calls
def test_set_url_same_value_does_not_reset():
c = CameraCache()
calls = []
c.set_url("http://printer/live/tokenA")
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert not calls
def test_set_url_changed_triggers_reset():
"""The actual bug scenario: token rotates after a printer reboot."""
c = CameraCache()
calls = []
c.set_url("http://printer/live/tokenA")
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenB")
assert c._url == "http://printer/live/tokenB"
assert calls == [True]
def test_set_url_empty_to_value_does_not_reset():
"""Startup case: no URL known yet, first status push sets it - nothing
running to tear down."""
c = CameraCache()
calls = []
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert not calls

View File

@@ -0,0 +1,81 @@
"""AttributeError crash on multiColorBox/report failure (Issue #100).
Real KX2-Pro bug: manually assigning a filament profile to an ACE slot
(custom-RFID / third-party filament) gets rejected by the printer. Instead of
echoing the normal success shape, the printer replies with `state: "failed"`
and `data` as a 2-element LIST (`["multi_color_box", [...]]`) instead of the
usual dict (`{"multi_color_box": [...]}`). `_on_multicolor_box` called
`data.get(...)` unconditionally, crashing with
`AttributeError: 'list' object has no attribute 'get'` and silently dropping
the report (including the slot-state update it would otherwise have done).
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
# Exact failure payload from the Issue #100 log.
FAILED_PAYLOAD = {
"state": "failed",
"data": ["multi_color_box", [{"filaments": {"id": 2}, "id": 0}]],
}
SUCCESS_PAYLOAD = {
"state": "success",
"data": {
"head_tools_model": 1,
"multi_color_box": [
{"id": -1, "slots": []},
{
"id": 0,
"slots": [
{"index": 0, "type": "PLA", "color": [0, 156, 189], "status": 5},
],
},
],
},
}
def _bridge():
c = MagicMock(); c.callbacks = {}; c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxmcb-"),
)
return KobraXBridge(c, args=args)
def test_failed_state_does_not_crash():
b = _bridge()
b._on_multicolor_box(FAILED_PAYLOAD) # must not raise
assert b._state["last_ams_set_error"] is True
def test_success_after_failure_clears_error_flag():
b = _bridge()
b._on_multicolor_box(FAILED_PAYLOAD)
assert b._state["last_ams_set_error"] is True
b._on_multicolor_box(SUCCESS_PAYLOAD)
assert b._state["last_ams_set_error"] is False
def test_non_dict_data_without_failed_state_does_not_crash():
"""Defensive guard: any future non-dict `data` shape must not crash,
even if the printer doesn't set state="failed" for it."""
b = _bridge()
b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]})
def test_failed_report_is_logged_with_the_triggering_request(caplog):
"""The failure payload alone carries no slot/type/color info - the log
must correlate it with the setInfo request that triggered it, otherwise
the failure reason can't be diagnosed from bridge logs alone."""
import logging
b = _bridge()
b._last_ams_set_request = {"global": 6, "box": 0, "local_slot": 3, "type": "PLA", "color": [33, 39, 33]}
with caplog.at_level(logging.WARNING):
b._on_multicolor_box(FAILED_PAYLOAD)
assert any("global" in r.message and "6" in r.message for r in caplog.records)

View File

@@ -108,7 +108,7 @@ main{flex:1;overflow-y:auto;padding:20px}
GridStack's resize-handle hit area and drag listeners. */
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
the cell smaller than the content needs. */
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box}
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow-y:auto;overflow-x:hidden;box-sizing:border-box}
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
as the user starts dragging, throwing off GridStack's position math. Kill
the hover transform for dashboard cards specifically. */
@@ -164,6 +164,11 @@ main{flex:1;overflow-y:auto;padding:20px}
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
/* Same pattern as #card-camera above: without this, shrinking the tile's
height just clips the lower content (time-grid/filename/buttons) outside
the visible area instead of making it scrollable (Issue #97). */
.grid-stack-item-content>#card-progress{display:flex;flex-direction:column;min-height:0}
.grid-stack-item-content>#card-progress>*{flex-shrink:0}
.hero-info{display:flex;flex-direction:column;gap:12px}
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}