forked from viewit/KX-Bridge-Release
Merge pull request #83: fix(spoolman): repair dead slot-map persistence + isolate it per printer
This commit is contained in:
@ -41,6 +41,21 @@ web_upload_warning = 1
|
|||||||
# Poll-Intervall in Sekunden
|
# Poll-Intervall in Sekunden
|
||||||
poll_interval = 3
|
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) ──────────────────────────────────────────────────
|
# ─── Multi-Printer (optional) ──────────────────────────────────────────────────
|
||||||
# Mehrere Drucker können als [printer_1], [printer_2], … definiert werden.
|
# Mehrere Drucker können als [printer_1], [printer_2], … definiert werden.
|
||||||
# Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port).
|
# Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port).
|
||||||
|
|||||||
@ -349,6 +349,82 @@ def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -
|
|||||||
return True
|
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:
|
def get(key: str, default: str = "") -> str:
|
||||||
return os.environ.get(key, default)
|
return os.environ.get(key, default)
|
||||||
|
|
||||||
|
|||||||
@ -911,23 +911,16 @@ class KobraXBridge:
|
|||||||
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
|
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
|
||||||
if _sm_url else None
|
if _sm_url else None
|
||||||
)
|
)
|
||||||
# Persistierte Spool-Zuordnung aus config.ini laden
|
# Persistierte Spool-Zuordnung (AMS-Slot → Spoolman-Spool) je Drucker laden.
|
||||||
_slot_spools_init: dict[int, int] = {}
|
# 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:
|
try:
|
||||||
import configparser as _cp2
|
import config_loader as _cl
|
||||||
_cfg_path2 = config_loader._find_config_file()
|
self._spoolman_slot_spools: dict[int, int] = _cl.list_spool_map(self._printer_id)
|
||||||
if _cfg_path2:
|
except Exception as _e:
|
||||||
_cfg2 = _cp2.ConfigParser()
|
log.warning("Spoolman: Slot-Map laden fehlgeschlagen: %s", _e)
|
||||||
_cfg2.read(_cfg_path2, encoding="utf-8")
|
self._spoolman_slot_spools = {} # {ams_slot_idx: spoolman_spool_id}
|
||||||
_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}
|
|
||||||
self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print
|
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_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
|
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()
|
int(k): int(v) for k, v in slot_map.items()
|
||||||
if str(v).isdigit() and int(v) > 0
|
if str(v).isdigit() and int(v) > 0
|
||||||
}
|
}
|
||||||
# Persistieren in config.ini damit die Zuordnung Bridge-Neustart überlebt
|
# Persistieren je Drucker (eigene [spoolman_<id>]-Sektion), damit die
|
||||||
|
# Zuordnung Bridge-Neustart überlebt und zwei AMS sich nicht überschreiben.
|
||||||
|
# (Vorher: NameError auf `config_loader` → nichts wurde je gespeichert.)
|
||||||
try:
|
try:
|
||||||
import configparser as _cp
|
import config_loader as _cl
|
||||||
_cfg_path = config_loader._find_config_file()
|
_cl.save_spool_map(self._spoolman_slot_spools, self._printer_id)
|
||||||
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)
|
|
||||||
except Exception as _e:
|
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_usage = {}
|
||||||
self._spoolman_slot_reported = {}
|
self._spoolman_slot_reported = {}
|
||||||
self._spoolman_last_usage = 0.0
|
self._spoolman_last_usage = 0.0
|
||||||
@ -2096,6 +2082,7 @@ class KobraXBridge:
|
|||||||
num_gates = len(slots)
|
num_gates = len(slots)
|
||||||
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
|
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
|
||||||
gate_filament_name = []
|
gate_filament_name = []
|
||||||
|
gate_spool_id = []
|
||||||
for _global_index, slot in slots:
|
for _global_index, slot in slots:
|
||||||
occupied = slot.get("status") == 5
|
occupied = slot.get("status") == 5
|
||||||
gate_status.append(1 if occupied else 0)
|
gate_status.append(1 if occupied else 0)
|
||||||
@ -2118,6 +2105,9 @@ class KobraXBridge:
|
|||||||
gate_filament_name.append(fila_name)
|
gate_filament_name.append(fila_name)
|
||||||
else:
|
else:
|
||||||
gate_filament_name.append("")
|
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)}
|
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)
|
active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1)
|
||||||
@ -2130,7 +2120,7 @@ class KobraXBridge:
|
|||||||
"gate_temperature": gate_temperature,
|
"gate_temperature": gate_temperature,
|
||||||
"gate_color_rgb": gate_color_rgb,
|
"gate_color_rgb": gate_color_rgb,
|
||||||
"gate_filament_name": gate_filament_name,
|
"gate_filament_name": gate_filament_name,
|
||||||
"gate_spool_id": [-1] * num_gates,
|
"gate_spool_id": gate_spool_id,
|
||||||
"ttg_map": list(range(num_gates)),
|
"ttg_map": list(range(num_gates)),
|
||||||
"tool": active_gate,
|
"tool": active_gate,
|
||||||
"gate": active_gate,
|
"gate": active_gate,
|
||||||
|
|||||||
100
tests/test_spoolman_slot_map.py
Normal file
100
tests/test_spoolman_slot_map.py
Normal file
@ -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_<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}
|
||||||
@ -100,7 +100,7 @@ function _buildSpoolmanSection(){
|
|||||||
var currentSpool=_slotSpoolMap[String(idx)]||'';
|
var currentSpool=_slotSpoolMap[String(idx)]||'';
|
||||||
var opts='<option value="">–</option>'+_spoolmanSpools.map(function(sp){
|
var opts='<option value="">–</option>'+_spoolmanSpools.map(function(sp){
|
||||||
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
|
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
|
||||||
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor+' ':'';
|
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
|
||||||
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
|
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
|
||||||
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
|
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
|
||||||
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
|
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
|
||||||
|
|||||||
Reference in New Issue
Block a user