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:
Walter Almada B
2026-07-01 21:42:40 -07:00
parent a16062f44f
commit 2a13f1f0dd
4 changed files with 211 additions and 30 deletions

View File

@@ -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)