From 0e1d46ee7f578f9b497e27aef5e6594f9b238aea Mon Sep 17 00:00:00 2001 From: walterioo Date: Tue, 30 Jun 2026 07:13:10 +0200 Subject: [PATCH 01/25] fix: isolate filament profiles per printer in multi-printer bridge (#74) Per-printer [filament_profiles_] sections so configuring one printer no longer overwrites another (read-fallback to the legacy global section keeps single-printer setups unchanged). Dropdown/switch links now navigate to each printer's own bridge_url. Adds pytest coverage and a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 16 ++++ config_loader.py | 89 ++++++++++++++++----- kobrax_moonraker_bridge.py | 8 +- tests/test_filament_profiles_per_printer.py | 67 ++++++++++++++++ web/themes/default/app.js | 4 +- 5 files changed, 156 insertions(+), 28 deletions(-) create mode 100644 tests/test_filament_profiles_per_printer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bb001..187f1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [Unreleased] + +### Fixed +- **Filament profiles not isolated between printers in a multi-printer bridge** + (issue #74). The slot→profile mapping and `visible_vendors` were stored in a + single global `[filament_profiles]` section, so configuring one printer + overwrote the other and after a restart both loaded the same mapping. Each + printer now persists to its own `[filament_profiles_]` section, with a + read-fallback to the legacy global section (single-printer setups unchanged). +- **Printer dropdown showed the other printer's filament profiles** (issue #74). + The header dropdown and the printers-management "switch" link navigated within + the same port (`/printerN`), so viewing another printer pulled its profile + names cross-instance from the local origin. The links now point at each + printer's own `bridge_url`, so every printer is viewed same-origin on its own + port. + ## [0.9.26] – 2026-06-21 ### New diff --git a/config_loader.py b/config_loader.py index bf41b85..1b66678 100644 --- a/config_loader.py +++ b/config_loader.py @@ -7,6 +7,7 @@ import os import sys import pathlib import configparser +from typing import Optional _BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent @@ -182,9 +183,27 @@ def list_printers() -> list[dict]: return printers -def list_filament_profiles() -> dict[int, dict]: +def _filament_section(printer_id: Optional[str] = None) -> str: + """Section name holding a printer's filament-profile mapping. + + Multi-printer (one bridge, N printers): each printer keeps its own + ``[filament_profiles_]`` section so the mappings cannot overwrite each + other. ``printer_id is None`` (single-printer / legacy callers) maps to the + original global ``[filament_profiles]`` section — full backward compatibility. + """ + pid = str(printer_id).strip() if printer_id is not None else "" + if pid and pid != "0": + return f"filament_profiles_{pid}" + return "filament_profiles" + + +def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]: """Liest die [filament_profiles]-Sektion aus config.ini. + With ``printer_id`` set, reads the per-printer ``[filament_profiles_]`` + section and falls back to the legacy global ``[filament_profiles]`` while + that printer has no own section yet. + Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt (als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit @@ -208,10 +227,13 @@ def list_filament_profiles() -> dict[int, dict]: return {} cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if not cfg.has_section("filament_profiles"): + section = _filament_section(printer_id) + if not cfg.has_section(section): + section = "filament_profiles" # fallback: legacy global section + if not cfg.has_section(section): return {} result: dict[int, dict] = {} - for key, value in cfg.items("filament_profiles"): + for key, value in cfg.items(section): # Erwartet: slot__id oder slot__vendor oder slot__name if not key.startswith("slot_"): continue @@ -231,74 +253,97 @@ def list_filament_profiles() -> dict[int, dict]: return result -def save_filament_profiles(profiles: dict[int, dict]) -> bool: +def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool: """Schreibt die übergebenen Slot-Profile in die [filament_profiles]- Sektion der config.ini. Existierende Einträge werden komplett ersetzt. profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}} Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint). + + With ``printer_id`` set, writes the per-printer ``[filament_profiles_]`` + section only — other printers and the legacy global section are untouched. """ path = _find_config_file() if not path: return False cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") + section = _filament_section(printer_id) # visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der # Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren. + # First save of a per-printer section inherits the legacy global filter. preserved_vendors = None - if cfg.has_option("filament_profiles", "visible_vendors"): + if cfg.has_option(section, "visible_vendors"): + preserved_vendors = cfg.get(section, "visible_vendors") + elif cfg.has_option("filament_profiles", "visible_vendors"): preserved_vendors = cfg.get("filament_profiles", "visible_vendors") - if cfg.has_section("filament_profiles"): - cfg.remove_section("filament_profiles") + if cfg.has_section(section): + cfg.remove_section(section) if profiles or preserved_vendors: - cfg["filament_profiles"] = {} + cfg[section] = {} if preserved_vendors: - cfg["filament_profiles"]["visible_vendors"] = preserved_vendors + cfg[section]["visible_vendors"] = preserved_vendors for slot_idx in sorted(profiles.keys()): entry = profiles[slot_idx] or {} if entry.get("vendor"): - cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"] + cfg[section][f"slot_{slot_idx}_vendor"] = entry["vendor"] if entry.get("name"): - cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"] + cfg[section][f"slot_{slot_idx}_name"] = entry["name"] if entry.get("id"): - cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"] + cfg[section][f"slot_{slot_idx}_id"] = entry["id"] with open(path, "w", encoding="utf-8") as f: cfg.write(f) return True -def list_visible_vendors() -> list[str]: +def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]: """Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini. Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A). Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren). + + With ``printer_id`` set, reads the per-printer section and falls back to the + legacy global ``[filament_profiles]`` filter. """ path = _find_config_file() if not path: return [] cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if not cfg.has_option("filament_profiles", "visible_vendors"): + section = _filament_section(printer_id) + if not cfg.has_option(section, "visible_vendors"): + section = "filament_profiles" # fallback: legacy global section + if not cfg.has_option(section, "visible_vendors"): return [] - raw = cfg.get("filament_profiles", "visible_vendors") + raw = cfg.get(section, "visible_vendors") return [v.strip() for v in raw.split(",") if v.strip()] -def save_visible_vendors(vendors: list[str]) -> bool: +def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool: """Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings - (slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.""" + (slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder. + + With ``printer_id`` set, writes the per-printer section. When that section is + created here for the first time, the slot mappings are seeded from the legacy + global section so they are not orphaned by the read-fallback in + ``list_filament_profiles``.""" path = _find_config_file() if not path: return False cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if not cfg.has_section("filament_profiles"): - cfg.add_section("filament_profiles") + section = _filament_section(printer_id) + if not cfg.has_section(section): + cfg.add_section(section) + if section != "filament_profiles" and cfg.has_section("filament_profiles"): + for key, value in cfg.items("filament_profiles"): + if key.startswith("slot_"): + cfg[section][key] = value clean = [v.strip() for v in (vendors or []) if v and v.strip()] if clean: - cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean) - elif cfg.has_option("filament_profiles", "visible_vendors"): - cfg.remove_option("filament_profiles", "visible_vendors") + cfg[section]["visible_vendors"] = ", ".join(clean) + elif cfg.has_option(section, "visible_vendors"): + cfg.remove_option(section, "visible_vendors") with open(path, "w", encoding="utf-8") as f: cfg.write(f) return True diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index afe5c5f..8749cf5 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -828,14 +828,14 @@ class KobraXBridge: # Marke ("PolyTerra PLA — Polymaker") statt nur "Generic PLA" anzeigt. try: import config_loader as _cl - self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles() + self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles(self._printer_id) except Exception: self._filament_profiles = {} # Vendor-Sichtbarkeitsfilter fürs Slot-Profil-Dropdown (Issue #41 Option A). # Leere Liste = alle Vendoren sichtbar (rückwärtskompatibel). try: import config_loader as _cl - self._visible_vendors: list[str] = _cl.list_visible_vendors() + self._visible_vendors: list[str] = _cl.list_visible_vendors(self._printer_id) except Exception: self._visible_vendors = [] self._last_state: dict = {} @@ -2601,7 +2601,7 @@ class KobraXBridge: # Persistieren in config.ini try: import config_loader as _cl - _cl.save_filament_profiles(self._filament_profiles) + _cl.save_filament_profiles(self._filament_profiles, self._printer_id) except Exception as e: log.warning(f"save_filament_profiles failed: {e}") return self._json_cors({"error": str(e)}, status=500) @@ -2631,7 +2631,7 @@ class KobraXBridge: self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()] try: import config_loader as _cl - _cl.save_visible_vendors(self._visible_vendors) + _cl.save_visible_vendors(self._visible_vendors, self._printer_id) except Exception as e: log.warning(f"save_visible_vendors failed: {e}") return self._json_cors({"error": str(e)}, status=500) diff --git a/tests/test_filament_profiles_per_printer.py b/tests/test_filament_profiles_per_printer.py new file mode 100644 index 0000000..c014576 --- /dev/null +++ b/tests/test_filament_profiles_per_printer.py @@ -0,0 +1,67 @@ +"""Per-printer filament-profile isolation (config_loader). + +Regression test for the multi-printer bug (issue #74): the slot->profile mapping +and ``visible_vendors`` lived in a single global ``[filament_profiles]`` section, +so configuring one printer overwrote the other and after a restart both loaded +the same map. Each printer now uses its own ``[filament_profiles_]`` section, +with a read-fallback to the legacy global section for backward compatibility. +""" +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root +import config_loader # noqa: E402 + +BASE_INI = ( + "[printer_1]\nname = K1\n\n" + "[printer_2]\nname = K2\n\n" + "[filament_profiles]\n" + "visible_vendors = Anycubic, SUNLU\n" + "slot_0_vendor = Anycubic\nslot_0_name = Anycubic PLA+\nslot_0_id = GFPLA+\n" +) + + +def _use_ini(monkeypatch, tmp_path, text=BASE_INI): + path = tmp_path / "config.ini" + path.write_text(text, encoding="utf-8") + monkeypatch.setattr(config_loader, "_find_config_file", lambda: path) + return path + + +def test_legacy_global_still_works(tmp_path, monkeypatch): + """No printer_id -> original global section (single-printer back-compat).""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+" + assert config_loader.list_visible_vendors() == ["Anycubic", "SUNLU"] + + +def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch): + """Before any per-printer save, both printers see the global mapping.""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+" + assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+" + + +def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch): + """Core regression: configuring printer 1 must not change printer 2.""" + _use_ini(monkeypatch, tmp_path) + config_loader.save_filament_profiles( + {0: {"vendor": "KINGROON", "name": "KINGROON PLA Basic", "id": "Pc0b8a01"}}, "1") + assert config_loader.list_filament_profiles("1")[0]["name"] == "KINGROON PLA Basic" + assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+" + # legacy global section preserved untouched + assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+" + + +def test_visible_vendors_isolated_per_printer(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path) + config_loader.save_visible_vendors(["KINGROON"], "1") + assert config_loader.list_visible_vendors("1") == ["KINGROON"] + assert config_loader.list_visible_vendors("2") == ["Anycubic", "SUNLU"] + + +def test_save_visible_vendors_keeps_slot_fallback(tmp_path, monkeypatch): + """Creating a per-printer section only for vendors must not orphan slots.""" + _use_ini(monkeypatch, tmp_path) + config_loader.save_visible_vendors(["KINGROON"], "1") + assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+" diff --git a/web/themes/default/app.js b/web/themes/default/app.js index e116f34..953487a 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -292,7 +292,7 @@ function renderPrinterDropdown(){ menu.innerHTML=_printers.map(function(p){ var active=_activePrinter&&String(p.id)===String(_activePrinter.id); var num=p.id; - return ''+ + return ''+ (active?'▶ ':'')+p.name+''; }).join(''); } @@ -3243,7 +3243,7 @@ function loadPrinterTab(){ '
'+ '🌡 '+nt+'°C🛏 '+bt+'°C'+ '
'+ - (!isActive?''+T.printers_switch+'':'
'+T.printers_current+'
')+ + (!isActive?''+T.printers_switch+'':'
'+T.printers_current+'
')+ ''; }).join(''); }); From 74fc2ddab03bb14deed6506d04403d1374398f0f Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 30 Jun 2026 11:13:34 +0200 Subject: [PATCH 02/25] feat: color picker, unified UI styling, filament mismatch detection, Spoolman slot assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slot color editor: Pickr HSV color picker (offline, served from lib/), recent swatches (up to 16, localStorage), copy color from other slot - Unified axes control panel: XY+Z merged, shared step size + custom mm input - Language selector moved from header to Settings → Appearance - Filament mismatch detection blocks Upload-and-Print on material mismatch, slot mapper opens automatically - Spoolman spool-per-slot assignment in AMS status tab and Filaments settings - Fix: Spoolman sync rate label — 0=end of print, not disabled (Issue #76) - Fix: lib/ assets served by bridge static handler for offline use - UI: global unified select + input styling, set-row labels match modal-field --- NIGHTLY_CHANGELOG.md | 7 +- kobrax_moonraker_bridge.py | 11 +++ web/themes/default/app.js | 108 ++++++++++++++++++++++ web/themes/default/index.html | 29 ++++-- web/themes/default/lib/pickr-nano.min.css | 2 + web/themes/default/lib/pickr.min.js | 3 + web/themes/default/style.css | 42 ++++++++- web/translations/de.json | 5 +- web/translations/en.json | 5 +- web/translations/es.json | 5 +- web/translations/fr.json | 5 +- web/translations/it.json | 5 +- web/translations/zh-cn.json | 5 +- 13 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 web/themes/default/lib/pickr-nano.min.css create mode 100644 web/themes/default/lib/pickr.min.js diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f6a628a..a34c495 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -3,4 +3,9 @@ - Unified axes control panel: XY and Z merged into one card, shared step size selector (0.1 / 1 / 5 / 10 mm) plus custom mm input field, Home XY/Z buttons placed directly below their respective pads - Language selector moved from header bar to Settings → Appearance - Filament mismatch detection: Upload-and-Print is intercepted when GCode material differs from the loaded AMS slot — slot mapper dialog opens automatically to correct the assignment before printing -- Spoolman: assign a spool per AMS slot directly in the AMS status tab (dropdown per slot kachel) and in the Filaments settings tab (dedicated assignment card) +- Spoolman: assign a spool per AMS slot directly in the AMS status tab (dropdown per slot tile) and in the Filaments settings tab (dedicated assignment card) +- Fix: filament profiles now isolated per printer in multi-printer setups — configuring one printer no longer overwrites the other (PR #75 by @walterioo) +- Fix: printer dropdown and switch link now navigate to each printer's own bridge URL (same-origin, no cross-instance profile bleed) +- Fix: Spoolman sync rate label corrected — 0 means sync at end of print, not disabled (Issue #76) +- Slot color editor: Pickr color picker (HSV wheel + hex input), recent color swatches (up to 16, saved in browser), and "Copy color from slot" dropdown for identical backup spool setup (Issue #73) +- UI: unified dropdown and input field styling across all settings panels diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 8749cf5..a1d8c9e 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -142,6 +142,11 @@ _KX_UI_ASSETS: dict[str, str] = { "style.css": "text/css", "app.js": "application/javascript", } +# Dateien aus lib/ werden anhand der Extension ausgeliefert (kein Whitelist-Eintrag nötig) +_KX_UI_LIB_TYPES: dict[str, str] = { + ".js": "application/javascript", + ".css": "text/css", +} _KX_UI_TRANSLATION_RE = re.compile(r"^translations/([a-z]{2}(?:-[a-z]{2})?)\.json$") # Ring-Buffer für Browser-Log-Stream (letzte 200 Einträge) @@ -3576,6 +3581,12 @@ class KobraXBridge: if ctype is not None: path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) + elif name.startswith("lib/"): + ext = os.path.splitext(name)[1].lower() + ctype = _KX_UI_LIB_TYPES.get(ext) + if not ctype: + raise web.HTTPNotFound() + path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) else: m = _KX_UI_TRANSLATION_RE.match(name) if not m: diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 953487a..a2ca5b3 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -1496,6 +1496,110 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){ }); }); } +// ── Pickr color picker ────────────────────────────────────────────────────── +var _pickr=null; + +function _initPickr(hex){ + // destroy previous instance if exists + if(_pickr){ try{ _pickr.destroyAndRemove(); }catch(e){} _pickr=null; } + var anchor=document.getElementById('slot-pickr-anchor'); + if(!anchor||typeof Pickr==='undefined') return; + // fresh button element so Pickr can mount + anchor.innerHTML='
'; + _pickr=Pickr.create({ + el:'#slot-pickr-btn', + theme:'nano', + default: hex||'#808080', + inline: true, + showAlways: true, + components:{ + preview:true, opacity:false, hue:true, + interaction:{ hex:true, rgba:false, input:true, save:false, clear:false } + } + }); + _pickr.on('change',function(color){ + var h=color.toHEXA().toString().slice(0,7); + document.getElementById('slot-edit-color').value=h; + document.getElementById('slot-edit-preview').style.background=h; + }); + // Theme anpassen: Pickr benutzt eigene CSS-Variablen, wir überschreiben via style + requestAnimationFrame(function(){ + var el=anchor.querySelector('.pickr'); + if(el) el.style.cssText='width:100%'; + var app=anchor.querySelector('.pcr-app'); + if(app){ + app.style.cssText='position:relative;width:100%;box-shadow:none;background:transparent'; + var btn=app.querySelector('.pcr-result'); + if(btn) btn.style.cssText='background:var(--raised);border:1px solid var(--border);color:var(--txt);border-radius:6px;font-size:12px'; + } + }); +} + +// ── Color swatches (localStorage, max 16) ────────────────────────────────── +var _SWATCH_KEY='kxb_color_swatches'; +var _SWATCH_MAX=16; + +function _loadSwatches(){ + try{ return JSON.parse(localStorage.getItem(_SWATCH_KEY)||'[]'); }catch(e){ return []; } +} +function _saveSwatches(arr){ try{ localStorage.setItem(_SWATCH_KEY, JSON.stringify(arr)); }catch(e){} } + +function _addSwatch(hex){ + var arr=_loadSwatches().filter(function(c){ return c.toLowerCase()!==hex.toLowerCase(); }); + arr.unshift(hex); + if(arr.length>_SWATCH_MAX) arr=arr.slice(0,_SWATCH_MAX); + _saveSwatches(arr); +} + +function _renderSwatches(){ + var el=document.getElementById('slot-color-swatches'); + if(!el) return; + var arr=_loadSwatches(); + if(!arr.length){ el.style.display='none'; return; } + el.style.display='flex'; + el.innerHTML=arr.map(function(c){ + return '
'; + }).join(''); +} + +function slotPickSwatch(hex){ + if(_pickr){ _pickr.setColor(hex); } + var ci=document.getElementById('slot-edit-color'); + if(ci) ci.value=hex; + document.getElementById('slot-edit-preview').style.background=hex; +} + +// ── Copy color from other slot ────────────────────────────────────────────── +function _renderCopyFromSlot(currentGlobalIdx){ + var slots=(window._amsSlots||[]).filter(function(s){ + return s.global_index!==currentGlobalIdx && s.status==5 && Array.isArray(s.color); + }); + var row=document.getElementById('slot-copy-row'); + var sel=document.getElementById('slot-copy-select'); + if(!row||!sel) return; + if(!slots.length){ row.style.display='none'; return; } + row.style.display=''; + var ph=document.getElementById('lbl-slot-copy-from'); + var phTxt=ph?ph.textContent:(T.slot_copy_from||'Copy color from slot…'); + sel.innerHTML=''+slots.map(function(s){ + var rgb=s.color; + var hex='#'+rgb.map(function(v){return('0'+Math.min(255,v).toString(16)).slice(-2)}).join(''); + return ''; + }).join(''); +} + +function slotCopyColor(sel){ + if(!sel.value) return; + var ci=document.getElementById('slot-edit-color'); + if(!ci) return; + ci.value=sel.value; + document.getElementById('slot-edit-preview').style.background=sel.value; + sel.selectedIndex=0; +} + +// ─────────────────────────────────────────────────────────────────────────── + function openSlotEdit(i){ var slot=(window._amsSlots||[])[i]||{}; var globalIdx=slot.global_index!=null?slot.global_index:(slot.index!=null?slot.index:i); @@ -1507,6 +1611,9 @@ function openSlotEdit(i){ var ci=document.getElementById('slot-edit-color'); ci.value=hex; document.getElementById('slot-edit-preview').style.background=hex; + _initPickr(hex); + _renderSwatches(); + _renderCopyFromSlot(globalIdx); var mat=(slot.type||'PLA').toUpperCase(); document.getElementById('slot-edit-mat').value=mat; var btns=document.getElementById('slot-mat-btns'); @@ -1631,6 +1738,7 @@ function hexToRgb(hex){ } function saveSlotEdit(){ var hex=document.getElementById('slot-edit-color').value; + _addSwatch(hex); var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA'; var color=hexToRgb(hex); var slotIdx=_slotEditIndex; diff --git a/web/themes/default/index.html b/web/themes/default/index.html index d483d94..0b7874b 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -5,6 +5,8 @@ KX-Bridge + + @@ -46,15 +48,26 @@ -
-
-
-
- +
+
+
+
+ +
+ +
+ +
+ +
@@ -589,7 +602,7 @@
- +
diff --git a/web/themes/default/lib/pickr-nano.min.css b/web/themes/default/lib/pickr-nano.min.css new file mode 100644 index 0000000..31ed391 --- /dev/null +++ b/web/themes/default/lib/pickr-nano.min.css @@ -0,0 +1,2 @@ +/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */ +.pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;border-radius:.15em;background:url("data:image/svg+xml;utf8, ") no-repeat center;background-size:0;transition:all .3s}.pickr .pcr-button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pickr .pcr-button::before{z-index:initial}.pickr .pcr-button::after{position:absolute;content:"";top:0;left:0;height:100%;width:100%;transition:background .3s;background:var(--pcr-color);border-radius:.15em}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear::before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-button.disabled{cursor:not-allowed}.pickr *,.pcr-app *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr input:focus,.pickr input.pcr-active,.pickr button:focus,.pickr button.pcr-active,.pcr-app input:focus,.pcr-app input.pcr-active,.pcr-app button:focus,.pcr-app button.pcr-active{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-palette,.pickr .pcr-slider,.pcr-app .pcr-palette,.pcr-app .pcr-slider{transition:box-shadow .3s}.pickr .pcr-palette:focus,.pickr .pcr-slider:focus,.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:flex;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Helvetica Neue",Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports(display: grid){.pcr-app .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit, 1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;transition:all .15s;overflow:hidden;background:rgba(0,0,0,0);z-index:1}.pcr-app .pcr-swatches>button::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button::after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--pcr-color);border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app .pcr-swatches>button:not(.pcr-active){box-shadow:none}.pcr-app .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -0.2em 0 -0.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{filter:brightness(0.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff;width:auto}.pcr-app .pcr-interaction .pcr-save,.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{color:#fff}.pcr-app .pcr-interaction .pcr-save:hover,.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-interaction .pcr-clear:hover{filter:brightness(0.925)}.pcr-app .pcr-interaction .pcr-save{background:#4285f4}.pcr-app .pcr-interaction .pcr-clear,.pcr-app .pcr-interaction .pcr-cancel{background:#f44250}.pcr-app .pcr-interaction .pcr-clear:focus,.pcr-app .pcr-interaction .pcr-cancel:focus{box-shadow:0 0 0 1px rgba(255,255,255,.85),0 0 0 3px rgba(244,66,80,.75)}.pcr-app .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none}.pcr-app .pcr-selection .pcr-color-palette,.pcr-app .pcr-selection .pcr-color-chooser,.pcr-app .pcr-selection .pcr-color-opacity{position:relative;-webkit-user-select:none;-moz-user-select:none;user-select:none;display:flex;flex-direction:column;cursor:grab;cursor:-webkit-grab}.pcr-app .pcr-selection .pcr-color-palette:active,.pcr-app .pcr-selection .pcr-color-chooser:active,.pcr-app .pcr-selection .pcr-color-opacity:active{cursor:grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=nano]{width:14.25em;max-width:95vw}.pcr-app[data-theme=nano] .pcr-swatches{margin-top:.6em;padding:0 .6em}.pcr-app[data-theme=nano] .pcr-interaction{padding:0 .6em .6em .6em}.pcr-app[data-theme=nano] .pcr-selection{display:grid;grid-gap:.6em;grid-template-columns:1fr 4fr;grid-template-rows:5fr auto auto;align-items:center;height:10.5em;width:100%;align-self:flex-start}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview{grid-area:2/1/4/1;height:100%;width:100%;display:flex;flex-direction:row;justify-content:center;margin-left:.6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-last-color{display:none}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color{position:relative;background:var(--pcr-color);width:2em;height:2em;border-radius:50em;overflow:hidden}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette{grid-area:1/1/2/3;width:100%;height:100%;z-index:1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette{border-radius:.15em;width:100%;height:100%}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette::before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url("data:image/svg+xml;utf8, ");background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser{grid-area:2/2/2/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{grid-area:3/2/3/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{height:.5em;margin:0 .6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-picker{top:50%;transform:translateY(-50%)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{flex-grow:1;border-radius:50em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider{background:linear-gradient(to right, hsl(0, 100%, 50%), hsl(60, 100%, 50%), hsl(120, 100%, 50%), hsl(180, 100%, 50%), hsl(240, 100%, 50%), hsl(300, 100%, 50%), hsl(0, 100%, 50%))}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{background:linear-gradient(to right, transparent, black),url("data:image/svg+xml;utf8, ");background-size:100%,.25em} diff --git a/web/themes/default/lib/pickr.min.js b/web/themes/default/lib/pickr.min.js new file mode 100644 index 0000000..c175e36 --- /dev/null +++ b/web/themes/default/lib/pickr.min.js @@ -0,0 +1,3 @@ +/*! Pickr 1.9.1 MIT | https://github.com/Simonwep/pickr */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pickr=e():t.Pickr=e()}(self,(()=>(()=>{"use strict";var t={d:(e,o)=>{for(var n in o)t.o(o,n)&&!t.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:o[n]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};t.d(e,{default:()=>E});var o={};function n(t,e,o,n,i={}){e instanceof HTMLCollection||e instanceof NodeList?e=Array.from(e):Array.isArray(e)||(e=[e]),Array.isArray(o)||(o=[o]);for(const s of e)for(const e of o)s[t](e,n,{capture:!1,...i});return Array.prototype.slice.call(arguments,1)}t.r(o),t.d(o,{adjustableInputNumbers:()=>p,createElementFromString:()=>r,createFromTemplate:()=>a,eventPath:()=>l,off:()=>s,on:()=>i,resolveElement:()=>c});const i=n.bind(null,"addEventListener"),s=n.bind(null,"removeEventListener");function r(t){const e=document.createElement("div");return e.innerHTML=t.trim(),e.firstElementChild}function a(t){const e=(t,e)=>{const o=t.getAttribute(e);return t.removeAttribute(e),o},o=(t,n={})=>{const i=e(t,":obj"),s=e(t,":ref"),r=i?n[i]={}:n;s&&(n[s]=t);for(const n of Array.from(t.children)){const t=e(n,":arr"),i=o(n,t?{}:r);t&&(r[t]||(r[t]=[])).push(Object.keys(i).length?i:n)}return n};return o(r(t))}function l(t){let e=t.path||t.composedPath&&t.composedPath();if(e)return e;let o=t.target.parentElement;for(e=[t.target,o];o=o.parentElement;)e.push(o);return e.push(document,window),e}function c(t){return t instanceof Element?t:"string"==typeof t?t.split(/>>/g).reduce(((t,e,o,n)=>(t=t.querySelector(e),ot)){function o(o){const n=[.001,.01,.1][Number(o.shiftKey||2*o.ctrlKey)]*(o.deltaY<0?1:-1);let i=0,s=t.selectionStart;t.value=t.value.replace(/[\d.]+/g,((t,o)=>o<=s&&o+t.length>=s?(s=o,e(Number(t),n,i)):(i++,t))),t.focus(),t.setSelectionRange(s,s),o.preventDefault(),t.dispatchEvent(new Event("input"))}i(t,"focus",(()=>i(window,"wheel",o,{passive:!1}))),i(t,"blur",(()=>s(window,"wheel",o)))}const{min:u,max:h,floor:d,round:m}=Math;function f(t,e,o){e/=100,o/=100;const n=d(t=t/360*6),i=t-n,s=o*(1-e),r=o*(1-i*e),a=o*(1-(1-i)*e),l=n%6;return[255*[o,r,s,s,a,o][l],255*[a,o,o,r,s,s][l],255*[s,s,a,o,o,r][l]]}function v(t,e,o){const n=(2-(e/=100))*(o/=100)/2;return 0!==n&&(e=1===n?0:n<.5?e*o/(2*n):e*o/(2-2*n)),[t,100*e,100*n]}function b(t,e,o){const n=u(t/=255,e/=255,o/=255),i=h(t,e,o),s=i-n;let r,a;if(0===s)r=a=0;else{a=s/i;const n=((i-t)/6+s/2)/s,l=((i-e)/6+s/2)/s,c=((i-o)/6+s/2)/s;t===i?r=c-l:e===i?r=1/3+n-c:o===i&&(r=2/3+l-n),r<0?r+=1:r>1&&(r-=1)}return[360*r,100*a,100*i]}function y(t,e,o,n){e/=100,o/=100;return[...b(255*(1-u(1,(t/=100)*(1-(n/=100))+n)),255*(1-u(1,e*(1-n)+n)),255*(1-u(1,o*(1-n)+n)))]}function g(t,e,o){e/=100;const n=2*(e*=(o/=100)<.5?o:1-o)/(o+e)*100,i=100*(o+e);return[t,isNaN(n)?0:n,i]}function _(t){return b(...t.match(/.{2}/g).map((t=>parseInt(t,16))))}function w(t){t=t.match(/^[a-zA-Z]+$/)?function(t){if("black"===t.toLowerCase())return"#000";const e=document.createElement("canvas").getContext("2d");return e.fillStyle=t,"#000"===e.fillStyle?null:e.fillStyle}(t):t;const e={cmyk:/^cmyk\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)/i,rgba:/^rgba?\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D+([\d.]+)(%?)\D*?(([\d.]+)(%?)|$)/i,hsla:/^hsla?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hsva:/^hsva?\D+([\d.]+)\D+([\d.]+)\D+([\d.]+)\D*?(([\d.]+)(%?)|$)/i,hexa:/^#?(([\dA-Fa-f]{3,4})|([\dA-Fa-f]{6})|([\dA-Fa-f]{8}))$/i},o=t=>t.map((t=>/^(|\d+)\.\d+|\d+$/.test(t)?Number(t):void 0));let n;t:for(const i in e)if(n=e[i].exec(t))switch(i){case"cmyk":{const[,t,e,s,r]=o(n);if(t>100||e>100||s>100||r>100)break t;return{values:y(t,e,s,r),type:i}}case"rgba":{let[,t,,e,,s,,,r]=o(n);if(t="%"===n[2]?t/100*255:t,e="%"===n[4]?e/100*255:e,s="%"===n[6]?s/100*255:s,r="%"===n[9]?r/100:r,t>255||e>255||s>255||r<0||r>1)break t;return{values:[...b(t,e,s),r],a:r,type:i}}case"hexa":{let[,t]=n;4!==t.length&&3!==t.length||(t=t.split("").map((t=>t+t)).join(""));const e=t.substring(0,6);let o=t.substring(6);return o=o?parseInt(o,16)/255:void 0,{values:[..._(e),o],a:o,type:i}}case"hsla":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[...g(t,e,s),r],a:r,type:i}}case"hsva":{let[,t,e,s,,r]=o(n);if(r="%"===n[6]?r/100:r,t>360||e>100||s>100||r<0||r>1)break t;return{values:[t,e,s,r],a:r,type:i}}}return{values:null,type:null}}function A(t=0,e=0,o=0,n=1){const i=(t,e)=>(o=-1)=>e(~o?t.map((t=>Number(t.toFixed(o)))):t),s={h:t,s:e,v:o,a:n,toHSVA(){const t=[s.h,s.s,s.v,s.a];return t.toString=i(t,(t=>`hsva(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toHSLA(){const t=[...v(s.h,s.s,s.v),s.a];return t.toString=i(t,(t=>`hsla(${t[0]}, ${t[1]}%, ${t[2]}%, ${s.a})`)),t},toRGBA(){const t=[...f(s.h,s.s,s.v),s.a];return t.toString=i(t,(t=>`rgba(${t[0]}, ${t[1]}, ${t[2]}, ${s.a})`)),t},toCMYK(){const t=function(t,e,o){const n=f(t,e,o),i=n[0]/255,s=n[1]/255,r=n[2]/255,a=u(1-i,1-s,1-r);return[100*(1===a?0:(1-i-a)/(1-a)),100*(1===a?0:(1-s-a)/(1-a)),100*(1===a?0:(1-r-a)/(1-a)),100*a]}(s.h,s.s,s.v);return t.toString=i(t,(t=>`cmyk(${t[0]}%, ${t[1]}%, ${t[2]}%, ${t[3]}%)`)),t},toHEXA(){const t=function(t,e,o){return f(t,e,o).map((t=>m(t).toString(16).padStart(2,"0")))}(s.h,s.s,s.v),e=s.a>=1?"":Number((255*s.a).toFixed(0)).toString(16).toUpperCase().padStart(2,"0");return e&&t.push(e),t.toString=()=>`#${t.join("").toUpperCase()}`,t},clone:()=>A(s.h,s.s,s.v,s.a)};return s}const $=t=>Math.max(Math.min(t,1),0);function C(t){const e={options:Object.assign({lock:null,onchange:()=>0,onstop:()=>0},t),_keyboard(t){const{options:o}=e,{type:n,key:i}=t;if(document.activeElement===o.wrapper){const{lock:o}=e.options,s="ArrowUp"===i,r="ArrowRight"===i,a="ArrowDown"===i,l="ArrowLeft"===i;if("keydown"===n&&(s||r||a||l)){let n=0,i=0;"v"===o?n=s||r?1:-1:"h"===o?n=s||r?-1:1:(i=s?-1:a?1:0,n=l?-1:r?1:0),e.update($(e.cache.x+.01*n),$(e.cache.y+.01*i)),t.preventDefault()}else i.startsWith("Arrow")&&(e.options.onstop(),t.preventDefault())}},_tapstart(t){i(document,["mouseup","touchend","touchcancel"],e._tapstop),i(document,["mousemove","touchmove"],e._tapmove),t.cancelable&&t.preventDefault(),e._tapmove(t)},_tapmove(t){const{options:o,cache:n}=e,{lock:i,element:s,wrapper:r}=o,a=r.getBoundingClientRect();let l=0,c=0;if(t){const e=t&&t.touches&&t.touches[0];l=t?(e||t).clientX:0,c=t?(e||t).clientY:0,la.left+a.width&&(l=a.left+a.width),ca.top+a.height&&(c=a.top+a.height),l-=a.left,c-=a.top}else n&&(l=n.x*a.width,c=n.y*a.height);"h"!==i&&(s.style.left=`calc(${l/a.width*100}% - ${s.offsetWidth/2}px)`),"v"!==i&&(s.style.top=`calc(${c/a.height*100}% - ${s.offsetHeight/2}px)`),e.cache={x:l/a.width,y:c/a.height};const p=$(l/a.width),u=$(c/a.height);switch(i){case"v":return o.onchange(p);case"h":return o.onchange(u);default:return o.onchange(p,u)}},_tapstop(){e.options.onstop(),s(document,["mouseup","touchend","touchcancel"],e._tapstop),s(document,["mousemove","touchmove"],e._tapmove)},trigger(){e._tapmove()},update(t=0,o=0){const{left:n,top:i,width:s,height:r}=e.options.wrapper.getBoundingClientRect();"h"===e.options.lock&&(o=t),e._tapmove({clientX:n+s*t,clientY:i+r*o})},destroy(){const{options:t,_tapstart:o,_keyboard:n}=e;s(document,["keydown","keyup"],n),s([t.wrapper,t.element],"mousedown",o),s([t.wrapper,t.element],"touchstart",o,{passive:!1})}},{options:o,_tapstart:n,_keyboard:r}=e;return i([o.wrapper,o.element],"mousedown",n),i([o.wrapper,o.element],"touchstart",n,{passive:!1}),i(document,["keydown","keyup"],r),e}function k(t={}){t=Object.assign({onchange:()=>0,className:"",elements:[]},t);const e=i(t.elements,"click",(e=>{t.elements.forEach((o=>o.classList[e.target===o?"add":"remove"](t.className))),t.onchange(e),e.stopPropagation()}));return{destroy:()=>s(...e)}}const S={variantFlipOrder:{start:"sme",middle:"mse",end:"ems"},positionFlipOrder:{top:"tbrl",right:"rltb",bottom:"btrl",left:"lrbt"},position:"bottom",margin:8,padding:0},O=(t,e,o)=>{const n="object"!=typeof t||t instanceof HTMLElement?{reference:t,popper:e,...o}:t;return{update(t=n){const{reference:e,popper:o}=Object.assign(n,t);if(!o||!e)throw new Error("Popper- or reference-element missing.");return((t,e,o)=>{const{container:n,arrow:i,margin:s,padding:r,position:a,variantFlipOrder:l,positionFlipOrder:c}={container:document.documentElement.getBoundingClientRect(),...S,...o},{left:p,top:u}=e.style;e.style.left="0",e.style.top="0";const h=t.getBoundingClientRect(),d=e.getBoundingClientRect(),m={t:h.top-d.height-s,b:h.bottom+s,r:h.right+s,l:h.left-d.width-s},f={vs:h.left,vm:h.left+h.width/2-d.width/2,ve:h.left+h.width-d.width,hs:h.top,hm:h.bottom-h.height/2-d.height/2,he:h.bottom-d.height},[v,b="middle"]=a.split("-"),y=c[v],g=l[b],{top:_,left:w,bottom:A,right:$}=n;for(const t of y){const o="t"===t||"b"===t;let n=m[t];const[s,a]=o?["top","left"]:["left","top"],[l,c]=o?[d.height,d.width]:[d.width,d.height],[p,u]=o?[A,$]:[$,A],[v,b]=o?[_,w]:[w,_];if(!(np))for(const p of g){let m=f[(o?"v":"h")+p];if(!(mu)){if(m-=d[a],n-=d[s],e.style[a]=`${m}px`,e.style[s]=`${n}px`,i){const e=o?h.width/2:h.height/2,r=c/2,u=e>r,d=m+{s:u?r:e,m:r,e:u?r:c-e}[p],f=n+{t:l,b:0,r:0,l}[t];i.style[a]=`${d}px`,i.style[s]=`${f}px`}return t+p}}}return e.style.left=p,e.style.top=u,null})(e,o,n)}}};class E{static utils=o;static version="1.9.1";static I18N_DEFAULTS={"ui:dialog":"color picker dialog","btn:toggle":"toggle color picker dialog","btn:swatch":"color swatch","btn:last-color":"use previous color","btn:save":"Save","btn:cancel":"Cancel","btn:clear":"Clear","aria:btn:save":"save and close","aria:btn:cancel":"cancel and close","aria:btn:clear":"clear and close","aria:input":"color input field","aria:palette":"color selection area","aria:hue":"hue selection slider","aria:opacity":"selection slider"};static DEFAULT_OPTIONS={appClass:null,theme:"classic",useAsButton:!1,padding:8,disabled:!1,comparison:!0,closeOnScroll:!1,outputPrecision:0,lockOpacity:!1,autoReposition:!0,container:"body",components:{interaction:{}},i18n:{},swatches:null,inline:!1,sliders:null,default:"#42445a",defaultRepresentation:null,position:"bottom-middle",adjustableNumbers:!0,showAlways:!1,closeWithKey:"Escape"};_initializingActive=!0;_recalc=!0;_nanopop=null;_root=null;_color=A();_lastColor=A();_swatchColors=[];_setupAnimationFrame=null;_eventListener={init:[],save:[],hide:[],show:[],clear:[],change:[],changestop:[],cancel:[],swatchselect:[]};constructor(t){this.options=t=Object.assign({...E.DEFAULT_OPTIONS},t);const{swatches:e,components:o,theme:n,sliders:i,lockOpacity:s,padding:r}=t;["nano","monolith"].includes(n)&&!i&&(t.sliders="h"),o.interaction||(o.interaction={});const{preview:a,opacity:l,hue:c,palette:p}=o;o.opacity=!s&&l,o.palette=p||a||l||c,this._preBuild(),this._buildComponents(),this._bindEvents(),this._finalBuild(),e&&e.length&&e.forEach((t=>this.addSwatch(t)));const{button:u,app:h}=this._root;this._nanopop=O(u,h,{margin:r}),u.setAttribute("role","button"),u.setAttribute("aria-label",this._t("btn:toggle"));const d=this;this._setupAnimationFrame=requestAnimationFrame((function e(){if(!h.offsetWidth)return requestAnimationFrame(e);d.setColor(t.default),d._rePositioningPicker(),t.defaultRepresentation&&(d._representation=t.defaultRepresentation,d.setColorRepresentation(d._representation)),t.showAlways&&d.show(),d._initializingActive=!1,d._emit("init")}))}static create=t=>new E(t);_preBuild(){const{options:t}=this;for(const e of["el","container"])t[e]=c(t[e]);this._root=(t=>{const{components:e,useAsButton:o,inline:n,appClass:i,theme:s,lockOpacity:r}=t.options,l=t=>t?"":'style="display:none" hidden',c=e=>t._t(e),p=a(`\n
\n\n ${o?"":''}\n\n
\n
\n
\n \n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n
\n\n
\n\n
\n \n\n \n \n \n \n \n\n \n \n \n
\n
\n
\n `),u=p.interaction;return u.options.find((t=>!t.hidden&&!t.classList.add("active"))),u.type=()=>u.options.find((t=>t.classList.contains("active"))),p})(this),t.useAsButton&&(this._root.button=t.el),t.container.appendChild(this._root.root)}_finalBuild(){const t=this.options,e=this._root;if(t.container.removeChild(e.root),t.inline){const o=t.el.parentElement;t.el.nextSibling?o.insertBefore(e.app,t.el.nextSibling):o.appendChild(e.app)}else t.container.appendChild(e.app);t.useAsButton?t.inline&&t.el.remove():t.el.parentNode.replaceChild(e.root,t.el),t.disabled&&this.disable(),t.comparison||(e.button.style.transition="none",t.useAsButton||(e.preview.lastColor.style.transition="none")),this.hide()}_buildComponents(){const t=this,e=this.options.components,o=(t.options.sliders||"v").repeat(2),[n,i]=o.match(/^[vh]+$/g)?o:[],s=()=>this._color||(this._color=this._lastColor.clone()),r={palette:C({element:t._root.palette.picker,wrapper:t._root.palette.palette,onstop:()=>t._emit("changestop","slider",t),onchange(o,n){if(!e.palette)return;const i=s(),{_root:r,options:a}=t,{lastColor:l,currentColor:c}=r.preview;t._recalc&&(i.s=100*o,i.v=100-100*n,i.v<0&&(i.v=0),t._updateOutput("slider"));const p=i.toRGBA().toString(0);this.element.style.background=p,this.wrapper.style.background=`\n linear-gradient(to top, rgba(0, 0, 0, ${i.a}), transparent),\n linear-gradient(to left, hsla(${i.h}, 100%, 50%, ${i.a}), rgba(255, 255, 255, ${i.a}))\n `,a.comparison?a.useAsButton||t._lastColor||l.style.setProperty("--pcr-color",p):(r.button.style.setProperty("--pcr-color",p),r.button.classList.remove("clear"));const u=i.toHEXA().toString();for(const{el:e,color:o}of t._swatchColors)e.classList[u===o.toHEXA().toString()?"add":"remove"]("pcr-active");c.style.setProperty("--pcr-color",p)}}),hue:C({lock:"v"===i?"h":"v",element:t._root.hue.picker,wrapper:t._root.hue.slider,onstop:()=>t._emit("changestop","slider",t),onchange(o){if(!e.hue||!e.palette)return;const n=s();t._recalc&&(n.h=360*o),this.element.style.backgroundColor=`hsl(${n.h}, 100%, 50%)`,r.palette.trigger()}}),opacity:C({lock:"v"===n?"h":"v",element:t._root.opacity.picker,wrapper:t._root.opacity.slider,onstop:()=>t._emit("changestop","slider",t),onchange(o){if(!e.opacity||!e.palette)return;const n=s();t._recalc&&(n.a=Math.round(100*o)/100),this.element.style.background=`rgba(0, 0, 0, ${n.a})`,r.palette.trigger()}}),selectable:k({elements:t._root.interaction.options,className:"active",onchange(e){t._representation=e.target.getAttribute("data-type").toUpperCase(),t._recalc&&t._updateOutput("swatch")}})};this._components=r}_bindEvents(){const{_root:t,options:e}=this,o=[i(t.interaction.clear,"click",(()=>this._clearColor())),i([t.interaction.cancel,t.preview.lastColor],"click",(()=>{this.setHSVA(...(this._lastColor||this._color).toHSVA(),!0),this._emit("cancel")})),i(t.interaction.save,"click",(()=>{!this.applyColor()&&!e.showAlways&&this.hide()})),i(t.interaction.result,["keyup","input"],(t=>{this.setColor(t.target.value,!0)&&!this._initializingActive&&(this._emit("change",this._color,"input",this),this._emit("changestop","input",this)),t.stopImmediatePropagation()})),i(t.interaction.result,["focus","blur"],(t=>{this._recalc="blur"===t.type,this._recalc&&this._updateOutput(null)})),i([t.palette.palette,t.palette.picker,t.hue.slider,t.hue.picker,t.opacity.slider,t.opacity.picker],["mousedown","touchstart"],(()=>this._recalc=!0),{passive:!0})];if(!e.showAlways){const n=e.closeWithKey;o.push(i(t.button,"click",(()=>this.isOpen()?this.hide():this.show())),i(document,"keyup",(t=>this.isOpen()&&(t.key===n||t.code===n)&&this.hide())),i(document,["touchstart","mousedown"],(e=>{this.isOpen()&&!l(e).some((e=>e===t.app||e===t.button))&&this.hide()}),{capture:!0}))}if(e.adjustableNumbers){const e={rgba:[255,255,255,1],hsva:[360,100,100,1],hsla:[360,100,100,1],cmyk:[100,100,100,100]};p(t.interaction.result,((t,o,n)=>{const i=e[this.getColorRepresentation().toLowerCase()];if(i){const e=i[n],s=t+(e>=100?1e3*o:o);return s<=0?0:Number((s{n.isOpen()&&(e.closeOnScroll&&n.hide(),null===t?(t=setTimeout((()=>t=null),100),requestAnimationFrame((function e(){n._rePositioningPicker(),null!==t&&requestAnimationFrame(e)}))):(clearTimeout(t),t=setTimeout((()=>t=null),100)))}),{capture:!0}))}this._eventBindings=o}_rePositioningPicker(){const{options:t}=this;if(!t.inline){if(!this._nanopop.update({container:document.body.getBoundingClientRect(),position:t.position})){const t=this._root.app,e=t.getBoundingClientRect();t.style.top=(window.innerHeight-e.height)/2+"px",t.style.left=(window.innerWidth-e.width)/2+"px"}}}_updateOutput(t){const{_root:e,_color:o,options:n}=this;if(e.interaction.type()){const t=`to${e.interaction.type().getAttribute("data-type")}`;e.interaction.result.value="function"==typeof o[t]?o[t]().toString(n.outputPrecision):""}!this._initializingActive&&this._recalc&&this._emit("change",o,t,this)}_clearColor(t=!1){const{_root:e,options:o}=this;o.useAsButton||e.button.style.setProperty("--pcr-color","rgba(0, 0, 0, 0.15)"),e.button.classList.add("clear"),o.showAlways||this.hide(),this._lastColor=null,this._initializingActive||t||(this._emit("save",null),this._emit("clear"))}_parseLocalColor(t){const{values:e,type:o,a:n}=w(t),{lockOpacity:i}=this.options,s=void 0!==n&&1!==n;return e&&3===e.length&&(e[3]=void 0),{values:!e||i&&s?null:e,type:o}}_t(t){return this.options.i18n[t]||E.I18N_DEFAULTS[t]}_emit(t,...e){this._eventListener[t].forEach((t=>t(...e,this)))}on(t,e){return this._eventListener[t].push(e),this}off(t,e){const o=this._eventListener[t]||[],n=o.indexOf(e);return~n&&o.splice(n,1),this}addSwatch(t){const{values:e}=this._parseLocalColor(t);if(e){const{_swatchColors:t,_root:o}=this,n=A(...e),s=r(`
'; }); - document.getElementById('ams-slots').innerHTML=html; + // Nicht rendern wenn ein Spool-Dropdown gerade offen ist (verhindert Schließen beim Poll) + var activeEl=document.activeElement; + var spoolOpen=activeEl&&activeEl.tagName==='SELECT'&&activeEl.dataset.spoolSlot!=null; + if(!spoolOpen) document.getElementById('ams-slots').innerHTML=html; } // camera overlay From 48bec556118c7860960aa26a96d7a366d3d5dda3 Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 30 Jun 2026 12:21:20 +0200 Subject: [PATCH 04/25] =?UTF-8?q?feat(ci):=20linux/arm/v7=20Platform=20zu?= =?UTF-8?q?=20Docker-Build=20hinzugef=C3=BCgt=20(Raspberry=20Pi=202/3=2032?= =?UTF-8?q?-bit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/nightly.yml | 2 +- .gitea/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 2a625bd..9b9818c 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -94,7 +94,7 @@ jobs: # VERSION-Datei im Arbeitsverzeichnis für den Docker-Build setzen (kein Commit) echo "$VERSION" > VERSION docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ --push \ --provenance=false \ --no-cache \ diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 8e58cd9..91ff4ec 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -61,7 +61,7 @@ jobs: run: | VERSION="${GITHUB_REF#refs/tags/v}" docker buildx build \ - --platform linux/amd64,linux/arm64 \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ --push \ --provenance=false \ --no-cache \ From 44383fabec3768f7acb8da2bc0b8dcedfe564369 Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 30 Jun 2026 14:30:10 +0200 Subject: [PATCH 05/25] =?UTF-8?q?fix(docker):=20gcc=20+=20python3-dev=20f?= =?UTF-8?q?=C3=BCr=20pycryptodome=20arm/v7=20Kompilierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 553475a..ceec4e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,11 @@ FROM python:3.11-slim-bookworm WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg gcc python3-dev && rm -rf /var/lib/apt/lists/* COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt && \ + apt-get purge -y gcc python3-dev && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY kobrax_moonraker_bridge.py . COPY web/ ./web/ From 6e9ba0672ffdc8a58180dc1f16cbe2a4d8bbe38b Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 30 Jun 2026 15:43:20 +0200 Subject: [PATCH 06/25] =?UTF-8?q?fix(spoolman):=20Slot-Spool-Zuordnung=20i?= =?UTF-8?q?n=20config.ini=20persistieren=20+=20beim=20Start=20laden;=20API?= =?UTF-8?q?-Feldname-Kompatibilit=C3=A4t=20(slot=5Fspools/slot=5Fmap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kobrax_moonraker_bridge.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index a1d8c9e..0d4e9b2 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -911,7 +911,23 @@ class KobraXBridge: SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0)) if _sm_url else None ) - self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id} + # Persistierte Spool-Zuordnung aus config.ini laden + _slot_spools_init: dict[int, int] = {} + try: + import configparser as _cp2 + _cfg_path2 = config_loader._find_config_file() + if _cfg_path2: + _cfg2 = _cp2.ConfigParser() + _cfg2.read(_cfg_path2, encoding="utf-8") + _raw = _cfg2.get("spoolman", "slot_spools", fallback="") + for _pair in _raw.split(","): + if ":" in _pair: + _k, _v = _pair.strip().split(":", 1) + if _k.isdigit() and _v.isdigit(): + _slot_spools_init[int(_k)] = int(_v) + except Exception: + pass + self._spoolman_slot_spools: dict[int, int] = _slot_spools_init # {ams_slot_idx: spoolman_spool_id} self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick @@ -1058,11 +1074,26 @@ class KobraXBridge: data = await request.json() except Exception: return self._json_cors({"error": "invalid JSON"}, status=400) - slot_map = data.get("slot_map") or {} + slot_map = data.get("slot_map") or data.get("slot_spools") or {} self._spoolman_slot_spools = { int(k): int(v) for k, v in slot_map.items() if str(v).isdigit() and int(v) > 0 } + # Persistieren in config.ini damit die Zuordnung Bridge-Neustart überlebt + try: + import configparser as _cp + _cfg_path = config_loader._find_config_file() + if _cfg_path: + _cfg = _cp.ConfigParser() + _cfg.read(_cfg_path, encoding="utf-8") + if not _cfg.has_section("spoolman"): + _cfg.add_section("spoolman") + _cfg.set("spoolman", "slot_spools", ",".join( + f"{k}:{v}" for k, v in self._spoolman_slot_spools.items())) + with open(_cfg_path, "w", encoding="utf-8") as _f: + _cfg.write(_f) + except Exception as _e: + log.debug(f"Spoolman slot_spools persist error: {_e}") self._spoolman_slot_usage = {} self._spoolman_slot_reported = {} self._spoolman_last_usage = 0.0 From c313e014ad55b7fbf801cfcaebb5f9aad123f8ac Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 30 Jun 2026 22:56:34 +0200 Subject: [PATCH 07/25] =?UTF-8?q?fix(ams):=20paint=5Findex=20im=20auto-map?= =?UTF-8?q?ping=20auf=20global=5Findex=20setzen=20statt=20enumerate-Z?= =?UTF-8?q?=C3=A4hler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei Multicolor-Drucken mit nicht bei 0 startenden Paint-Indizes (T2, T3...) wurde paint_index als 0,1,2... statt als tatsächlicher GCode-T-Index gesendet. Drucker hat dadurch die falschen Slots für die falschen Farben verwendet. Fixes #78 --- kobrax_moonraker_bridge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 0d4e9b2..fd6b60b 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1603,13 +1603,13 @@ class KobraXBridge: loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default) return [ { - "paint_index": pidx, + "paint_index": gidx, "ams_index": self._slot_to_print_ams_index(gidx), "paint_color": [255, 255, 255, 255], "ams_color": self._slot_color_rgba(s), "material_type": s.get("type", "PLA"), } - for pidx, (gidx, s) in enumerate(loaded) + for gidx, s in loaded ] def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: From 4f5aa8d126e913f28a9c486519dd696ffd081cbf Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 30 Jun 2026 23:01:03 +0200 Subject: [PATCH 08/25] =?UTF-8?q?Revert=20"fix(ams):=20paint=5Findex=20im?= =?UTF-8?q?=20auto-mapping=20auf=20global=5Findex=20setzen=20statt=20enume?= =?UTF-8?q?rate-Z=C3=A4hler"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c313e014ad55b7fbf801cfcaebb5f9aad123f8ac. --- kobrax_moonraker_bridge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index fd6b60b..0d4e9b2 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1603,13 +1603,13 @@ class KobraXBridge: loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default) return [ { - "paint_index": gidx, + "paint_index": pidx, "ams_index": self._slot_to_print_ams_index(gidx), "paint_color": [255, 255, 255, 255], "ams_color": self._slot_color_rgba(s), "material_type": s.get("type", "PLA"), } - for gidx, s in loaded + for pidx, (gidx, s) in enumerate(loaded) ] def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: From a16062f44fd00e371d3573f99905ee86e4f65baf Mon Sep 17 00:00:00 2001 From: viewit Date: Wed, 1 Jul 2026 20:51:12 +0200 Subject: [PATCH 09/25] =?UTF-8?q?fix(ams):=20ams=5Fbox=5Fmapping=20mit=20P?= =?UTF-8?q?latzhaltern=20f=C3=BCr=20fehlende=20Paint-Indizes=20auff=C3=BCl?= =?UTF-8?q?len?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drucker interpretiert ams_box_mapping als geordnete Liste (Eintrag N = TN). Bei Drucken die T0 nicht nutzen wurden die Einträge um 1 verschoben, sodass T2 (rot) auf den Slot von T3 (weiß) zeigte. Fixes #78 --- NIGHTLY_CHANGELOG.md | 11 ++--------- kobrax_moonraker_bridge.py | 36 ++++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index a34c495..492b142 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,11 +1,4 @@ ## Changes in this build -- Unified axes control panel: XY and Z merged into one card, shared step size selector (0.1 / 1 / 5 / 10 mm) plus custom mm input field, Home XY/Z buttons placed directly below their respective pads -- Language selector moved from header bar to Settings → Appearance -- Filament mismatch detection: Upload-and-Print is intercepted when GCode material differs from the loaded AMS slot — slot mapper dialog opens automatically to correct the assignment before printing -- Spoolman: assign a spool per AMS slot directly in the AMS status tab (dropdown per slot tile) and in the Filaments settings tab (dedicated assignment card) -- Fix: filament profiles now isolated per printer in multi-printer setups — configuring one printer no longer overwrites the other (PR #75 by @walterioo) -- Fix: printer dropdown and switch link now navigate to each printer's own bridge URL (same-origin, no cross-instance profile bleed) -- Fix: Spoolman sync rate label corrected — 0 means sync at end of print, not disabled (Issue #76) -- Slot color editor: Pickr color picker (HSV wheel + hex input), recent color swatches (up to 16, saved in browser), and "Copy color from slot" dropdown for identical backup spool setup (Issue #73) -- UI: unified dropdown and input field styling across all settings panels +- Fix: wrong filament slot used in multicolor prints when T0 is unused — AMS box mapping now includes placeholder entries for all paint indices so the printer correctly matches T1/T2/T3 tool changes to the right slots (Issue #78) +- Fix: Spoolman spool-slot assignment now persisted to config.ini and restored on bridge restart diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 0d4e9b2..978bab3 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1601,16 +1601,32 @@ class KobraXBridge: loaded = loaded_slots if loaded is None: loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default) - return [ - { - "paint_index": pidx, - "ams_index": self._slot_to_print_ams_index(gidx), - "paint_color": [255, 255, 255, 255], - "ams_color": self._slot_color_rgba(s), - "material_type": s.get("type", "PLA"), - } - for pidx, (gidx, s) in enumerate(loaded) - ] + if not loaded: + return [] + loaded_map = {gidx: s for gidx, s in loaded} + max_idx = max(loaded_map.keys()) + # Drucker interpretiert ams_box_mapping als geordnete Liste (Eintrag N = TN). + # Fehlende Slots müssen als Platzhalter rein, sonst verschiebt sich alles. + result = [] + for i in range(max_idx + 1): + if i in loaded_map: + s = loaded_map[i] + result.append({ + "paint_index": i, + "ams_index": self._slot_to_print_ams_index(i), + "paint_color": [255, 255, 255, 255], + "ams_color": self._slot_color_rgba(s), + "material_type": s.get("type", "PLA"), + }) + else: + result.append({ + "paint_index": i, + "ams_index": self._slot_to_print_ams_index(i), + "paint_color": [255, 255, 255, 255], + "ams_color": [255, 255, 255, 255], + "material_type": "PLA", + }) + return result def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: """Build print mapping from UI filament assignments. From 2a13f1f0dd317e4c8e968f6f808d30bbff3b0f35 Mon Sep 17 00:00:00 2001 From: Walter Almada B Date: Wed, 1 Jul 2026 21:42:40 -0700 Subject: [PATCH 10/25] fix(spoolman): repair dead slot-map persistence + isolate it per printer The AMS-slot -> Spoolman-spool persistence never worked: KobraXBridge referenced `config_loader` in both the load (__init__) and save (handle_kx_spoolman_set_active) paths, but the module alias is `env_loader` (kobrax_moonraker_bridge.py:32). The resulting NameError was swallowed by a bare `except`, so the map was neither loaded on startup nor written on change - it only appeared to persist. The map also lived in a single global `[spoolman] slot_spools` key, so on a multi-printer bridge two AMS units clobbered each other's mapping (same class of bug as #74/#75 for filament profiles). - config_loader: add list_spool_map()/save_spool_map(printer_id) using a per-printer `[spoolman_]` section with read-fallback to the legacy global key, mirroring _filament_section/list_filament_profiles. The global `[spoolman]` section keeps server/sync_rate. - bridge: load via config_loader.list_spool_map(self._printer_id); persist via save_spool_map(..., self._printer_id); surface failures via log.warning instead of a silent except. - _build_mmu_object: emit real gate_spool_id from the per-printer map (was hardcoded [-1]*num_gates) so Happy-Hare/OrcaSlicer can show the bound spool. - config.ini.example: document the [spoolman] section. - tests: tests/test_spoolman_slot_map.py (per-printer isolation, persistence round-trip, server/sync_rate preservation, parser robustness). Verified on a 2-printer bridge: after restart KX1 loads its spools and KX2 loads its own, isolated; a real multicolor print deducted per slot (white spool 1.02g vs 0.98g slicer estimate) against the correct printer's spools. Co-Authored-By: Claude Opus 4.8 (1M context) --- config.ini.example | 15 +++++ config_loader.py | 76 ++++++++++++++++++++++++ kobrax_moonraker_bridge.py | 50 +++++++--------- tests/test_spoolman_slot_map.py | 100 ++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 30 deletions(-) create mode 100644 tests/test_spoolman_slot_map.py diff --git a/config.ini.example b/config.ini.example index f0fa3b3..5b31436 100644 --- a/config.ini.example +++ b/config.ini.example @@ -41,6 +41,21 @@ web_upload_warning = 1 # Poll-Intervall in Sekunden poll_interval = 3 +# ─── Spoolman (optional) ─────────────────────────────────────────────────────── +# Verfolgt den Filamentverbrauch je AMS-Slot und bucht ihn automatisch vom +# passenden Spool ab (mm-basiert, wie Moonraker; Spoolman rechnet mm→Gramm). +# [spoolman] +# # Server-URL der Spoolman-Instanz (aus Sicht des Bridge-Containers erreichbar): +# server = http://192.168.x.x:7912 +# # 0 = nur am Druckende abbuchen, >0 = alle N Sekunden während des Drucks: +# sync_rate = 0 +# +# Die AMS-Slot → Spool-Zuordnung wird in der Weboberfläche gesetzt und je Drucker +# automatisch persistiert (nicht von Hand eintragen): +# Einzeldrucker : [spoolman] slot_spools = 0:42,1:17 +# Multi-Printer : [spoolman_1] slot_spools = 0:42,1:17 +# [spoolman_2] slot_spools = 0:5,1:6 + # ─── Multi-Printer (optional) ────────────────────────────────────────────────── # Mehrere Drucker können als [printer_1], [printer_2], … definiert werden. # Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port). diff --git a/config_loader.py b/config_loader.py index 1b66678..888fd26 100644 --- a/config_loader.py +++ b/config_loader.py @@ -349,6 +349,82 @@ def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) - return True +def _spoolman_map_section(printer_id: Optional[str] = None) -> str: + """Section name holding a printer's AMS-slot → Spoolman-spool map. + + Multi-printer (one bridge, N printers): each printer keeps its map in its + own ``[spoolman_]`` section so two AMS units cannot overwrite each + other's mapping. ``printer_id is None`` (single-printer / legacy callers) + uses the original ``[spoolman] slot_spools`` key — full backward + compatibility. The global ``[spoolman]`` section keeps ``server`` / + ``sync_rate`` regardless. + """ + pid = str(printer_id).strip() if printer_id is not None else "" + if pid and pid != "0": + return f"{CONFIG_SECTION_SPOOLMAN}_{pid}" + return CONFIG_SECTION_SPOOLMAN + + +def _parse_slot_spools(raw: str) -> dict[int, int]: + """Parse ``"0:42,1:17"`` → ``{0: 42, 1: 17}`` (positive spool ids only).""" + result: dict[int, int] = {} + for pair in (raw or "").split(","): + pair = pair.strip() + if ":" not in pair: + continue + k, _, v = pair.partition(":") + k, v = k.strip(), v.strip() + if k.isdigit() and v.lstrip("-").isdigit() and int(v) > 0: + result[int(k)] = int(v) + return result + + +def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]: + """Read the AMS-slot → Spoolman-spool-id map from config.ini. + + With ``printer_id`` set, reads the per-printer ``[spoolman_] + slot_spools`` key and falls back to the legacy global ``[spoolman] + slot_spools`` while that printer has no own section yet. Returns + ``{slot_index: spool_id}`` (only positive ids). + """ + path = _find_config_file() + if not path: + return {} + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _spoolman_map_section(printer_id) + if cfg.has_option(section, "slot_spools"): + return _parse_slot_spools(cfg.get(section, "slot_spools", fallback="")) + if cfg.has_option(CONFIG_SECTION_SPOOLMAN, "slot_spools"): # legacy global fallback + return _parse_slot_spools(cfg.get(CONFIG_SECTION_SPOOLMAN, "slot_spools", fallback="")) + return {} + + +def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None) -> bool: + """Persist the AMS-slot → Spoolman-spool-id map to config.ini. + + With ``printer_id`` set, writes only the per-printer ``[spoolman_]`` + section so other printers and the global ``[spoolman]`` server config stay + untouched. An empty map clears the key. + """ + path = _find_config_file() + if not path: + return False + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _spoolman_map_section(printer_id) + clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0} + if clean: + if not cfg.has_section(section): + cfg.add_section(section) + cfg[section]["slot_spools"] = ",".join(f"{k}:{v}" for k, v in sorted(clean.items())) + elif cfg.has_option(section, "slot_spools"): + cfg.remove_option(section, "slot_spools") + with open(path, "w", encoding="utf-8") as f: + cfg.write(f) + return True + + def get(key: str, default: str = "") -> str: return os.environ.get(key, default) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 978bab3..2deb518 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -911,23 +911,16 @@ class KobraXBridge: SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0)) if _sm_url else None ) - # Persistierte Spool-Zuordnung aus config.ini laden - _slot_spools_init: dict[int, int] = {} + # Persistierte Spool-Zuordnung (AMS-Slot → Spoolman-Spool) je Drucker laden. + # Fix: hier wurde `config_loader` referenziert, aber der Modul-Alias ist + # `env_loader` (Zeile 32) → NameError, den das bare `except` verschluckte, + # sodass die Persistenz nie lud. Jetzt über den lokalen Import + per-Drucker. try: - import configparser as _cp2 - _cfg_path2 = config_loader._find_config_file() - if _cfg_path2: - _cfg2 = _cp2.ConfigParser() - _cfg2.read(_cfg_path2, encoding="utf-8") - _raw = _cfg2.get("spoolman", "slot_spools", fallback="") - for _pair in _raw.split(","): - if ":" in _pair: - _k, _v = _pair.strip().split(":", 1) - if _k.isdigit() and _v.isdigit(): - _slot_spools_init[int(_k)] = int(_v) - except Exception: - pass - self._spoolman_slot_spools: dict[int, int] = _slot_spools_init # {ams_slot_idx: spoolman_spool_id} + import config_loader as _cl + self._spoolman_slot_spools: dict[int, int] = _cl.list_spool_map(self._printer_id) + except Exception as _e: + log.warning("Spoolman: Slot-Map laden fehlgeschlagen: %s", _e) + self._spoolman_slot_spools = {} # {ams_slot_idx: spoolman_spool_id} self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick @@ -1079,21 +1072,14 @@ class KobraXBridge: int(k): int(v) for k, v in slot_map.items() if str(v).isdigit() and int(v) > 0 } - # Persistieren in config.ini damit die Zuordnung Bridge-Neustart überlebt + # Persistieren je Drucker (eigene [spoolman_]-Sektion), damit die + # Zuordnung Bridge-Neustart überlebt und zwei AMS sich nicht überschreiben. + # (Vorher: NameError auf `config_loader` → nichts wurde je gespeichert.) try: - import configparser as _cp - _cfg_path = config_loader._find_config_file() - if _cfg_path: - _cfg = _cp.ConfigParser() - _cfg.read(_cfg_path, encoding="utf-8") - if not _cfg.has_section("spoolman"): - _cfg.add_section("spoolman") - _cfg.set("spoolman", "slot_spools", ",".join( - f"{k}:{v}" for k, v in self._spoolman_slot_spools.items())) - with open(_cfg_path, "w", encoding="utf-8") as _f: - _cfg.write(_f) + import config_loader as _cl + _cl.save_spool_map(self._spoolman_slot_spools, self._printer_id) except Exception as _e: - log.debug(f"Spoolman slot_spools persist error: {_e}") + log.warning("Spoolman: Slot-Map speichern fehlgeschlagen: %s", _e) self._spoolman_slot_usage = {} self._spoolman_slot_reported = {} self._spoolman_last_usage = 0.0 @@ -2096,6 +2082,7 @@ class KobraXBridge: num_gates = len(slots) gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], [] gate_filament_name = [] + gate_spool_id = [] for _global_index, slot in slots: occupied = slot.get("status") == 5 gate_status.append(1 if occupied else 0) @@ -2118,6 +2105,9 @@ class KobraXBridge: gate_filament_name.append(fila_name) else: gate_filament_name.append("") + # Spoolman-Spool-ID je Gate aus der (druckerspezifischen) Slot-Map, damit + # Happy-Hare/OrcaSlicer den gebundenen Spool anzeigen kann (-1 = keiner). + gate_spool_id.append(self._spoolman_slot_spools.get(_global_index, -1) if occupied else -1) loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(slots)} active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1) @@ -2130,7 +2120,7 @@ class KobraXBridge: "gate_temperature": gate_temperature, "gate_color_rgb": gate_color_rgb, "gate_filament_name": gate_filament_name, - "gate_spool_id": [-1] * num_gates, + "gate_spool_id": gate_spool_id, "ttg_map": list(range(num_gates)), "tool": active_gate, "gate": active_gate, diff --git a/tests/test_spoolman_slot_map.py b/tests/test_spoolman_slot_map.py new file mode 100644 index 0000000..c744694 --- /dev/null +++ b/tests/test_spoolman_slot_map.py @@ -0,0 +1,100 @@ +"""Per-printer Spoolman slot-map isolation + persistence (config_loader). + +Regression test for two bugs in the Spoolman slot→spool persistence: + + 1. The bridge referenced ``config_loader`` while the module alias is + ``env_loader`` → ``NameError`` swallowed by a bare ``except``, so the map + was never loaded nor saved (persistence looked implemented but was dead). + 2. The map lived in a single global ``[spoolman] slot_spools`` key, so two + printers/two AMS units overwrote each other (same class as issue #74/#75). + +Each printer now uses its own ``[spoolman_]`` section, with a read-fallback +to the legacy global key for backward compatibility. The global ``[spoolman]`` +section keeps ``server`` / ``sync_rate``. +""" +import sys +import pathlib +import configparser + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root +import config_loader # noqa: E402 + +BASE_INI = ( + "[printer_1]\nname = K1\n\n" + "[printer_2]\nname = K2\n\n" + "[spoolman]\n" + "server = http://192.168.3.200:7912\n" + "sync_rate = 0\n" + "slot_spools = 0:1,1:2\n" +) + + +def _use_ini(monkeypatch, tmp_path, text=BASE_INI): + path = tmp_path / "config.ini" + path.write_text(text, encoding="utf-8") + monkeypatch.setattr(config_loader, "_find_config_file", lambda: path) + return path + + +def test_legacy_global_read(tmp_path, monkeypatch): + """No printer_id -> original global [spoolman] slot_spools (back-compat).""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_spool_map() == {0: 1, 1: 2} + + +def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch): + """Before any per-printer save, both printers see the global mapping.""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_spool_map("1") == {0: 1, 1: 2} + assert config_loader.list_spool_map("2") == {0: 1, 1: 2} + + +def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch): + """Core regression: mapping printer 1 must not change printer 2.""" + _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42, 1: 17}, "1") + assert config_loader.list_spool_map("1") == {0: 42, 1: 17} + # printer 2 has no own section yet -> still the global fallback + assert config_loader.list_spool_map("2") == {0: 1, 1: 2} + # legacy global key preserved untouched + assert config_loader.list_spool_map() == {0: 1, 1: 2} + + +def test_both_printers_isolated_after_each_saves(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42, 1: 17}, "1") + config_loader.save_spool_map({0: 5, 1: 6}, "2") + assert config_loader.list_spool_map("1") == {0: 42, 1: 17} + assert config_loader.list_spool_map("2") == {0: 5, 1: 6} + + +def test_save_preserves_server_and_sync_rate(tmp_path, monkeypatch): + """Writing a per-printer map must not clobber [spoolman] server/sync_rate.""" + path = _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42}, "1") + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + assert cfg.get("spoolman", "server") == "http://192.168.3.200:7912" + assert cfg.get("spoolman", "sync_rate") == "0" + assert cfg.get("spoolman_1", "slot_spools") == "0:42" + + +def test_persistence_round_trips(tmp_path, monkeypatch): + """Save then read back (simulates a bridge restart) — the map survives.""" + _use_ini(monkeypatch, tmp_path, text="[spoolman]\nserver = http://x:7912\n") + config_loader.save_spool_map({0: 7, 2: 9}, "1") + assert config_loader.list_spool_map("1") == {0: 7, 2: 9} + + +def test_empty_map_clears_the_key(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42}, "1") + config_loader.save_spool_map({}, "1") # clear + # per-printer key gone -> falls back to the legacy global map + assert config_loader.list_spool_map("1") == {0: 1, 1: 2} + + +def test_parse_ignores_malformed_and_nonpositive(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path, + text="[spoolman]\nslot_spools = 0:1, x:y, 2:0, 3:-4, 4:5, junk\n") + assert config_loader.list_spool_map() == {0: 1, 4: 5} From a39226d2dd3d2beebe48f67d961b54aa1bed0089 Mon Sep 17 00:00:00 2001 From: Walter Almada B Date: Wed, 1 Jul 2026 22:21:59 -0700 Subject: [PATCH 11/25] fix(spoolman): show vendor name in the spool dropdown (was "[object Object]") The print-dialog spool dropdown built its option label from sp.filament.vendor (the whole vendor object) instead of sp.filament.vendor.name, so options rendered as "#5 [object Object] PLA+ (1000g)". The sibling builder in the slot card already uses .vendor.name; this aligns the two. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/themes/default/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/themes/default/app.js b/web/themes/default/app.js index b06d08f..6f80ba4 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -100,7 +100,7 @@ function _buildSpoolmanSection(){ var currentSpool=_slotSpoolMap[String(idx)]||''; var opts=''+_spoolmanSpools.map(function(sp){ var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':''; - var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor+' ':''; + var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':''; var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool'; return ''; From cd11542352baa3382c6a90ce731d8e50fce13066 Mon Sep 17 00:00:00 2001 From: viewit Date: Thu, 2 Jul 2026 21:54:42 +0200 Subject: [PATCH 12/25] =?UTF-8?q?fix(filament):=20PLA-Varianten=20(PLA+,?= =?UTF-8?q?=20Silk,=20Matte)=20korrekt=20erkennen=20und=20an=20OrcaSlicer?= =?UTF-8?q?=20=C3=BCbermitteln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _normalize_material() normalisiert Drucker-Typen auf kanonische Keys - _TRAY_INFO_IDX erweitert um Silk/Matte/CF-Varianten und Schreibweisen - _default_filament_name() mappt Varianten auf korrekte Generic-Profile - Filament-Dropdown zeigt Hersteller-Profile der jeweiligen Variante - Material-Buttons: PLA+, PLA Silk, PLA Matte hinzugefügt Fixes #82 --- NIGHTLY_CHANGELOG.md | 7 +++- kobrax_moonraker_bridge.py | 80 ++++++++++++++++++++++++++++++-------- web/themes/default/app.js | 29 ++++++++++++-- 3 files changed, 93 insertions(+), 23 deletions(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 492b142..9055ef9 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,4 +1,7 @@ ## Changes in this build -- Fix: wrong filament slot used in multicolor prints when T0 is unused — AMS box mapping now includes placeholder entries for all paint indices so the printer correctly matches T1/T2/T3 tool changes to the right slots (Issue #78) -- Fix: Spoolman spool-slot assignment now persisted to config.ini and restored on bridge restart +- Fix: Spoolman spool-slot assignment persistence was silently broken (NameError on config_loader swallowed by bare except) — now uses correct module reference and logs failures; per-printer isolation added so multi-printer setups no longer overwrite each other (Issue #80, PR #83 by @walterioo) +- Fix: spool dropdown in print dialog showed "[object Object]" instead of vendor name (PR #83) +- Fix: gate_spool_id in MMU object now populated from per-printer slot map instead of hardcoded -1 (PR #83) +- Fix: PLA variants (PLA+, PLA Silk, PLA Matte) now recognized as their material family — correct tray_info_idx sent to printer, correct Generic profile synced to OrcaSlicer, and vendor profiles for the variant shown in slot editor dropdown (Issue #82) +- UI: PLA+, PLA Silk and PLA Matte buttons added to slot material selector diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 2deb518..c08fe00 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1855,23 +1855,55 @@ class KobraXBridge: # `compatible_printers: []` (= mit allen Druckern kompatibel). _TRAY_INFO_IDX = { # Anycubic-eigene Kobra-X-Profile - "PLA": "GFPLA", - "PLA+": "GFPLA+", - "PLA SILK": "GFPLA Silk", - "PETG": "GFPETG", - "ABS": "GFABS", - "ASA": "GFASA", - "TPU": "GFTPU 95A", - "PVA": "GFPVA", + "PLA": "GFPLA", + "PLA+": "GFPLA+", + "PLA SILK": "GFPLA Silk", + "PLA-SILK": "GFPLA Silk", + "PLASILK": "GFPLA Silk", + "SILK PLA": "GFPLA Silk", + "PLA MATTE": "GFPLA", + "PLA-MATTE": "GFPLA", + "PLA MARBLE": "GFPLA", + "PLA WOOD": "GFPLA", + "PETG": "GFPETG", + "PETG+": "GFPETG", + "ABS": "GFABS", + "ASA": "GFASA", + "TPU": "GFTPU 95A", + "TPE": "GFTPU 95A", + "PVA": "GFPVA", # Kein Anycubic-Kobra-X-Profil → Library-Fallback - "PLA-CF": "OGFL98", - "PETG-CF": "OGFG98", - "PA": "OGFN99", - "PA-CF": "OGFN98", - "PC": "OGFC99", - "HIPS": "OGFS98", + "PLA-CF": "OGFL98", + "PLA CF": "OGFL98", + "PETG-CF": "OGFG98", + "PETG CF": "OGFG98", + "PA": "OGFN99", + "PA-CF": "OGFN98", + "PA CF": "OGFN98", + "PC": "OGFC99", + "HIPS": "OGFS98", } + # Normalisiert Material-Typ-Strings auf den kanonischen Key für _TRAY_INFO_IDX + # und _default_filament_name. PLA-Varianten die nicht exakt matchen fallen + # auf ihre Basisfamilie zurück (PLA+ → PLA+, PLA Matte → PLA, etc.). + @staticmethod + def _normalize_material(mat: str) -> str: + m = mat.upper().strip().replace("-", " ").replace("_", " ") + # Bekannte Varianten normalisieren + _ALIASES = { + "PLAPLUS": "PLA+", "PLA PLUS": "PLA+", + "SILK PLA": "PLA SILK", "PLASILK": "PLA SILK", + "PLA MATTE": "PLA MATTE", "PLA MARBLE": "PLA MARBLE", + "PLA WOOD": "PLA WOOD", + "TPE": "TPU", + "PETG PLUS": "PETG+", + "PA6": "PA", "PA12": "PA", "PA66": "PA", + } + if m in _ALIASES: + return _ALIASES[m] + return m + def _build_lane_data(self) -> dict: """Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0. @@ -1910,7 +1942,7 @@ class KobraXBridge: color_hex = color_raw[:6].upper() + "FF" else: color_hex = "FFFFFFFF" - material = slot.get("type", "PLA").upper() + material = self._normalize_material(slot.get("type", "PLA")) # User-Override aus config.ini [filament_profiles].slot_N_id # bekommt Vorrang vor dem Default-Mapping nach material-Type. # Vendor wird mitgesendet (tray_sub_brands + filament_vendor), @@ -2086,7 +2118,7 @@ class KobraXBridge: for _global_index, slot in slots: occupied = slot.get("status") == 5 gate_status.append(1 if occupied else 0) - material = (slot.get("type") or "PLA").upper() if occupied else "" + material = self._normalize_material(slot.get("type") or "PLA") if occupied else "" gate_material.append(material) c = slot.get("color", [0, 0, 0]) if occupied else [0, 0, 0] # Happy Hare erwartet gate_color als RRGGBB OHNE '#' (Klipper-Limitation). @@ -2136,8 +2168,22 @@ class KobraXBridge: kann pro Slot eine konkrete Marke setzen wenn er das will.""" if not material: return "" - mat = material.upper().strip() + mat = self._normalize_material(material) profs = self._load_orca_filaments() + # Varianten-Mapping: Drucker meldet z.B. "PLA SILK", OrcaSlicer speichert + # alle Varianten unter type=PLA mit dem Variant-Namen im name-Feld. + _VARIANT_NAME = { + "PLA SILK": "Generic PLA Silk", + "PLA MATTE": "Generic PLA Matte", + "PLA+": "Generic PLA", + "PLA-CF": "Generic PLA-CF", + "PETG-CF": "Generic PETG-CF", + } + if mat in _VARIANT_NAME: + target = _VARIANT_NAME[mat] + for p in profs: + if p.get("vendor") == "Generic" and p.get("name") == target: + return p["name"] def _match_type(p: dict) -> bool: pt = (p.get("type") or "").upper() return pt == mat or pt.startswith(mat + "-") or pt.startswith(mat + " ") diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 6f80ba4..696c1bc 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -1413,7 +1413,14 @@ function doProfileImportUpload(files){ // ── AMS Slot Edit ── var _slotEditIndex=-1; var _slotEditLoaded=false; -var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS']; +var _MAT_PRESETS=['PLA','PLA+','PLA SILK','PLA MATTE','PETG','ABS','ASA','TPU','PA','PC','HIPS']; +function _normalizeMat(m){ + var s=m.toUpperCase().trim().replace(/-/g,' ').replace(/_/g,' '); + var aliases={'PLAPLUS':'PLA+','PLA PLUS':'PLA+','SILK PLA':'PLA SILK','PLASILK':'PLA SILK', + 'PLA MATTE':'PLA','PLA MARBLE':'PLA','PLA WOOD':'PLA','TPE':'TPU', + 'PETG PLUS':'PETG+','PA6':'PA','PA12':'PA','PA66':'PA'}; + return aliases[s]||s; +} var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK']; function updateSlotEditFeedButton(){ var btn=document.getElementById('btn-slot-edit-feed'); @@ -1454,10 +1461,21 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){ _loadOrcaFilaments(function(profiles){ // Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten) var matU=(material||'').toUpperCase().trim(); + // PLA-Varianten: Drucker meldet "PLA SILK"/"PLA+"/"PLA MATTE", OrcaSlicer + // speichert alle unter type=PLA — Namens-Keyword als Zusatzfilter. + var _PLA_VARIANT_KW={'PLA SILK':'silk','PLA+':'pla+','PLA MATTE':'matte', + 'PLA MARBLE':'marble','PLA WOOD':'wood'}; + var variantKw=_PLA_VARIANT_KW[matU]||null; + var baseMat=variantKw?'PLA':matU; var matched=profiles.filter(function(p){ var pt=(p.type||'').toUpperCase(); - // PLA-CF, PLA-SILK etc. zählen auch zu PLA - return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' '); + var nameL=(p.name||'').toLowerCase(); + // Basis-Typ muss passen + var typeOk=baseMat===''||pt===baseMat||pt.startsWith(baseMat+'-')||pt.startsWith(baseMat+' '); + if(!typeOk) return false; + // Bei Variante: Name muss Keyword enthalten (z.B. "silk", "matte", "pla+") + if(variantKw) return nameL.indexOf(variantKw)!==-1; + return true; }); sel.innerHTML=''; // User-Profile (is_user) zuerst — eigene Optgroup '★ Eigene' an erster Stelle. @@ -1619,11 +1637,14 @@ function openSlotEdit(i){ _renderCopyFromSlot(globalIdx); var mat=(slot.type||'PLA').toUpperCase(); document.getElementById('slot-edit-mat').value=mat; + // Normalisieren für Button-Highlighting: PLA-Varianten auf nächsten Preset mappen + var matNorm=_normalizeMat(mat); var btns=document.getElementById('slot-mat-btns'); btns.innerHTML=_MAT_PRESETS.map(function(m){ + var active=m===mat||m===matNorm; return ''; + +(active?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+''; }).join(''); // OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot // aus /kx/filament/slots holen (enthält vendor+name+id). From 786fa08ca0df4191bf69cd2da4c43201a698a582 Mon Sep 17 00:00:00 2001 From: viewit Date: Mon, 6 Jul 2026 13:54:44 +0200 Subject: [PATCH 13/25] fix: dashboard reprint slot-shift, startup IP log, resonance compensation toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard reprint now delegates to _start_print with gcode_filaments from DB so the used_paint_indices filter applies correctly (Issue #84) - Startup log no longer shows 0.0.0.0 — actual LAN IP is displayed (Issue #86) - New vibration_compensation setting: toggle in Settings UI activates resonance compensation before each print, follows exact auto_leveling pattern (Issue #85) --- NIGHTLY_CHANGELOG.md | 4 ++ config_loader.py | 15 +++--- env_loader.py | 5 +- kobrax_moonraker_bridge.py | 90 +++++++++++++++++++++++------------ web/themes/default/app.js | 7 ++- web/themes/default/index.html | 4 ++ web/translations/de.json | 1 + web/translations/en.json | 1 + web/translations/es.json | 1 + web/translations/zh-cn.json | 1 + 10 files changed, 88 insertions(+), 41 deletions(-) diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 9055ef9..07ae38d 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,5 +1,9 @@ ## Changes in this build +- Fix: Dashboard reprint no longer targets wrong/empty slot after a spool is removed between prints — slot mapping now uses cached GCode filament data to apply the same used-paint-index filter as a regular upload+print (Issue #84) +- Fix: Startup log no longer shows `0.0.0.0` as the bridge address — the actual LAN IP is now displayed, matching what OrcaSlicer needs (Issue #86) +- Feat: New "Resonance Compensation" toggle in Settings — when enabled, `vibration_compensation` is activated before each print, compensating for mass shifts as the spool empties (Issue #85) + - Fix: Spoolman spool-slot assignment persistence was silently broken (NameError on config_loader swallowed by bare except) — now uses correct module reference and logs failures; per-printer isolation added so multi-printer setups no longer overwrite each other (Issue #80, PR #83 by @walterioo) - Fix: spool dropdown in print dialog showed "[object Object]" instead of vendor name (PR #83) - Fix: gate_spool_id in MMU object now populated from per-printer slot map instead of hardcoded -1 (PR #83) diff --git a/config_loader.py b/config_loader.py index 888fd26..df01ee8 100644 --- a/config_loader.py +++ b/config_loader.py @@ -60,8 +60,9 @@ def _load_config_file(path: pathlib.Path): "MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"), "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), - "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), - "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), + "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), + "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"), + "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), @@ -113,8 +114,9 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): } cfg[CONFIG_SECTION_PRINT] = { "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), - "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), - "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), + "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), + "vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"), + "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), "web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), } cfg[CONFIG_SECTION_BRIDGE] = { @@ -437,8 +439,9 @@ PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") -AUTO_LEVELING = int(get("AUTO_LEVELING","1")) -CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0")) +AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) +VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "") diff --git a/env_loader.py b/env_loader.py index 0ff9433..9f93a0f 100644 --- a/env_loader.py +++ b/env_loader.py @@ -47,7 +47,8 @@ PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") -AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) -CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) +AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) +VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index c08fe00..c882403 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -2970,7 +2970,7 @@ class KobraXBridge: }, "task_settings": { "auto_leveling": auto_leveling, - "vibration_compensation": 0, + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), "flow_calibration": 0, "dry_mode": 0, "ai_settings": {"status": 0, "count": 0, "type": 1}, @@ -3555,25 +3555,50 @@ class KobraXBridge: excluded_objects = body.get("excluded_objects") or [] if not isinstance(excluded_objects, list): excluded_objects = [] - if filament_assignments is not None: - ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments) - if unused_count: - log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") - if invalid_count: - log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") - if not ams_box_mapping: - return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) - else: - # AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen - ams_box_mapping = self._build_auto_ams_box_mapping() - use_ams = len(ams_box_mapping) > 0 - # Dialog-Checkbox (body) hat Vorrang, sonst Setting-Default (wie handle_kx_print). auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) url = self._state.get("last_upload_url", "") filesize = self._state.get("last_upload_size", 0) md5 = self._state.get("last_upload_md5", "") + if filament_assignments is not None: + # Explizite Slot-Zuweisung aus dem Filament-Dialog + ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments) + if unused_count: + log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") + if invalid_count: + log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") + if not ams_box_mapping: + return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) + else: + # Dashboard-Reprint: gcode_filaments aus DB laden damit used_paint_indices- + # Filter greift und leere/verschobene Slots nicht falsch gemappt werden. + gcode_filaments = None + try: + db_file = self._db.get_file_by_name(filename) + if db_file and db_file.get("gcode_filaments"): + gcode_filaments = json.loads(db_file["gcode_filaments"]) + except Exception: + pass + + # Pre-Print Skip setzen bevor _start_print aufgerufen wird + self._skip_state = {"skipped": [], "ts": int(time.time())} + if excluded_objects: + self._pending_preprint_skip = [str(n) for n in excluded_objects if isinstance(n, str) and n] + self._pending_preprint_skip_deadline = time.time() + 12.0 + else: + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + + log.info(f"print/start api=1 mode={self._filament_mode} assignments=False gcode_filaments={gcode_filaments is not None}") + loop = asyncio.get_event_loop() + loop.run_in_executor(None, lambda: self._start_print( + filename, url, md5, filesize, + gcode_filaments=gcode_filaments, + )) + return web.json_response({"result": "ok"}) + + use_ams = len(ams_box_mapping) > 0 payload = { "taskid": "-1", "url": url, @@ -3589,7 +3614,7 @@ class KobraXBridge: }, "task_settings": { "auto_leveling": auto_leveling, - "vibration_compensation": 0, + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), "flow_calibration": 0, "dry_mode": 0, "ai_settings": {"status": 0, "count": 0, "type": 0}, @@ -3610,7 +3635,7 @@ class KobraXBridge: log.info( f"print/start api=1 mode={self._filament_mode} " - f"ams={len(ams_box_mapping)} slots assignments={filament_assignments is not None}" + f"ams={len(ams_box_mapping)} slots assignments=True" ) loop = asyncio.get_event_loop() @@ -4274,10 +4299,11 @@ class KobraXBridge: "camera_url": s["camera_url"], "fan_speed": s["fan_speed"], "print_speed_mode": s["print_speed_mode"], - "auto_leveling": getattr(self._args, "auto_leveling", 1), - "camera_on_print": getattr(self._args, "camera_on_print", 0), - "web_upload_warning": getattr(self._args, "web_upload_warning", 1), - "light_on": s["light_on"], + "auto_leveling": getattr(self._args, "auto_leveling", 1), + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "camera_on_print": getattr(self._args, "camera_on_print", 0), + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "light_on": s["light_on"], "light_brightness": s["light_brightness"], "ams_slots": self._ams_slots, "ams_loaded_slot": self._ams_loaded_slot, @@ -4425,10 +4451,11 @@ class KobraXBridge: "mode_id": self._args.mode_id, "device_id": self._args.device_id, "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), - "auto_leveling": getattr(self._args, "auto_leveling", 1), - "camera_on_print": getattr(self._args, "camera_on_print", 0), - "web_upload_warning": getattr(self._args, "web_upload_warning", 1), - "print_start_dialog": getattr(self._args, "print_start_dialog", 1), + "auto_leveling": getattr(self._args, "auto_leveling", 1), + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "camera_on_print": getattr(self._args, "camera_on_print", 0), + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "print_start_dialog": getattr(self._args, "print_start_dialog", 1), "poll_interval": getattr(self._args, "poll_interval", 3), "filament_profiles": {str(k): v for k, v in self._filament_profiles.items()}, "visible_vendors": self._visible_vendors, @@ -4461,8 +4488,9 @@ class KobraXBridge: cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or ""))) cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or ""))) cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto")))) - cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) - cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) + cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) + cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) + cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) if "poll_interval" in data: @@ -5525,7 +5553,6 @@ async def run_bridge(args): site = web.TCPSite(runner, args.host, per_args.port) await site.start() runners.append((runner, client, pid)) - log.info(f"[Printer {pid}] Bridge running on http://{args.host}:{per_args.port}") import socket as _socket try: @@ -5537,8 +5564,8 @@ async def run_bridge(args): # An alle Bridge-Instanzen weitergeben — wird für absolute Webcam-URLs genutzt for _b in all_bridges.values(): _b._local_ip = _local_ip - log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Ports: " + - ", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values())) + ports = ", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values()) + log.info(f"OrcaSlicer → Klipper → http://{_local_ip}:{ports}") log.info("Ctrl-C zum Beenden") try: @@ -5582,8 +5609,9 @@ def main(): parser.add_argument("--mode-id", default=env_loader.MODE_ID) parser.add_argument("--device-id", default=env_loader.DEVICE_ID) parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT) - parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) - parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) + parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) + parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION) + parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING) parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG) parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int) diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 696c1bc..158ed7d 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -433,6 +433,7 @@ function applyLang(){ setText('lbl-default-slot',T.settings_default_slot); setText('opt-slot-auto',T.settings_slot_auto); setText('lbl-auto-leveling',T.settings_auto_leveling); + setText('lbl-vibration-compensation',T.settings_vibration_compensation); setText('lbl-file-ready-mode',T.settings_file_ready_mode); setText('opt-file-ready-dialog',T.settings_file_ready_dialog); setText('opt-file-ready-banner',T.settings_file_ready_banner); @@ -1086,6 +1087,7 @@ function openSettings(){ document.getElementById('s-mode-id').value=d.mode_id||''; document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); + var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation; var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0)); var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); @@ -1851,8 +1853,9 @@ function saveSettings(){ device_id: document.getElementById('s-device-id').value, mode_id: document.getElementById('s-mode-id').value, default_ams_slot: document.getElementById('s-default-slot').value, - auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, - camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, + auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, + vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0, + camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10), web_upload_warning:webUploadWarning, poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)), diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 0b7874b..f9d2140 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -509,6 +509,10 @@
+