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_<id>]` 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) <noreply@anthropic.com>
101 lines
4.1 KiB
Python
101 lines
4.1 KiB
Python
"""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_<id>]`` 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}
|