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(''); });