diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f3b749a..f3f3667 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,5 +1,4 @@ ## Changes in this build -- Fix: dashboard reprint from the cache path crashed with an AttributeError (`self._db` did not exist) instead of applying the filament slot filter (PR #93, thanks @Pavulon87) -- Feat: the dashboard now shows a banner with the reason and error code when a print is paused, with a direct link to the Anycubic error-code docs (PR #92, thanks @Pavulon87) -- Fix: `/api/camera/stream` (the MJPEG live view used by the dashboard and every Moonraker-compatible client) opened a brand-new ffmpeg process and printer connection per HTTP client — two simultaneous viewers could already exhaust the printer's very limited camera connection slots and cause intermittent "stream unavailable"/429 failures. All consumers now share one ffmpeg process via the same fanout pattern already used for the H.264 stream (credit to @Pavulon87 for the fix and for finding two related race conditions along the way) +- 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 16af537..263a86c 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1585,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) @@ -1621,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 @@ -1824,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)) 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/web/themes/default/app.js b/web/themes/default/app.js index 020abe8..8b583ec 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -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); @@ -2756,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'); @@ -2804,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; @@ -2862,22 +2872,56 @@ function renderStore(){ } else if(!f.last_print_status){ lastInfo='