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_<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>
This commit is contained in:
@@ -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_<id>]`` 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_<id>]
|
||||
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_<id>]``
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user