Recognise rolls put back, use CC2 Control's log for bookings
CI / test (push) Successful in 1m13s
CI / image (push) Successful in 48s

- A roll put back into the slot it left, with the tag data unchanged, is
  mapped again and CC2 Control's question answered; if the touchscreen then
  replaces the id, it was another roll and goes to storage.
- A touchscreen edit of a scanned roll keeps the mapping and re-answers
  CC2 Control's question so its use is counted.
- Bookings name the print and its result from CC2 Control's log.
- A weigh-in in CC2 Control sets Spoolman's remaining weight instead of
  being booked as use.
- After "Take out" with the roll still in the tray, the tray's logged use
  is booked to that roll until it leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 10:29:33 +01:00
1 parent a9041eb538
commit 67c3b54903
7 files changed
+607 -40

No files matched your search

+245 -20
View File
@@ -4,10 +4,16 @@ Rules:
- The printer is only read (SSH `cat` of the canvas record). No MQTT, no printer commands.
- Writes go to CC2 Control's spool endpoints and to Spoolman only, and only when dry-run is off.
- Slot <-> spool mappings are kept by the bridge; a tag id vanishing from the record
(weight falls back to 1000 after a touchscreen edit) does not drop a mapping.
(weight falls back to 1000 after a touchscreen edit) does not drop a mapping made by a scan.
- The record keeps a tray's tag data after the roll is taken out. A roll put back into the slot
it left, with the record unchanged, is taken to be that roll again ("reinserted"); if the
touchscreen then replaces the id, it was another roll and the mapping goes.
- Several printers: one worker each; a spool is mapped to at most one slot of one printer.
- Spoolman shows where a loaded spool is (location "<printer> Slot N", extra fields
"drucker"/"slot"); the printer wins over manual edits there.
- Consumption is the drop of CC2 Control's remaining weight, booked once the printer is idle.
CC2 Control's log adds what was printed (file, result), weigh-ins (Spoolman takes the weighed
value) and usage of a tray left without spool after "Take out" (booked to the roll still in it).
"""
from __future__ import annotations
@@ -33,7 +39,54 @@ MIN_BOOKING = 0.05 # grams; smaller changes accumulate until they reach this
SPOOL_CACHE_SECONDS = 300
SYNC_SECONDS = 60 # full Spoolman comparison (locations, extra fields)
LOG_KEEP = 1000
LOG_SEEN_KEEP = 400 # CC2 Control lists its newest 150 entries
TAKEOUT_MARGIN = 120 # seconds: the printer's clock and ours may differ a little
TAKEOUT_KEEP = 86400
REANSWER_SECONDS = 60
SOURCE_NAMES = {"ssh": "SSH", "cc2": "CC2 Control"}
RESULTS = {
"complete": "fertig", "completed": "fertig", "cancelled": "abgebrochen", "canceled": "abgebrochen",
"error": "Fehler", "failed": "Fehler", "ended": "beendet", "changed": "Spule gewechselt",
"moved": "Spule umgezogen", "removed": "entnommen", "runout": "leergelaufen", "late": "nachträglich zugeordnet",
}
def _num(value, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def log_key(e: dict) -> str:
return f"{e.get('time')}|{e.get('slot')}|{e.get('kind')}|{e.get('spool')}|{_num(e.get('mm')):.1f}|{_num(e.get('grams')):.3f}"
def job_text(e: dict) -> str:
"""'benchy.gcode (abgebrochen)' for a CC2 Control print log entry."""
if e.get("kind") == "runout":
return "Rolle leergelaufen"
job = str(e.get("job") or "").rsplit("/", 1)[-1] or "ohne Dateiname"
result = str(e.get("result") or "")
result = RESULTS.get(result, result)
return f"{job} ({result})" if result else job
def describe(entries: list[dict]) -> str:
parts: list[str] = []
for e in entries:
if e.get("kind") in ("print", "runout"):
text = job_text(e)
if text not in parts:
parts.append(text)
if not parts:
return ""
more = f" und {len(parts) - 4} weitere" if len(parts) > 4 else ""
return " – " + ", ".join(parts[:4]) + more
def grams_from_mm(mm: float, diameter: float, density: float) -> float:
return mm * 3.141592653589793 * (diameter / 2) ** 2 * density / 1000
class ActionLog:
@@ -205,8 +258,21 @@ class PrinterWorker:
self._process_trays(cc2)
self._process_cc2_slots(cc2)
self._reconcile_cc2(cc2)
self._process_cc2_log(cc2)
self._consumption(cc2)
def _record_id(self, n: int) -> int | None:
for s in self.record_slots:
if s["slot"] == n:
return s["spool_id"]
return None
def _close_takeout(self, n: int | None = None, spool: int | None = None) -> None:
now = self.bridge.clock()
for w in self.st["takeout"]:
if w.get("until") is None and (w["slot"] == n or w["spool"] == spool):
w["until"] = now
# ---------- record / scans ----------
def _process_record(self, record: dict) -> None:
@@ -217,6 +283,13 @@ class PrinterWorker:
mappings = self.st["slots"]
for s in slots:
n, tid = s["slot"], s["spool_id"]
if prev is not None and tid != prev[n]["spool_id"]:
# the tray's tag data was rewritten: whatever was there before is gone
self.st["last"].pop(str(n), None)
self._close_takeout(n)
m = mappings.get(str(n))
if tid is None and m and m.get("reinserted"):
self.bridge.release(self, n, "Tag-Daten am Display ersetzt, also eine andere Rolle ohne Spoolman-Tag")
if tid is None:
continue
if prev is not None:
@@ -271,6 +344,7 @@ class PrinterWorker:
if st is None:
continue
if st == 0:
self._close_takeout(n)
since = self.empty_since.setdefault(n, now)
if now - since >= self.settings.empty_grace and str(n) in self.st["slots"]:
self.bridge.release(self, n, f"Slot seit {int(now - since)} s leer")
@@ -287,14 +361,20 @@ class PrinterWorker:
sid = slot_spool_id(slot)
m = mappings.get(str(n))
mirrored = spoolman_id_of(by_id[sid]) if sid in by_id else None
question = str(slot.get("question") or "")
loaded = self.tray_status.get(n) in (1, 2)
if question:
self._close_takeout(n) # filament went in or was changed: maybe another roll
# A spool we already map elsewhere (fresh scan, maybe in the other printer) wins
# over this CC2 Control's older binding.
elsewhere = mirrored is not None and self.bridge.mapped_elsewhere(mirrored, self.id, n)
if m is None:
if mirrored is not None and not elsewhere and self.tray_status.get(n) in (1, 2):
if mirrored is not None and not elsewhere and loaded:
# assigned in CC2 Control by hand
self.bridge.map(self, n, mirrored, by_id[sid].get("name") or str(mirrored),
"in CC2 Control zugewiesen", sid)
elif not sid and question == "inserted" and loaded:
self._reinserted(n)
continue
if m.get("cc2") and sid == m["cc2"]:
m["cc2_seen"] = m["assigned"] = True
@@ -309,8 +389,44 @@ class PrinterWorker:
"in CC2 Control zugewiesen", sid)
elif mirrored is None:
self.bridge.release(self, n, "in CC2 Control mit anderer Spule belegt")
elif not slot.get("question"):
elif not question:
if loaded and self._record_id(n) == m["spool"]:
# "Take out" with the roll still in the tray: Spoolman says storage, but what the
# tray prints now is still that roll's
self.st["takeout"].append({"slot": n, "spool": m["spool"], "name": m.get("name") or str(m["spool"]),
"since": self.bridge.clock(), "until": None})
self.bridge.release(self, n, "in CC2 Control herausgenommen bzw. ins Lager gelegt")
elif loaded:
self._answer_again(n, m, question)
def _reinserted(self, n: int) -> None:
"""No mapping, CC2 Control asks about filament put in, and the record still names the roll
that last left this slot: it is that roll again."""
last = self.st["last"].get(str(n))
rid = self._record_id(n)
if not last or rid != last["spool"] or self.bridge.mapped_elsewhere(rid, self.id, n):
return
self.bridge.map(self, n, rid, last["name"], "wieder eingelegt, Tag-Daten unverändert")
self.st["slots"][str(n)]["reinserted"] = True
def _answer_again(self, n: int, m: dict, question: str) -> None:
"""CC2 Control unbound a mapped slot and asks which spool it is."""
rid = self._record_id(n)
if question == "inserted":
if rid != m["spool"]:
self.bridge.release(self, n, "andere Rolle eingelegt")
return
m["reinserted"] = True # taken out and put back without a new scan
elif rid != m["spool"] and (rid is not None or m.get("reinserted")):
return
# same roll (or a touchscreen edit of it): tell CC2 Control again, at most once a minute
if not m.get("assigned") or self.bridge.clock() - m.get("answered_at", 0) < REANSWER_SECONDS:
return
m["assigned"] = False
m["answered_at"] = self.bridge.clock()
self.bridge.dry_logged.pop(f"{self.id}:assign:{n}", None)
self._log("info", f"Slot {n + 1}: CC2 Control fragt nach der Spule ({question}); "
f"{m.get('name') or m['spool']} wird erneut zugewiesen")
def _reconcile_cc2(self, cc2: dict) -> None:
mirrors: dict[int, dict] = {}
@@ -347,7 +463,7 @@ class PrinterWorker:
continue
self.st["booked"][cc2_id] = {
"spoolman": m["spool"], "remaining": float(fields.get("remaining", fields.get("net", 1000))),
"pending": None, "last": None,
"pending": None, "last": None, "log_time": 0.0,
}
m["cc2"] = cc2_id
else:
@@ -371,8 +487,13 @@ class PrinterWorker:
def _consumption(self, cc2: dict) -> None:
job_active = bool((cc2.get("job") or {}).get("active"))
if job_active:
# a print ran with it: a later touchscreen edit is an edit of this roll
for m in self.st["slots"].values():
m.pop("reinserted", None)
booked = self.st["booked"]
now = self.bridge.clock()
entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)]
for sp in cc2.get("spools") or []:
smid = spoolman_id_of(sp)
if smid is None:
@@ -382,48 +503,63 @@ class PrinterWorker:
rem = float(sp.get("remaining"))
except (TypeError, ValueError):
continue
name = sp.get("name") or cid
mine = [e for e in entries if str(e.get("spool")) == cid]
newest = max((_num(e.get("time")) for e in mine), default=0.0)
seen = self.remaining_seen.get(cid)
if seen is None or abs(seen[0] - rem) > 1e-6:
self.remaining_seen[cid] = (rem, now)
b = booked.get(cid)
if b is None or b.get("spoolman") != smid:
booked[cid] = {"spoolman": smid, "remaining": rem, "pending": None, "last": None}
self._log("info", f"{sp.get('name')}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})")
booked[cid] = {"spoolman": smid, "remaining": rem, "pending": None, "last": None, "log_time": newest}
self._log("info", f"{name}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})")
continue
b.setdefault("log_time", newest) # state from before the log was read
fresh = [e for e in mine if _num(e.get("time")) > b["log_time"]]
try:
if b.get("pending") and not self._resolve_pending(cid, b):
continue
settled = not job_active and now - self.remaining_seen[cid][1] >= self.settings.settle_seconds
if any(e.get("kind") == "correction" for e in fresh):
if settled:
self._weighin(cid, b, name, smid, rem, newest)
continue
delta = b["remaining"] - rem
if delta < -MIN_BOOKING:
self._log("warn", f"{sp.get('name')}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
self._log("warn", f"{name}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
"nicht gebucht, neuer Ausgangsstand")
b["remaining"] = rem
b["log_time"] = newest
continue
if delta < MIN_BOOKING or job_active:
if delta < MIN_BOOKING or not settled:
continue
if now - self.remaining_seen[cid][1] < self.settings.settle_seconds:
continue
self._book(cid, b, sp.get("name") or cid, smid, delta, rem)
self._book(cid, b, name, smid, delta, rem, describe(fresh), newest)
except SpoolmanNotFound:
self.bridge._once_error(f"{self.id}:book:{cid}",
f"{self.name}: {sp.get('name')}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
f"{self.name}: {name}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
except SpoolmanError as e:
self.bridge._spoolman_error(str(e))
def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float) -> None:
def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float,
details: str = "", log_time: float | None = None) -> None:
if self.settings.dry_run:
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen", lambda: None)
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen{details}", lambda: None)
return
before = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.bridge.clock()}
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.bridge.clock(),
"details": details, "log_time": log_time}
self.bridge.state.save() # intent first: a crash after the PUT must not book twice
self.bridge.spoolman.use(smid, delta)
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht")
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht{details}",
details, log_time)
def _booked(self, b: dict, grams: float, target: float, msg: str) -> None:
def _booked(self, b: dict, grams: float, target: float, msg: str, details: str = "",
log_time: float | None = None) -> None:
b["remaining"] = target
b["pending"] = None
b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3)}
b["last"] = {"time": self.bridge.clock(), "grams": round(grams, 3), "details": details.removeprefix(" – ")}
if log_time is not None:
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
self.bridge.state.save()
self._log("info", msg)
@@ -431,18 +567,98 @@ class PrinterWorker:
"""A booking was interrupted: check whether Spoolman already has it."""
p = b["pending"]
smid = b["spoolman"]
details, log_time = p.get("details") or "", p.get("log_time")
used = float(self.bridge.spoolman.spool(smid).get("used_weight") or 0)
if used >= p["before"] + p["grams"] - 0.01:
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman")
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g war bereits in Spoolman",
details, log_time)
return True
if self.settings.dry_run:
self._write(f"book:{cid}", f"unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} wiederholen",
lambda: None)
return False
self.bridge.spoolman.use(smid, p["grams"])
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt")
self._booked(b, p["grams"], p["target"], f"Unterbrochene Buchung über {p['grams']:.2f} g auf Spoolman-Spule {smid} nachgeholt",
details, log_time)
return True
def _weighin(self, cid: str, b: dict, name: str, smid: int, rem: float, log_time: float) -> None:
"""Weighed in CC2 Control: Spoolman takes the weighed value (and any use since) as it is."""
sm = self.bridge.spoolman.spool(smid)
base = sm.get("initial_weight") or (sm.get("filament") or {}).get("weight")
if not base:
self.bridge._once_error(f"{self.id}:weigh:{cid}", f"{self.name}: {name}: Spoolman-Spule {smid} hat kein "
"Startgewicht; Nachwiegen nicht übernommen")
return
target = max(0.0, rem)
used = round(max(0.0, float(base) - target), 3)
_, done = self._write(f"weigh:{cid}", f"in Spoolman den Rest von {name} auf {target:.1f} g setzen "
"(in CC2 Control nachgewogen)",
lambda: self.bridge.spoolman.update_spool(smid, {"used_weight": used}))
if done:
b["remaining"] = rem
b["log_time"] = max(b.get("log_time") or 0.0, log_time)
self.bridge.spool_cache.pop(smid, None)
self.bridge.state.save()
# ---------- CC2 Control's log: use of a tray left without spool after "Take out" ----------
def _process_cc2_log(self, cc2: dict) -> None:
st = self.st
entries = [e for e in cc2.get("log") or [] if isinstance(e, dict)]
keys = [log_key(e) for e in entries]
now = self.bridge.clock()
if st.get("log_seen") is None:
st["log_seen"] = keys[:LOG_SEEN_KEEP] # older entries are not ours to book
else:
seen = set(st["log_seen"])
for e, key in zip(entries, keys):
if key in seen:
continue
st["log_seen"].append(key)
seen.add(key)
n, t, mm = e.get("slot"), _num(e.get("time")), _num(e.get("mm"))
if e.get("kind") != "print" or e.get("spool") or n not in (0, 1, 2, 3) or mm <= 0:
continue
win = next((w for w in st["takeout"] if w["slot"] == n and
w["since"] - TAKEOUT_MARGIN <= t <= (w["until"] or now) + TAKEOUT_MARGIN), None)
if win:
st["extra"].append({"key": key, "slot": n, "spool": win["spool"], "name": win["name"], "mm": mm,
"job": job_text(e), "before": None, "grams": None})
st["log_seen"] = st["log_seen"][-LOG_SEEN_KEEP:]
st["takeout"] = [w for w in st["takeout"] if w["until"] is None or now - w["until"] < TAKEOUT_KEEP]
self._process_extra()
def _process_extra(self) -> None:
rest = []
for item in self.st["extra"]:
smid = item["spool"]
try:
sm = self.bridge.spoolman.spool(smid)
fil = sm.get("filament") or {}
grams = round(grams_from_mm(item["mm"], _num(fil.get("diameter"), 1.75) or 1.75,
_num(fil.get("density"), 1.24) or 1.24), 3)
msg = (f"{grams:.2f} g von {item['name']} auf Spoolman-Spule {smid} nachgebucht (Slot {item['slot'] + 1} "
f"druckte nach „Take out“ ohne Spule) – {item['job']}")
if self.settings.dry_run:
self._write(f"extra:{item['key']}", msg, lambda: None)
rest.append(item)
continue
used = float(sm.get("used_weight") or 0)
if item["before"] is not None and used >= item["before"] + item["grams"] - 0.01:
self._log("info", msg) # booked before a crash
continue
item["before"], item["grams"] = used, grams
self.bridge.state.save()
self.bridge.spoolman.use(smid, grams)
self._log("info", msg)
except SpoolmanNotFound:
self._log("warn", f"Spoolman-Spule {smid} existiert nicht; {item['mm']:.0f} mm aus Slot {item['slot'] + 1} nicht gebucht")
except SpoolmanError as e:
self.bridge._spoolman_error(str(e))
rest.append(item)
self.st["extra"] = rest
# ---------- UI ----------
def snapshot(self) -> dict:
@@ -457,7 +673,9 @@ class PrinterWorker:
sid = slot_spool_id(cs)
cached = self.bridge.spool_cache.get(m["spool"]) if m else None
b = self.st["booked"].get(m["cc2"]) if m and m.get("cc2") else None
takeout = next((t for t in self.st["takeout"] if t["slot"] == n and t["until"] is None), None)
slots.append({
"takeout": {"spool": takeout["spool"], "name": takeout["name"]} if takeout else None,
"slot": n,
"record": record_slots[n],
"tray_status": self.tray_status.get(n),
@@ -638,6 +856,7 @@ class Bridge:
if cur and cur["spool"] == tid:
if cc2_id is None:
cur["assigned"] = False # rescan: make sure CC2 Control has it in the slot
cur.pop("reinserted", None)
self.dry_logged.pop(f"{w.id}:assign:{n}", None)
return
for pid, k, m in list(self.mappings()):
@@ -653,12 +872,18 @@ class Bridge:
"cc2": cc2_id, "assigned": cc2_id is not None, "cc2_seen": cc2_id is not None,
}
w.empty_since.pop(n, None)
for other in self.workers.values():
last = other.st["last"]
for k in [k for k, v in last.items() if v["spool"] == tid or (other is w and k == str(n))]:
del last[k]
other._close_takeout(n if other is w else None, tid)
w._log("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})")
def release(self, w: PrinterWorker, n: int, reason: str) -> None:
m = w.st["slots"].pop(str(n), None)
if not m:
return
w.st["last"][str(n)] = {"spool": m["spool"], "name": m.get("name") or str(m["spool"])}
self.dry_logged.pop(f"{w.id}:assign:{n}", None)
w._log("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})")
+4
View File
@@ -240,6 +240,10 @@ class StateStore:
p.setdefault("booked", {})
p.setdefault("last_record", None)
p.setdefault("scans", [])
p.setdefault("last", {}) # slot -> roll that last left it (for putting it back)
p.setdefault("takeout", []) # windows in which a tray prints for a roll taken out in CC2 Control
p.setdefault("log_seen", None) # CC2 Control log entries already looked at
p.setdefault("extra", []) # bookings from CC2 Control's log still to make
return p
def save(self) -> None:
+10 -5
View File
@@ -12,6 +12,7 @@
{% endblock %}
{% block script %}
<script>
const QUESTION = { inserted: 'eingelegt', changed: 'geändert' };
const TRAY = { 0: ['leer', 'warn'], 1: ['eingelegt', 'ok'], 2: ['fördert', 'ok'] };
const LEVEL = { info: ['Info', ''], warn: ['Warnung', 'warn'], error: ['Fehler', 'err'], dry: ['Dry-Run', 'dry'] };
@@ -35,8 +36,8 @@ function slotCard(p, s) {
assign = s.cc2_matches ? '<span class="tag ok">zugewiesen</span>'
: (m.cc2 ? '<span class="tag warn">nicht zugewiesen</span>' : '<span class="tag warn">noch nicht angelegt</span>');
}
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: ${esc(s.cc2_question)}</span>`;
const orphan = !m && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2);
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: Spule ${esc(QUESTION[s.cc2_question] || s.cc2_question)}</span>`;
const orphan = !m && !s.takeout && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2);
const lb = s.last_booking;
return `<div class="panel">
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
@@ -47,10 +48,14 @@ function slotCard(p, s) {
<dt>Spoolman</dt><dd>${m ? `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '') : '<span class="muted">nicht zugeordnet</span>'}</dd>
<dt>CC2 Control</dt><dd>${cc ? esc(cc.name) + ` <span class="small muted">Rest ${Number(cc.remaining).toFixed(1)} g</span><br>` : ''}${assign}</dd>
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` : '<span class="muted">–</span>'}</dd>
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` +
(lb.details ? `<br><span class="small muted">${esc(lb.details)}</span>` : '') : '<span class="muted">–</span>'}</dd>
</dl>
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steckt noch, ist aber keinem Slot zugeordnet
(z. B. nach „Take out“ in CC2 Control). Verbrauch wird so nicht gezählt – zum Zuordnen die Spule neu einlegen und scannen.</p>` : ''}
${s.takeout ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">${esc(s.takeout.name)} wurde in CC2 Control herausgenommen
und steht in Spoolman im Lager, steckt aber noch. Was dieser Slot druckt, bucht die Bridge trotzdem auf ${esc(s.takeout.name)},
bis die Rolle entnommen wird. Wieder zuordnen: Rolle herausnehmen und neu einlegen.</p>` : ''}
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steht noch im Slot, ist aber keiner Rolle
zugeordnet. Liegt diese Rolle hier, sie herausnehmen und neu einlegen (oder in CC2 Control auswählen).</p>` : ''}
${m ? `<div class="row" style="margin-top:10px">
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Zuordnung erneut senden</button>
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}