Compare commits
3 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| 74fc2ddab0 | |||
| 771599be0c | |||
| 0e1d46ee7f |
16
CHANGELOG.md
16
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_<id>]` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<id>]`` 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_<id>]``
|
||||
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_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_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_<id>]``
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -828,14 +833,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 +2606,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 +2636,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)
|
||||
@@ -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:
|
||||
|
||||
67
tests/test_filament_profiles_per_printer.py
Normal file
67
tests/test_filament_profiles_per_printer.py
Normal file
@@ -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_<id>]`` 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+"
|
||||
@@ -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 '<a href="/printer'+num+'" style="display:block;padding:10px 14px;color:'+(active?'var(--accent)':'var(--txt)')+';text-decoration:none;font-size:13px;border-bottom:1px solid var(--border)" '+(active?'style="font-weight:600"':'')+'>'+
|
||||
return '<a href="'+((p.bridge_url||'').replace(/\/+$/,''))+'/printer'+num+'" style="display:block;padding:10px 14px;color:'+(active?'var(--accent)':'var(--txt)')+';text-decoration:none;font-size:13px;border-bottom:1px solid var(--border)" '+(active?'style="font-weight:600"':'')+'>'+
|
||||
(active?'▶ ':'')+p.name+'</a>';
|
||||
}).join('');
|
||||
}
|
||||
@@ -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='<div id="slot-pickr-btn"></div>';
|
||||
_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 '<div title="'+c+'" onclick="slotPickSwatch(\''+c+'\')" style="width:22px;height:22px;border-radius:4px;background:'+c+
|
||||
';border:2px solid rgba(255,255,255,.2);cursor:pointer;flex-shrink:0"></div>';
|
||||
}).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='<option value="">'+phTxt+'</option>'+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 '<option value="'+hex+'">Slot '+(s.global_index+1)+' — '+(s.type||'?')+' '+hex+'</option>';
|
||||
}).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;
|
||||
@@ -3243,7 +3351,7 @@ function loadPrinterTab(){
|
||||
'<div style="font-size:12px;color:var(--txt2);display:flex;gap:12px">'+
|
||||
'<span>🌡 '+nt+'°C</span><span>🛏 '+bt+'°C</span>'+
|
||||
'</div>'+
|
||||
(!isActive?'<a href="/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
|
||||
(!isActive?'<a href="'+url+'/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
|
||||
'</div>';
|
||||
}).join('');
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>KX-Bridge</title>
|
||||
<link rel="stylesheet" href="/kx/ui/style.css">
|
||||
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
|
||||
<script src="/kx/ui/lib/pickr.min.js"></script>
|
||||
<body>
|
||||
|
||||
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
|
||||
@@ -46,15 +48,26 @@
|
||||
<span class="modal-title" id="slot-edit-title"></span>
|
||||
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1">✕</button>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
|
||||
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
|
||||
<input type="color" id="slot-edit-color"
|
||||
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
|
||||
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
|
||||
<div style="display:flex;align-items:flex-start;gap:16px;margin-bottom:12px">
|
||||
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0;margin-top:4px"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-color"></div>
|
||||
<!-- Pickr anchor — JS mounts the picker here -->
|
||||
<div id="slot-pickr-anchor"></div>
|
||||
<!-- hidden input keeps the hex value for saveSlotEdit() -->
|
||||
<input type="hidden" id="slot-edit-color">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recent color swatches (max 16, localStorage) -->
|
||||
<div id="slot-color-swatches" style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px"></div>
|
||||
<!-- Copy from slot -->
|
||||
<div id="slot-copy-row" style="display:none;margin-bottom:16px">
|
||||
<select id="slot-copy-select"
|
||||
style="width:100%;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;box-sizing:border-box"
|
||||
onchange="slotCopyColor(this)">
|
||||
<option value="" id="lbl-slot-copy-from">Copy color from slot…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-bottom:20px">
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
|
||||
@@ -589,7 +602,7 @@
|
||||
<input type="text" id="s-spoolman-url" placeholder="http://spoolman:7912" style="width:200px">
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<label id="lbl-spoolman-sync-rate">Sync-Rate (s, 0=aus)</label>
|
||||
<label id="lbl-spoolman-sync-rate">Sync-Rate (s, 0=Druckende)</label>
|
||||
<input type="number" id="s-spoolman-sync-rate" min="0" max="3600" value="30" style="width:80px">
|
||||
</div>
|
||||
<div id="spoolman-status-row" style="margin-top:6px;font-size:12px;color:var(--txt2)">
|
||||
|
||||
2
web/themes/default/lib/pickr-nano.min.css
vendored
Normal file
2
web/themes/default/lib/pickr-nano.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
web/themes/default/lib/pickr.min.js
vendored
Normal file
3
web/themes/default/lib/pickr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -15,10 +15,46 @@
|
||||
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
/* select/option-Farben explizit setzen — OrcaSlicers Device-Tab-Webview erbt
|
||||
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29). */
|
||||
select{background:var(--raised)!important;color:var(--txt)!important}
|
||||
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29).
|
||||
Einheitliches Styling für alle Dropdowns im gesamten UI. */
|
||||
select{
|
||||
background:var(--raised)!important;
|
||||
color:var(--txt)!important;
|
||||
border:1px solid var(--border)!important;
|
||||
border-radius:8px!important;
|
||||
padding:6px 10px!important;
|
||||
font-size:13px!important;
|
||||
appearance:none!important;
|
||||
-webkit-appearance:none!important;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888' d='M6 8L0 0h12z'/%3E%3C/svg%3E")!important;
|
||||
background-repeat:no-repeat!important;
|
||||
background-position:right 10px center!important;
|
||||
padding-right:28px!important;
|
||||
cursor:pointer!important;
|
||||
outline:none!important;
|
||||
box-sizing:border-box!important;
|
||||
}
|
||||
select:focus{border-color:var(--accent)!important;box-shadow:0 0 0 2px rgba(0,200,255,0.18)!important}
|
||||
select option{background:var(--card)!important;color:var(--txt)!important}
|
||||
|
||||
/* Einheitliches Styling für Text/Number-Inputs */
|
||||
input[type=text],input[type=number],input[type=url],input[type=password],input[type=email],input[type=search]{
|
||||
background:var(--raised);
|
||||
color:var(--txt);
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
padding:6px 10px;
|
||||
font-size:13px;
|
||||
outline:none;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
input[type=text]:focus,input[type=number]:focus,input[type=url]:focus,
|
||||
input[type=password]:focus,input[type=email]:focus,input[type=search]:focus{
|
||||
border-color:var(--accent);
|
||||
box-shadow:0 0 0 2px rgba(0,200,255,0.18);
|
||||
}
|
||||
input::placeholder{color:var(--txt2);opacity:1}
|
||||
|
||||
/* ── HEADER ── */
|
||||
header{background:var(--card);border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
|
||||
@@ -265,6 +301,8 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
|
||||
.modal-field input{background:var(--raised);border:1px solid var(--border);
|
||||
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
|
||||
.modal-field input:focus{outline:none;border-color:var(--accent)}
|
||||
.set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
|
||||
.set-row label{font-size:12px;color:var(--txt2)}
|
||||
.poll-btns{display:flex;gap:8px}
|
||||
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
|
||||
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
"lbl_light": "💡 Licht",
|
||||
"lbl_remaining": "Restzeit:",
|
||||
"lbl_slicer_time": "Slicer-Schätzung:",
|
||||
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=aus)",
|
||||
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
|
||||
"lbl_spoolman_url": "Server-URL",
|
||||
"lbl_unload": "Ausziehen",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -324,5 +324,6 @@
|
||||
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
|
||||
"update_error": "Fehler",
|
||||
"update_none": "Bereits aktuell",
|
||||
"update_restarting": "Starte neu..."
|
||||
"update_restarting": "Starte neu...",
|
||||
"slot_copy_from": "Farbe von Slot kopieren…"
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"lbl_light": "💡 Light",
|
||||
"lbl_remaining": "Remaining:",
|
||||
"lbl_slicer_time": "Slicer estimate:",
|
||||
"lbl_spoolman_sync_rate": "Sync rate (s, 0=off)",
|
||||
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
|
||||
"lbl_spoolman_url": "Server URL",
|
||||
"lbl_unload": "Unload",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -324,5 +324,6 @@
|
||||
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
|
||||
"update_error": "Error",
|
||||
"update_none": "Already up to date",
|
||||
"update_restarting": "Restarting..."
|
||||
"update_restarting": "Restarting...",
|
||||
"slot_copy_from": "Copy color from slot…"
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"lbl_light": "💡 Luz",
|
||||
"lbl_remaining": "Restante:",
|
||||
"lbl_slicer_time": "Estimación del slicer:",
|
||||
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=desact.)",
|
||||
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
|
||||
"lbl_spoolman_url": "URL del servidor",
|
||||
"lbl_unload": "Descargar",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -324,5 +324,6 @@
|
||||
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
|
||||
"update_error": "Error",
|
||||
"update_none": "Ya actualizado",
|
||||
"update_restarting": "Reiniciando..."
|
||||
"update_restarting": "Reiniciando...",
|
||||
"slot_copy_from": "Copiar color del slot…"
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"lbl_light": "💡 Lumière",
|
||||
"lbl_remaining": "Restant :",
|
||||
"lbl_slicer_time": "Estimation slicer :",
|
||||
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=désact.)",
|
||||
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
|
||||
"lbl_spoolman_url": "URL du serveur",
|
||||
"lbl_unload": "Décharger",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -324,5 +324,6 @@
|
||||
"update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d",
|
||||
"update_error": "Erreur",
|
||||
"update_none": "Déjà à jour",
|
||||
"update_restarting": "Redémarrage…"
|
||||
"update_restarting": "Redémarrage…",
|
||||
"slot_copy_from": "Copier la couleur du slot…"
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"lbl_light": "💡 Luce",
|
||||
"lbl_remaining": "Rimanente:",
|
||||
"lbl_slicer_time": "Stima slicer:",
|
||||
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=disatt.)",
|
||||
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
|
||||
"lbl_spoolman_url": "URL server",
|
||||
"lbl_unload": "Rimuovi",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -324,5 +324,6 @@
|
||||
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
|
||||
"update_error": "Errore",
|
||||
"update_none": "Già aggiornato",
|
||||
"update_restarting": "Riavvio in corso..."
|
||||
"update_restarting": "Riavvio in corso...",
|
||||
"slot_copy_from": "Copia colore dallo slot…"
|
||||
}
|
||||
@@ -123,7 +123,7 @@
|
||||
"lbl_light": "💡 灯光",
|
||||
"lbl_remaining": "剩余时间:",
|
||||
"lbl_slicer_time": "切片预估:",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=关闭)",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=打印结束)",
|
||||
"lbl_spoolman_url": "服务器地址",
|
||||
"lbl_unload": "退料",
|
||||
"lbl_zpos": "Z (mm)",
|
||||
@@ -324,5 +324,6 @@
|
||||
"update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d",
|
||||
"update_error": "错误",
|
||||
"update_none": "已是最新版本",
|
||||
"update_restarting": "重启中..."
|
||||
"update_restarting": "重启中...",
|
||||
"slot_copy_from": "从插槽复制颜色…"
|
||||
}
|
||||
Reference in New Issue
Block a user