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}