863 lines
38 KiB
Python
863 lines
38 KiB
Python
"""
|
|
bridge_ams.py - AmsFilamentMixin for KobraXBridge.
|
|
|
|
AMS/ACE slot topology + aggregation, per-slot filament-profile resolution
|
|
(material normalization, RFID vendor matching, effective slot profile),
|
|
ACE dryer presets, the lane-data builder for OrcaSlicer sync, and the
|
|
_on_multicolor_box / _on_light MQTT callbacks (which live here because they
|
|
are all about AMS state). Mixed into KobraXBridge.
|
|
|
|
────────────────────────────────────────────────────────────────────────────
|
|
Copyright (C) 2026 viewit (KX-Bridge contributors)
|
|
|
|
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
import logging
|
|
import threading
|
|
|
|
log = logging.getLogger("bridge")
|
|
|
|
|
|
class AmsFilamentMixin:
|
|
def _default_ace_dry_presets(self) -> dict[str, dict]:
|
|
return {
|
|
"pla": {"temp": 45, "duration_sec": 4 * 3600},
|
|
"pla_plus": {"temp": 45, "duration_sec": 4 * 3600},
|
|
"petg": {"temp": 50, "duration_sec": 4 * 3600},
|
|
"tpu": {"temp": 55, "duration_sec": 4 * 3600},
|
|
"abs_asa": {"temp": 45, "duration_sec": 8 * 3600},
|
|
"pa_pc": {"temp": 55, "duration_sec": 12 * 3600},
|
|
"custom_1": {"name": "Custom 1", "temp": 45, "duration_sec": 4 * 3600},
|
|
"custom_2": {"name": "Custom 2", "temp": 45, "duration_sec": 4 * 3600},
|
|
"custom_3": {"name": "Custom 3", "temp": 45, "duration_sec": 4 * 3600},
|
|
}
|
|
|
|
def _sanitize_ace_dry_presets(self, presets: dict) -> dict[str, dict]:
|
|
out = self._default_ace_dry_presets()
|
|
for key in list(out.keys()):
|
|
src = presets.get(key) if isinstance(presets, dict) else None
|
|
if not isinstance(src, dict):
|
|
continue
|
|
try:
|
|
t = int(src.get("temp", out[key]["temp"]))
|
|
except Exception:
|
|
t = out[key]["temp"]
|
|
try:
|
|
d = int(src.get("duration_sec", out[key]["duration_sec"]))
|
|
except Exception:
|
|
d = out[key]["duration_sec"]
|
|
out[key]["temp"] = max(30, min(80, t))
|
|
out[key]["duration_sec"] = max(10 * 60, min(24 * 3600, d))
|
|
if key.startswith("custom_"):
|
|
name = str(src.get("name", out[key].get("name", key.replace("_", " ").title()))).strip()
|
|
out[key]["name"] = name or out[key].get("name", "Custom")
|
|
return out
|
|
|
|
def _load_ace_dry_presets_config(self) -> dict[str, dict]:
|
|
import configparser
|
|
defaults = self._default_ace_dry_presets()
|
|
cfg_path = self._find_config_path()
|
|
if not cfg_path.is_file():
|
|
return defaults
|
|
cfg = configparser.ConfigParser(interpolation=None)
|
|
cfg.read(cfg_path, encoding="utf-8")
|
|
sec = "ace_dry_presets"
|
|
if not cfg.has_section(sec):
|
|
return defaults
|
|
out = {}
|
|
for key, d in defaults.items():
|
|
temp_k = f"{key}_temp"
|
|
dur_k = f"{key}_duration_sec"
|
|
try:
|
|
temp = int(cfg.get(sec, temp_k, fallback=str(d["temp"])))
|
|
except Exception:
|
|
temp = d["temp"]
|
|
try:
|
|
dur = int(cfg.get(sec, dur_k, fallback=str(d["duration_sec"])))
|
|
except Exception:
|
|
dur = d["duration_sec"]
|
|
out[key] = {
|
|
"temp": max(30, min(80, temp)),
|
|
"duration_sec": max(10 * 60, min(24 * 3600, dur)),
|
|
}
|
|
if key.startswith("custom_"):
|
|
name_k = f"{key}_name"
|
|
name = cfg.get(sec, name_k, fallback=str(d.get("name", key.replace("_", " ").title()))).strip()
|
|
out[key]["name"] = name or str(d.get("name", "Custom"))
|
|
return out
|
|
|
|
@staticmethod
|
|
def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str:
|
|
"""Detect active filament topology mode.
|
|
|
|
Modes:
|
|
- toolhead: only toolhead slots
|
|
- ace_direct: ACE channels directly mapped, no toolhead box present.
|
|
Covers one unit (Kobra X) as well as multiple daisy-chained units
|
|
(Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a
|
|
block of 4 global slots at box_id * 4.
|
|
- ace_hub: toolhead + ACE via hub (slot 4 as hub path)
|
|
"""
|
|
toolhead = any(b.get("id") == -1 for b in boxes)
|
|
ace = any(b.get("id", -1) >= 0 for b in boxes)
|
|
if ace and toolhead:
|
|
return "ace_hub"
|
|
if ace:
|
|
return "ace_direct"
|
|
return "toolhead"
|
|
|
|
@staticmethod
|
|
def _aggregate_slots(boxes: list, mode: str = "toolhead") -> tuple:
|
|
"""Aggregate multi_color_box list into a flat global slot list."""
|
|
toolhead = next((b for b in boxes if b.get("id") == -1), None)
|
|
ace_boxes = sorted(
|
|
[b for b in boxes if b.get("id", -1) >= 0],
|
|
key=lambda b: b["id"]
|
|
)
|
|
|
|
global_slots: list = []
|
|
global_loaded: int = -1
|
|
|
|
if mode == "toolhead":
|
|
if toolhead:
|
|
for local_idx, s in enumerate(toolhead.get("slots") or []):
|
|
s = dict(s)
|
|
s["global_index"] = local_idx
|
|
s["box_id"] = -1
|
|
global_slots.append(s)
|
|
loaded = toolhead.get("loaded_slot", -1)
|
|
if loaded >= 0:
|
|
global_loaded = loaded
|
|
return global_slots, global_loaded
|
|
|
|
if mode == "ace_direct":
|
|
# One or more ACE units, no toolhead buffer (Kobra X: 1 unit,
|
|
# Kobra S1: up to 2+ units, Issue #95). Global index =
|
|
# box_id * 4 + local slot, so the numbering matches
|
|
# _global_to_box_slot's //4-%4 fallback and stays stable
|
|
# regardless of report order.
|
|
for ace in ace_boxes:
|
|
ace_id = int(ace["id"])
|
|
base = ace_id * 4
|
|
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
|
|
s = dict(s)
|
|
s["global_index"] = base + local_idx
|
|
s["box_id"] = ace_id
|
|
global_slots.append(s)
|
|
ace_loaded = ace.get("loaded_slot", -1)
|
|
if 0 <= ace_loaded < 4:
|
|
global_loaded = base + ace_loaded
|
|
return global_slots, global_loaded
|
|
|
|
# ace_hub
|
|
if toolhead:
|
|
for local_idx, s in enumerate((toolhead.get("slots") or [])[:3]):
|
|
s = dict(s)
|
|
s["global_index"] = local_idx
|
|
s["box_id"] = -1
|
|
global_slots.append(s)
|
|
th_loaded = toolhead.get("loaded_slot", -1)
|
|
if 0 <= th_loaded <= 2:
|
|
global_loaded = th_loaded
|
|
|
|
for ace in ace_boxes:
|
|
ace_id = ace["id"]
|
|
base = 3 + ace_id * 4
|
|
for local_idx, s in enumerate(ace.get("slots") or []):
|
|
s = dict(s)
|
|
s["global_index"] = base + local_idx
|
|
s["box_id"] = ace_id
|
|
global_slots.append(s)
|
|
ace_loaded = ace.get("loaded_slot", -1)
|
|
if ace_loaded >= 0:
|
|
global_loaded = base + ace_loaded
|
|
|
|
return global_slots, global_loaded
|
|
|
|
def _global_to_box_slot(self, global_index: int) -> tuple:
|
|
"""Convert a global slot index to (box_id, local_slot_index)."""
|
|
for s in self._ams_slots:
|
|
if s.get("global_index") == global_index:
|
|
return s.get("box_id", -1), s.get("index", global_index)
|
|
|
|
ace_present = any(s.get("box_id", -1) >= 0 for s in self._ams_slots)
|
|
if self._filament_mode == "ace_direct" and ace_present:
|
|
return global_index // 4, global_index % 4
|
|
if not ace_present or global_index < 3:
|
|
return -1, global_index
|
|
offset = global_index - 3
|
|
return offset // 4, offset % 4
|
|
|
|
def _slot_to_print_ams_index(self, global_index: int) -> int:
|
|
"""Convert UI/global slot index to printer print/start ams_index.
|
|
|
|
In ace_hub mode, print/start uses global channel numbering where
|
|
toolhead channels occupy 1..3 and ACE0 starts at index 4.
|
|
"""
|
|
idx = int(global_index)
|
|
if self._filament_mode == "ace_hub":
|
|
box_id, local_slot = self._global_to_box_slot(idx)
|
|
if box_id >= 0:
|
|
return 4 + box_id * 4 + int(local_slot)
|
|
return idx
|
|
return idx
|
|
|
|
def _slot_usable_for_print(self, global_index: int) -> bool:
|
|
"""Whether a global slot can be used for current filament mode."""
|
|
slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == int(global_index)), None)
|
|
if not slot:
|
|
return False
|
|
if int(slot.get("status", 0)) != 5:
|
|
return False
|
|
|
|
box_id = int(slot.get("box_id", -1))
|
|
if self._filament_mode == "ace_hub":
|
|
# In hub mode, toolhead channels (0..2) and ACE channels are both printable.
|
|
return box_id == -1 or box_id >= 0
|
|
if self._filament_mode == "ace_direct":
|
|
return box_id >= 0
|
|
return box_id == -1
|
|
|
|
def _loaded_slots_for_print(self) -> list[tuple[int, dict]]:
|
|
"""Loaded slots filtered for current filament mode."""
|
|
loaded = [
|
|
(int(s.get("global_index", i)), s)
|
|
for i, s in enumerate(self._ams_slots)
|
|
if s.get("status") == 5 and self._slot_usable_for_print(int(s.get("global_index", i)))
|
|
]
|
|
return loaded
|
|
|
|
def _select_loaded_slots_for_print(self, warn_on_empty_default: bool = False) -> list[tuple[int, dict]]:
|
|
"""Return loaded slots, honoring default_ams_slot when configured."""
|
|
default_slot = getattr(self._args, "default_ams_slot", "auto")
|
|
all_loaded = self._loaded_slots_for_print()
|
|
if default_slot == "auto":
|
|
return all_loaded
|
|
|
|
try:
|
|
slot_idx = int(default_slot)
|
|
except ValueError:
|
|
return all_loaded
|
|
|
|
selected = [(i, s) for i, s in all_loaded if i == slot_idx]
|
|
if selected:
|
|
return selected
|
|
|
|
if warn_on_empty_default:
|
|
log.warning(f"Default slot {slot_idx} is empty - falling back to auto")
|
|
return all_loaded
|
|
|
|
@staticmethod
|
|
def _slot_color_rgba(slot: dict) -> list[int]:
|
|
color = slot.get("color", [255, 255, 255])
|
|
if isinstance(color, list) and len(color) >= 3:
|
|
return [int(color[0]), int(color[1]), int(color[2]), 255]
|
|
return [255, 255, 255, 255]
|
|
|
|
def _build_auto_ams_box_mapping(
|
|
self,
|
|
warn_on_empty_default: bool = False,
|
|
loaded_slots: list[tuple[int, dict]] | None = None,
|
|
) -> list[dict]:
|
|
"""Build print mapping from currently loaded slots (no explicit dialog assignments)."""
|
|
loaded = loaded_slots
|
|
if loaded is None:
|
|
loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default)
|
|
if not loaded:
|
|
return []
|
|
loaded_map = {gidx: s for gidx, s in loaded}
|
|
max_idx = max(loaded_map.keys())
|
|
# The printer interprets ams_box_mapping as an ordered list (entry N = TN).
|
|
# Missing slots must be inserted as placeholders, otherwise everything shifts.
|
|
# A placeholder must NOT reference a physically empty tray: the printer
|
|
# rejects such an entry even for a tool the GCode never calls (printing
|
|
# Filament 4 with the slot below it empty fails; all-full works). Point
|
|
# gap placeholders at a definitely-loaded tray instead of the gap's own
|
|
# (empty) index.
|
|
fallback_gidx = max_idx # highest loaded slot -> loaded + printable
|
|
fallback_slot = loaded_map[fallback_gidx]
|
|
fallback_ams = self._slot_to_print_ams_index(fallback_gidx)
|
|
result = []
|
|
for i in range(max_idx + 1):
|
|
if i in loaded_map:
|
|
s = loaded_map[i]
|
|
result.append({
|
|
"paint_index": i,
|
|
"ams_index": self._slot_to_print_ams_index(i),
|
|
"paint_color": [255, 255, 255, 255],
|
|
"ams_color": self._slot_color_rgba(s),
|
|
"material_type": s.get("type", "PLA"),
|
|
})
|
|
else:
|
|
result.append({
|
|
"paint_index": i,
|
|
"ams_index": fallback_ams,
|
|
"paint_color": [255, 255, 255, 255],
|
|
"ams_color": self._slot_color_rgba(fallback_slot),
|
|
"material_type": fallback_slot.get("type", "PLA"),
|
|
})
|
|
return result
|
|
|
|
def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]:
|
|
"""Build print mapping from UI filament assignments.
|
|
|
|
Returns (mapping, unused_count, invalid_count).
|
|
"""
|
|
slot_by_global_index = {
|
|
int(s.get("global_index", i)): s
|
|
for i, s in enumerate(self._ams_slots)
|
|
}
|
|
ams_box_mapping: list[dict] = []
|
|
unused_count = 0
|
|
invalid_count = 0
|
|
|
|
for i, a in enumerate(assignments):
|
|
try:
|
|
if a.get("is_used") is False:
|
|
unused_count += 1
|
|
continue
|
|
global_slot = int(a["slot_index"])
|
|
except (ValueError, TypeError, KeyError):
|
|
invalid_count += 1
|
|
continue
|
|
|
|
if global_slot < 0:
|
|
unused_count += 1
|
|
continue
|
|
if not self._slot_usable_for_print(global_slot):
|
|
invalid_count += 1
|
|
continue
|
|
|
|
slot = slot_by_global_index.get(global_slot, {})
|
|
ams_box_mapping.append({
|
|
# Preserve slicer paint indices (can be sparse when paint 0 is unused).
|
|
"paint_index": a.get("paint_index", i),
|
|
"ams_index": self._slot_to_print_ams_index(global_slot),
|
|
"paint_color": a.get("paint_color", [255, 255, 255, 255]),
|
|
"ams_color": self._slot_color_rgba(slot),
|
|
"material_type": slot.get("type", a.get("material", "PLA")),
|
|
})
|
|
|
|
return ams_box_mapping, unused_count, invalid_count
|
|
|
|
def _box_local_to_global(self, box_id: int, local_slot: int, boxes: list) -> int:
|
|
"""Convert (box_id, local slot) to global slot index for current topology."""
|
|
if box_id == -1:
|
|
return local_slot
|
|
if self._filament_mode == "ace_direct":
|
|
# Multi-ACE (Issue #95): each unit occupies its own block of 4.
|
|
# Identical to the old `return local_slot` for a single unit (id 0).
|
|
return box_id * 4 + local_slot
|
|
return 3 + box_id * 4 + local_slot
|
|
|
|
def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
|
|
"""Build {global_slot_index: loading|unloading} from feed_status data."""
|
|
# Note: all boxes are considered — the old primary_ace_id filter (skip
|
|
# every ACE box except the first in ace_direct mode) is gone since the
|
|
# slot aggregation now handles multiple ACE units (Issue #95).
|
|
activity: dict = {}
|
|
for box in boxes:
|
|
fs = box.get("feed_status") or {}
|
|
current_status = int(fs.get("current_status", -1))
|
|
local_slot = int(fs.get("slot_index", -1))
|
|
feed_type = int(fs.get("type", -1))
|
|
if current_status in (-1, 10, 11) or local_slot < 0:
|
|
continue
|
|
box_slots = box.get("slots") or []
|
|
if local_slot >= len(box_slots) or (box_slots[local_slot] or {}).get("status") != 5:
|
|
continue
|
|
if feed_type == 1:
|
|
act = "loading"
|
|
elif feed_type == 2:
|
|
act = "unloading"
|
|
else:
|
|
continue
|
|
global_slot = self._box_local_to_global(int(box.get("id", -1)), local_slot, boxes)
|
|
if feed_type == 1 and self._pending_load_slot >= 0 and global_slot != self._pending_load_slot:
|
|
# Ignore transient firmware-reported loading slots that differ from the requested target.
|
|
if global_loaded >= 0 and global_loaded != self._pending_load_slot:
|
|
activity[global_loaded] = "unloading"
|
|
continue
|
|
if feed_type == 1 and global_loaded >= 0 and global_slot != global_loaded:
|
|
# During a slot swap the firmware reports the target slot immediately,
|
|
# while the previously loaded slot is still being unloaded first.
|
|
activity[global_loaded] = "unloading"
|
|
activity[global_slot] = act
|
|
return activity
|
|
|
|
def _on_multicolor_box(self, payload: dict):
|
|
if payload.get("state") == "failed":
|
|
req = getattr(self, "_last_ams_set_request", None)
|
|
log.warning(
|
|
f"multiColorBox setInfo rejected by printer: request={req} raw_response={payload.get('data')}"
|
|
)
|
|
self._state["last_ams_set_error"] = True
|
|
return
|
|
data = payload.get("data") or {}
|
|
if not isinstance(data, dict):
|
|
log.warning(f"multiColorBox/report: unexpected data shape: {data!r}")
|
|
return
|
|
boxes = data.get("multi_color_box") or []
|
|
if not boxes:
|
|
return
|
|
self._state["last_ams_set_error"] = False
|
|
self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model))
|
|
self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model)
|
|
self._state["filament_mode"] = self._filament_mode
|
|
|
|
global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode)
|
|
self._ams_loaded_slot = global_loaded
|
|
self._update_ace_drying_state(data, boxes)
|
|
for box in boxes:
|
|
bid = int(box.get("id", -1))
|
|
if 0 <= bid <= 3 and "auto_feed" in box:
|
|
self._ace_auto_feed[bid] = int(box["auto_feed"])
|
|
if self._pending_load_slot >= 0 and global_loaded == self._pending_load_slot:
|
|
self._pending_load_slot = -1
|
|
activity_map = self._slot_activity_map(boxes, global_loaded)
|
|
for s in global_slots:
|
|
s["activity"] = activity_map.get(s.get("global_index"), "")
|
|
|
|
# Tip forming: after feed-in (status=10) or feed-out (status=11)
|
|
# the original slicer automatically sends type=3 (extruder retract).
|
|
# Check ALL boxes so ACE-triggered events are handled correctly.
|
|
for box in boxes:
|
|
fs = box.get("feed_status") or {}
|
|
current_status = fs.get("current_status")
|
|
slot_index = fs.get("slot_index", 0)
|
|
box_id = box.get("id", -1)
|
|
if current_status in (10, 11):
|
|
def _tip_form(bi=box_id, si=slot_index, cs=current_status):
|
|
import time; time.sleep(2)
|
|
self.client.publish(
|
|
"multiColorBox", "feedFilament",
|
|
{"multi_color_box": [{"id": bi, "feed_status": {"slot_index": si, "type": 3}}]},
|
|
timeout=0
|
|
)
|
|
log.info(f"Tip forming (type=3) after status={cs} box={bi} slot={si}")
|
|
threading.Thread(target=_tip_form, daemon=True).start()
|
|
|
|
if global_slots:
|
|
self._ams_slots = global_slots
|
|
log.info(f"AMS slots received: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}")
|
|
self._push_status_update()
|
|
|
|
def _update_ace_drying_state(self, data: dict, boxes: list):
|
|
"""Extract ACE drying state from multiColorBox report/getInfo payloads."""
|
|
ace_ids = sorted({int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0})
|
|
self._ace_box_ids = [i for i in ace_ids if 0 <= i <= 3]
|
|
|
|
def _num_from(src: dict, keys: tuple[str, ...], default=None):
|
|
for k in keys:
|
|
v = src.get(k)
|
|
if v is not None:
|
|
try:
|
|
return float(v)
|
|
except Exception:
|
|
return default
|
|
return default
|
|
|
|
def _humidity_from(src: dict, default=None):
|
|
return _num_from(src, ("humidity", "current_humidity", "cur_humidity", "relative_humidity", "humidity_value"), default)
|
|
|
|
def _current_temp_from(src: dict, default=None):
|
|
return _num_from(src, ("current_temp", "cur_temp", "temperature", "temp", "drying_temp", "chamber_temp"), default)
|
|
|
|
def _minutes_from(src: dict, key: str, default=0):
|
|
raw = src.get(key, default)
|
|
try:
|
|
value = int(float(raw))
|
|
except Exception:
|
|
return int(default)
|
|
# Some firmware payloads report dryer times in seconds while the UI uses minutes.
|
|
if value > (24 * 60):
|
|
return max(0, int(round(value / 60.0)))
|
|
return max(0, value)
|
|
|
|
per_unit: list[dict] = []
|
|
for box in boxes:
|
|
bid = int(box.get("id", -1))
|
|
if bid < 0:
|
|
continue
|
|
|
|
bs = box.get("drying_status") or box.get("drying_settings")
|
|
bs = bs if isinstance(bs, dict) else {}
|
|
hu = _humidity_from(bs, _humidity_from(box))
|
|
ct = _current_temp_from(bs, _current_temp_from(box))
|
|
|
|
if bs or hu is not None or ct is not None:
|
|
per_unit.append({
|
|
"id": bid,
|
|
"status": int(bs.get("status", 0)),
|
|
"target_temp": int(bs.get("target_temp", 0)),
|
|
"duration": _minutes_from(bs, "duration", 0),
|
|
"remain_time": _minutes_from(bs, "remain_time", 0),
|
|
"humidity": hu,
|
|
"current_temp": ct,
|
|
})
|
|
|
|
src = data.get("drying_status") or data.get("drying_settings")
|
|
if not isinstance(src, dict):
|
|
for box in boxes:
|
|
if int(box.get("id", -1)) < 0:
|
|
continue
|
|
cand = box.get("drying_status") or box.get("drying_settings")
|
|
if isinstance(cand, dict):
|
|
src = cand
|
|
break
|
|
|
|
if isinstance(src, dict):
|
|
cur = self._state.get("ace_drying") or {}
|
|
active = [u for u in per_unit if u.get("status", 0)]
|
|
primary = active[0] if active else (per_unit[0] if per_unit else {})
|
|
self._state["ace_drying"] = {
|
|
"status": int(src.get("status", cur.get("status", 0))),
|
|
"target_temp": int(src.get("target_temp", cur.get("target_temp", 0))),
|
|
"duration": _minutes_from(src, "duration", cur.get("duration", 0)),
|
|
"remain_time": _minutes_from(src, "remain_time", cur.get("remain_time", 0)),
|
|
"humidity": _humidity_from(src, primary.get("humidity", cur.get("humidity"))),
|
|
"current_temp": _current_temp_from(src, primary.get("current_temp", cur.get("current_temp"))),
|
|
"units": per_unit,
|
|
}
|
|
elif per_unit:
|
|
active = [u for u in per_unit if u.get("status", 0)]
|
|
primary = active[0] if active else per_unit[0]
|
|
self._state["ace_drying"] = {
|
|
"status": int(primary.get("status", 0)),
|
|
"target_temp": int(primary.get("target_temp", 0)),
|
|
"duration": int(primary.get("duration", 0)),
|
|
"remain_time": int(primary.get("remain_time", 0)),
|
|
"humidity": primary.get("humidity"),
|
|
"current_temp": primary.get("current_temp"),
|
|
"units": per_unit,
|
|
}
|
|
|
|
def _on_light(self, payload: dict):
|
|
d = payload.get("data") or {}
|
|
self._state["light_on"] = bool(d.get("status", 0))
|
|
self._state["light_brightness"] = int(d.get("brightness", 80))
|
|
self._push_status_update()
|
|
|
|
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
|
|
# Default mapping per material type when the user has not set a slot
|
|
# profile override. For the Kobra X we prefer Anycubic's own
|
|
# filament IDs from the `@Anycubic Kobra X 0.4 nozzle` profiles - those
|
|
# are printer-specific is_compatible and are picked up by OrcaSlicer directly
|
|
# matched. Library fallbacks (OGF*) only for material types without
|
|
# Kobra X-specific Anycubic profile - their @system profiles have
|
|
# `compatible_printers: []` (= compatible with all printers).
|
|
_TRAY_INFO_IDX = {
|
|
# Anycubic-eigene Kobra-X-Profile
|
|
"PLA": "GFPLA",
|
|
"PLA+": "GFPLA+",
|
|
"PLA SILK": "GFPLA Silk",
|
|
"PLA-SILK": "GFPLA Silk",
|
|
"PLASILK": "GFPLA Silk",
|
|
"SILK PLA": "GFPLA Silk",
|
|
"PLA MATTE": "GFPLA",
|
|
"PLA-MATTE": "GFPLA",
|
|
"PLA MARBLE": "GFPLA",
|
|
"PLA WOOD": "GFPLA",
|
|
"PETG": "GFPETG",
|
|
"PETG+": "GFPETG",
|
|
"ABS": "GFABS",
|
|
"ASA": "GFASA",
|
|
"TPU": "GFTPU 95A",
|
|
"TPE": "GFTPU 95A",
|
|
"PVA": "GFPVA",
|
|
# Kein Anycubic-Kobra-X-Profil → Library-Fallback
|
|
"PLA-CF": "OGFL98",
|
|
"PLA CF": "OGFL98",
|
|
"PETG-CF": "OGFG98",
|
|
"PETG CF": "OGFG98",
|
|
"PA": "OGFN99",
|
|
"PA-CF": "OGFN98",
|
|
"PA CF": "OGFN98",
|
|
"PC": "OGFC99",
|
|
"HIPS": "OGFS98",
|
|
}
|
|
|
|
# Normalizes material type strings to the canonical key for _TRAY_INFO_IDX
|
|
# and _default_filament_name. PLA variants without an exact match fall
|
|
# back to their base family (PLA+ -> PLA+, PLA Matte -> PLA, etc.).
|
|
@staticmethod
|
|
def _normalize_material(mat: str) -> str:
|
|
m = mat.upper().strip().replace("-", " ").replace("_", " ")
|
|
# Bekannte Varianten normalisieren
|
|
_ALIASES = {
|
|
"PLAPLUS": "PLA+", "PLA PLUS": "PLA+",
|
|
"SILK PLA": "PLA SILK", "PLASILK": "PLA SILK",
|
|
"PLA MATTE": "PLA MATTE", "PLA MARBLE": "PLA MARBLE",
|
|
"PLA WOOD": "PLA WOOD",
|
|
"TPE": "TPU",
|
|
"PETG PLUS": "PETG+",
|
|
"PA6": "PA", "PA12": "PA", "PA66": "PA",
|
|
}
|
|
if m in _ALIASES:
|
|
return _ALIASES[m]
|
|
return m
|
|
|
|
@staticmethod
|
|
def _material_family(mat: str) -> str:
|
|
"""Reduce a material to its base polymer family.
|
|
|
|
PLA / PLA+ / PLA SILK / PLA MATTE -> "PLA"; PETG / PETG+ -> "PETG"; etc.
|
|
Used by the stale-profile guard: only a change of *family* (e.g. PETG ->
|
|
PLA) invalidates a saved slot profile — a change within the family
|
|
(PLA -> PLA SILK) must not discard an otherwise valid profile.
|
|
"""
|
|
if not mat:
|
|
return ""
|
|
m = AmsFilamentMixin._normalize_material(mat)
|
|
# Longer prefixes first so "PETG" is not swallowed by "PET".
|
|
for fam in ("PETG", "PLA", "ABS", "ASA", "TPU", "PVA", "HIPS", "PA", "PC", "PET"):
|
|
if m.startswith(fam):
|
|
return fam
|
|
return m
|
|
|
|
def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]:
|
|
"""Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
|
|
"GEEETECH PLA Bas", written via third-party RFID tools) into
|
|
(vendor, material_family).
|
|
|
|
Anycubic's ACE RFID system concatenates vendor + material + a
|
|
truncated serial/variant into one `type` string for custom tags -
|
|
unlike a normal spool report where `type` is just "PLA"/"PETG"/etc.
|
|
Returns ("", "") when the first token isn't a known vendor (from the
|
|
merged system+user filament library), which leaves plain type
|
|
strings like "PLA" completely unaffected (Issue #101).
|
|
"""
|
|
tokens = raw_type.split()
|
|
if len(tokens) < 2:
|
|
return "", ""
|
|
first = tokens[0].strip().lower()
|
|
vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()}
|
|
vendor = vendors.get(first)
|
|
if not vendor:
|
|
return "", ""
|
|
family = self._material_family(" ".join(tokens[1:]))
|
|
if not family:
|
|
return "", ""
|
|
return vendor, family
|
|
|
|
@staticmethod
|
|
def _rfid_variant_tokens(raw_type: str) -> list[str]:
|
|
"""Tokens after "VENDOR TYPE" in a combined ACE-RFID string (e.g.
|
|
["bas"] for "GEEETECH PLA Bas") - the truncated variant/serial that
|
|
distinguishes multiple profiles of the same (vendor, material family),
|
|
e.g. "Basic" vs. "Matte". Kept separate from _parse_combined_rfid_type()
|
|
so that function's 2-tuple signature (and its existing callers/tests)
|
|
stay unchanged (Issue #101)."""
|
|
tokens = raw_type.split()
|
|
return [t.lower() for t in tokens[2:]]
|
|
|
|
def _match_profile_by_vendor_family(self, vendor: str, family: str,
|
|
variant_tokens: list[str] | None = None) -> dict:
|
|
"""Find an imported/system filament profile by (vendor, material
|
|
family) - used to auto-resolve a combined ACE-RFID type string to
|
|
the user's already-imported OrcaSlicer profile (Issue #101), since
|
|
the exact profile `name` never appears verbatim in the truncated
|
|
RFID string.
|
|
|
|
When multiple profiles share the same (vendor, family) - e.g. "Geeetech
|
|
PLA Basic" and "Geeetech PLA Matte" both matching (Geeetech, PLA) -
|
|
variant_tokens (the RFID string's remaining tokens, e.g. ["bas"] for
|
|
"Basic") are scored against each candidate's name: a word-prefix match
|
|
scores higher than a plain substring match, so "bas" prefers "Basic"
|
|
over "Matte" or an unrelated profile name containing "bas" as noise.
|
|
Falls back to the first match when nothing disambiguates."""
|
|
matches = [
|
|
p for p in self._load_orca_filaments()
|
|
if p.get("vendor", "").lower() == vendor.lower()
|
|
and self._material_family(p.get("type", "")) == family
|
|
]
|
|
if not matches:
|
|
return {}
|
|
if len(matches) == 1 or not variant_tokens:
|
|
return matches[0]
|
|
|
|
best = matches[0]
|
|
best_score = -1
|
|
for p in matches:
|
|
name_words = p.get("name", "").lower().split()
|
|
score = 0
|
|
for tok in variant_tokens:
|
|
if any(w.startswith(tok) for w in name_words):
|
|
score += 2
|
|
elif tok in p.get("name", "").lower():
|
|
score += 1
|
|
if score > best_score:
|
|
best_score = score
|
|
best = p
|
|
log.debug(
|
|
f"_match_profile_by_vendor_family: {len(matches)} profiles match "
|
|
f"vendor={vendor!r} family={family!r}, variant_tokens={variant_tokens!r} "
|
|
f"-> {best.get('name')!r} (score={best_score})"
|
|
)
|
|
return best
|
|
|
|
def _profile_material(self, profile: dict) -> str:
|
|
"""Material type (e.g. "PETG") of a saved slot profile, resolved by
|
|
(vendor, name) from the Orca filament library. Returns "" when the
|
|
profile is not in the library — we do NOT guess in that case."""
|
|
name = (profile or {}).get("name", "")
|
|
if not name:
|
|
return ""
|
|
vendor = profile.get("vendor", "")
|
|
for p in self._load_orca_filaments():
|
|
if p.get("vendor") == vendor and p.get("name") == name:
|
|
return p.get("type", "") or ""
|
|
return ""
|
|
|
|
def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict:
|
|
"""Saved slot-profile override — but only while its material *family*
|
|
still matches the material currently loaded in the AMS. Falls back to
|
|
auto-resolving a combined ACE-RFID type string (Issue #101) when there
|
|
is no (usable) manual override.
|
|
|
|
Non-destructive suppression (Option A): when the family no longer matches
|
|
(e.g. a PETG profile but PLA loaded) the override is skipped → falls
|
|
through to the RFID auto-match / generic default. The override stays in
|
|
config.ini and reactivates as soon as the matching material is loaded
|
|
again. When the profile's family is unknown we do NOT suppress (fail-safe).
|
|
|
|
Centralized here (rather than duplicated per caller) so every consumer -
|
|
the dashboard's /kx/filament/slots, Happy-Hare gate data, and the
|
|
OrcaSlicer lane-data sync - benefits from RFID auto-matching identically,
|
|
instead of only the one call site that happened to also call
|
|
_parse_combined_rfid_type() directly."""
|
|
# A combined ACE-RFID string ("GEEETECH PLA Bas") carries a vendor
|
|
# prefix that _material_family() alone can't see past (it only
|
|
# strips known polymer prefixes, so "GEEETECH PLA BAS" resolves to
|
|
# itself, not "PLA") - resolve the plain material family through the
|
|
# RFID parser first so the stale-profile guard below compares against
|
|
# the actual polymer family, not the raw combined string.
|
|
vendor, family = self._parse_combined_rfid_type(ams_material)
|
|
plain_material = family or ams_material
|
|
|
|
profile = self._filament_profiles.get(global_idx) or {}
|
|
if profile.get("name"):
|
|
prof_fam = self._material_family(self._profile_material(profile))
|
|
ams_fam = self._material_family(plain_material)
|
|
if not (prof_fam and ams_fam and prof_fam != ams_fam):
|
|
return profile
|
|
|
|
if vendor:
|
|
variant_tokens = self._rfid_variant_tokens(ams_material)
|
|
auto = self._match_profile_by_vendor_family(vendor, family, variant_tokens)
|
|
if auto.get("name"):
|
|
return auto
|
|
|
|
return {}
|
|
|
|
def _build_lane_data(self) -> dict:
|
|
"""Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0.
|
|
|
|
POSITION-FAITHFUL: every physical slot keeps its position (tray id =
|
|
slot position). Empty slots are reported as placeholder trays, NOT
|
|
filtered out/compacted - otherwise colors shift to wrong positions
|
|
(e.g. slot 1=yellow, 2=empty, 3=red -> red must not land on position 2).
|
|
"""
|
|
slots = self._ams_slots
|
|
total = len(slots)
|
|
if total == 0:
|
|
return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"}
|
|
|
|
ams_count = (total + 3) // 4
|
|
ams_exist_bits = 0
|
|
tray_exist_bits = 0
|
|
ams_array = []
|
|
|
|
for ams_id in range(ams_count):
|
|
ams_exist_bits |= (1 << ams_id)
|
|
tray_array = []
|
|
max_slot = min(3, total - ams_id * 4 - 1)
|
|
for slot_id in range(max_slot + 1):
|
|
slot_index = ams_id * 4 + slot_id
|
|
slot = slots[slot_index] if slot_index < total else {}
|
|
occupied = slot.get("status") == 5
|
|
|
|
if occupied:
|
|
tray_exist_bits |= (1 << slot_index)
|
|
color_raw = slot.get("color", [255, 255, 255])
|
|
if isinstance(color_raw, list) and len(color_raw) >= 3:
|
|
color_hex = "{:02X}{:02X}{:02X}FF".format(
|
|
int(color_raw[0]), int(color_raw[1]), int(color_raw[2])
|
|
)
|
|
elif isinstance(color_raw, str) and len(color_raw) >= 6:
|
|
color_hex = color_raw[:6].upper() + "FF"
|
|
else:
|
|
color_hex = "FFFFFFFF"
|
|
material = self._normalize_material(slot.get("type", "PLA"))
|
|
# User override from config.ini [filament_profiles].slot_N_id
|
|
# takes precedence over the default mapping by material type.
|
|
# The vendor is sent along (tray_sub_brands + filament_vendor),
|
|
# so a patched OrcaSlicer can match by brand + type +
|
|
# color (analogous to SnapmakerPrinterAgent).
|
|
# Three-layer resolution for the filament hint sent to OrcaSlicer,
|
|
# all handled inside _effective_slot_profile() (Issue #101):
|
|
# 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
|
|
# 2. Combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
|
|
# "GEEETECH PLA Bas") auto-matched against the user's
|
|
# already-imported profile library. Not persisted to
|
|
# config.ini - re-derives on every call, so a differently
|
|
# tagged spool loaded later isn't stuck with a stale match.
|
|
# 3. Generic fallback (_TRAY_INFO_IDX) per material type - no
|
|
# vendor hint; OrcaSlicer then picks its own generic preset
|
|
user_profile = self._effective_slot_profile(slot_index, material)
|
|
if user_profile.get("name"):
|
|
material = self._material_family(user_profile.get("type", material)) or material
|
|
vendor = user_profile.get("vendor", "")
|
|
fila_name = user_profile.get("name", "")
|
|
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
|
else:
|
|
# Default: Library-Generic-Profil (siehe _default_filament_name) —
|
|
# is compatible with all printers and guaranteed to be visible.
|
|
# The user deliberately picks a concrete brand per slot if they
|
|
# want one; the default stays neutral.
|
|
fila_name = self._default_filament_name(material)
|
|
vendor = "Generic" if fila_name.startswith("Generic ") else ""
|
|
tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
|
tray_array.append({
|
|
"id": str(slot_id),
|
|
"tag_uid": "0000000000000000",
|
|
"tray_info_idx": tray_info_idx,
|
|
"tray_type": material,
|
|
"tray_color": color_hex,
|
|
"tray_sub_brands": vendor,
|
|
# OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
|
|
# `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
|
|
# filament_id_by_type). We send both spellings so that
|
|
# older patch variants + future upstream PRs are both
|
|
# covered.
|
|
"name": fila_name,
|
|
"vendor_name": vendor,
|
|
# Aliases for older patch variants (variant 2,
|
|
# MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
|
|
# otherwise resolve the preset name via find_preset().
|
|
"filament_id": tray_info_idx,
|
|
"filament_vendor": vendor,
|
|
"filament_name": fila_name,
|
|
"preset": fila_name,
|
|
})
|
|
else:
|
|
tray_array.append({
|
|
"id": str(slot_id),
|
|
"tag_uid": "0000000000000000",
|
|
"tray_info_idx": "",
|
|
"tray_type": "",
|
|
"tray_color": "00000000",
|
|
"tray_slot_placeholder": "1",
|
|
})
|
|
|
|
ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array})
|
|
|
|
return {
|
|
"ams": ams_array,
|
|
"ams_exist_bits": format(ams_exist_bits, "X"),
|
|
"tray_exist_bits": format(tray_exist_bits, "X"),
|
|
}
|