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='
'+T.store_never+'
'; } - return '
'+ + 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=''+ + ''; + 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 '
'+ + checkbox+ thumb+ '
'+name+statusBadge+'
'+ lastInfo+ '
⏱ '+T.store_estimate+': '+est+'
'+ '
📅 '+date+'
'+ '
'+ - ''+ - ''+ - ''+ '
'+ '
'; }).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&&n0?(T.store_selected_count||'{n} selected').replace('{n}',n):''; + delBtn.disabled=n===0; } function formatDur(sec){ @@ -2897,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){ diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 2d63ab1..0f4e19f 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -417,6 +417,19 @@
+
diff --git a/web/translations/de.json b/web/translations/de.json index 991d02d..d72c052 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -311,6 +311,8 @@ "ss_dur": "⏱ Druckzeit", "ss_name": "A–Z 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", @@ -319,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}", diff --git a/web/translations/en.json b/web/translations/en.json index d43716d..67c699d 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -311,6 +311,8 @@ "ss_dur": "⏱ Print time", "ss_name": "A–Z 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", @@ -319,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}", diff --git a/web/translations/es.json b/web/translations/es.json index 48a4894..df5a2d5 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -311,6 +311,8 @@ "ss_dur": "⏱ Tiempo de impresión", "ss_name": "A–Z 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", @@ -319,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}", diff --git a/web/translations/fr.json b/web/translations/fr.json index 95216dd..526917f 100644 --- a/web/translations/fr.json +++ b/web/translations/fr.json @@ -297,6 +297,8 @@ "ss_dur": "⏱ Durée d'impression", "ss_name": "A–Z 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", @@ -305,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}", diff --git a/web/translations/it.json b/web/translations/it.json index 71d6543..ab170b3 100644 --- a/web/translations/it.json +++ b/web/translations/it.json @@ -297,6 +297,8 @@ "ss_dur": "⏱ Tempo di stampa", "ss_name": "Nome A–Z", "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", @@ -305,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}", diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index 51d627f..9a7cf27 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -311,6 +311,8 @@ "ss_dur": "⏱ 打印时间", "ss_name": "A–Z 名称", "store_delete_confirm": "删除文件?", + "store_delete_selected": "删除所选", + "store_delete_selected_confirm": "删除已选择的 {n} 个文件?", "store_download": "⬇ 下载", "store_empty": "尚未上传文件。", "store_estimate": "估算", @@ -319,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}",