Multiple printers, printer/slot in Spoolman, kept in sync
- One worker per printer (name, IP, CC2 Control URL, own pinned host key); a spool sits in at most one slot of one printer, moves between printers - Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot" (created by the bridge), cleared and "Lager" on removal - Full comparison every minute: the printer wins over manual edits of loaded spools; stale printer locations go to storage; own locations untouched - Automatic migration of v1 settings, state and host key - Status hint when a tag is still in a tray but unmapped (e.g. after "Take out" in CC2 Control) - SSH test: connection drops during exec give a clean error, not a 500 - Mock environment with several printers; tests for all of the above Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
1 parent
bf32667f77
commit
a9041eb538
15 files changed
+1400
-684
No files matched your search
+568
-373
File diff suppressed because it is too large.
Load diff
+30
-2
@@ -2,11 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
# Extra fields the bridge keeps on loaded spools (Spoolman stores values JSON-encoded).
|
||||
FIELD_PRINTER = "drucker"
|
||||
FIELD_SLOT = "slot"
|
||||
FIELD_DEFS = {
|
||||
FIELD_PRINTER: {"name": "Drucker", "field_type": "text", "order": 100},
|
||||
FIELD_SLOT: {"name": "Slot", "field_type": "integer", "order": 101},
|
||||
}
|
||||
|
||||
|
||||
class SpoolmanError(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -42,8 +52,14 @@ class Spoolman:
|
||||
def spools(self, archived: bool = False) -> list[dict]:
|
||||
return self._request("GET", "/spool", params={"allow_archived": str(archived).lower()})
|
||||
|
||||
def set_location(self, spool_id: int, location: str) -> dict:
|
||||
return self._request("PATCH", f"/spool/{int(spool_id)}", json={"location": location})
|
||||
def update_spool(self, spool_id: int, body: dict) -> dict:
|
||||
return self._request("PATCH", f"/spool/{int(spool_id)}", json=body)
|
||||
|
||||
def fields(self) -> list[dict]:
|
||||
return self._request("GET", "/field/spool")
|
||||
|
||||
def add_field(self, key: str, params: dict) -> list[dict]:
|
||||
return self._request("POST", f"/field/spool/{key}", json=params)
|
||||
|
||||
def use(self, spool_id: int, grams: float) -> dict:
|
||||
return self._request("PUT", f"/spool/{int(spool_id)}/use", json={"use_weight": round(float(grams), 3)})
|
||||
@@ -73,6 +89,16 @@ def color_hex(spool: dict) -> str | None:
|
||||
return color if re.fullmatch(r"[0-9A-F]{6}", color) else None
|
||||
|
||||
|
||||
def extra_value(spool: dict, key: str):
|
||||
raw = (spool.get("extra") or {}).get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def summary(spool: dict) -> dict:
|
||||
fil = spool.get("filament") or {}
|
||||
return {
|
||||
@@ -91,4 +117,6 @@ def summary(spool: dict) -> dict:
|
||||
"location": spool.get("location") or "",
|
||||
"archived": bool(spool.get("archived")),
|
||||
"used_weight": spool.get("used_weight"),
|
||||
"printer": extra_value(spool, FIELD_PRINTER),
|
||||
"slot": extra_value(spool, FIELD_SLOT),
|
||||
}
|
||||
+22
-11
@@ -108,25 +108,36 @@ class KeyStore:
|
||||
"public": f"{key.get_name()} {key.get_base64()} cc2-spoolman-bridge",
|
||||
}
|
||||
|
||||
def host_key(self) -> dict | None:
|
||||
return read_json(self.host_path, None)
|
||||
def _host_path(self, pid: str) -> Path:
|
||||
if not pid.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValueError("invalid printer id")
|
||||
return self.dir / f"host_key_{pid}.json"
|
||||
|
||||
def pin_host_key(self, host: str, key: paramiko.PKey) -> None:
|
||||
def migrate_host_key(self, pid: str) -> None:
|
||||
"""v1 kept one host key in host_key.json; it belongs to the first printer."""
|
||||
if self.host_path.exists() and not self._host_path(pid).exists():
|
||||
os.replace(self.host_path, self._host_path(pid))
|
||||
|
||||
def host_key(self, pid: str) -> dict | None:
|
||||
return read_json(self._host_path(pid), None)
|
||||
|
||||
def pin_host_key(self, pid: str, host: str, key: paramiko.PKey) -> None:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(self.host_path, {
|
||||
atomic_write_json(self._host_path(pid), {
|
||||
"host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
|
||||
}, mode=0o600)
|
||||
|
||||
def forget_host_key(self) -> None:
|
||||
def forget_host_key(self, pid: str) -> None:
|
||||
try:
|
||||
self.host_path.unlink()
|
||||
self._host_path(pid).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class CanvasRecordReader:
|
||||
def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
|
||||
def __init__(self, keys: KeyStore, pid: str, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
|
||||
self.keys = keys
|
||||
self.pid = pid
|
||||
self.host, self.port, self.user, self.timeout = host, port, user, timeout
|
||||
self._transport: paramiko.Transport | None = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -155,7 +166,7 @@ class CanvasRecordReader:
|
||||
try:
|
||||
t.start_client(timeout=self.timeout)
|
||||
server_key = t.get_remote_server_key()
|
||||
pinned = self.keys.host_key()
|
||||
pinned = self.keys.host_key(self.pid)
|
||||
if pinned is None:
|
||||
if not accept_unknown:
|
||||
raise HostKeyUnknown(server_key)
|
||||
@@ -222,11 +233,11 @@ class CanvasRecordReader:
|
||||
try:
|
||||
server_key = self._connect(accept_unknown=True)
|
||||
raw = self._exec()
|
||||
except HostKeyMismatch:
|
||||
raise
|
||||
except (paramiko.SSHException, OSError, EOFError) as e:
|
||||
raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
|
||||
finally:
|
||||
self._close()
|
||||
pinned = self.keys.host_key()
|
||||
pinned = self.keys.host_key(self.pid)
|
||||
return {
|
||||
"host_key": {"type": server_key.get_name(), "key": server_key.get_base64(),
|
||||
"fingerprint": fingerprint(server_key)},
|
||||
|
||||
+172
-67
@@ -4,11 +4,15 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from pathlib import Path
|
||||
|
||||
V1_LOCATION_SLOT = "CC2 Slot {n}"
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -47,64 +51,6 @@ def read_json(path: Path, default):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
printer_host: str = "10.10.2.202"
|
||||
ssh_port: int = 22
|
||||
ssh_user: str = "root"
|
||||
cc2_url: str = "http://10.10.2.202:8081"
|
||||
spoolman_url: str = "http://10.10.6.10:7912"
|
||||
id_offset: int = 10000
|
||||
poll_interval: float = 5.0
|
||||
location_slot: str = "CC2 Slot {n}"
|
||||
location_storage: str = "Lager"
|
||||
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
|
||||
settle_seconds: float = 60.0 # `used` must be unchanged this long before booking
|
||||
dry_run: bool = True
|
||||
|
||||
# environment variable -> field (only used as defaults before settings.json exists)
|
||||
ENV = {
|
||||
"PRINTER_HOST": "printer_host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
|
||||
"CC2_URL": "cc2_url", "SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset",
|
||||
"POLL_INTERVAL": "poll_interval", "DRY_RUN": "dry_run",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Settings":
|
||||
s = cls()
|
||||
s.update(d)
|
||||
return s
|
||||
|
||||
def update(self, d: dict) -> None:
|
||||
for f in fields(self):
|
||||
if f.name in d and d[f.name] is not None:
|
||||
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
|
||||
self.validate()
|
||||
|
||||
def validate(self) -> None:
|
||||
if not 1 <= self.ssh_port <= 65535:
|
||||
raise ValueError("SSH-Port ungültig")
|
||||
if not 0 <= self.id_offset <= 65000:
|
||||
raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen")
|
||||
if not 1 <= self.poll_interval <= 300:
|
||||
raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen")
|
||||
if "{n}" not in self.location_slot:
|
||||
raise ValueError("Ortsname für Slots muss {n} enthalten")
|
||||
for name in ("cc2_url", "spoolman_url"):
|
||||
v = getattr(self, name)
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError(f"{name} muss mit http:// oder https:// beginnen")
|
||||
setattr(self, name, v.rstrip("/"))
|
||||
if not self.location_storage.strip():
|
||||
raise ValueError("Ortsname für das Lager fehlt")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
def slot_location(self, slot: int) -> str:
|
||||
return self.location_slot.replace("{n}", str(slot + 1))
|
||||
|
||||
|
||||
def _coerce(typ, value, name):
|
||||
typ = typ if isinstance(typ, str) else typ.__name__
|
||||
try:
|
||||
@@ -121,6 +67,138 @@ def _coerce(typ, value, name):
|
||||
raise ValueError(f"Ungültiger Wert für {name}") from None
|
||||
|
||||
|
||||
def new_printer_id() -> str:
|
||||
return "p" + secrets.token_hex(3)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrinterConfig:
|
||||
id: str = ""
|
||||
name: str = "CC2"
|
||||
host: str = "10.10.2.202"
|
||||
ssh_port: int = 22
|
||||
ssh_user: str = "root"
|
||||
cc2_url: str = "http://10.10.2.202:8081"
|
||||
enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "PrinterConfig":
|
||||
p = cls()
|
||||
for f in fields(p):
|
||||
if f.name in d and d[f.name] is not None:
|
||||
setattr(p, f.name, _coerce(f.type, d[f.name], f.name))
|
||||
if not p.id:
|
||||
p.id = new_printer_id()
|
||||
p.validate()
|
||||
return p
|
||||
|
||||
def validate(self) -> None:
|
||||
if not re.fullmatch(r"[a-z0-9_-]{1,32}", self.id):
|
||||
raise ValueError("Ungültige Drucker-ID")
|
||||
if not self.name.strip():
|
||||
raise ValueError("Druckername fehlt")
|
||||
if len(self.name) > 60:
|
||||
raise ValueError("Druckername ist zu lang (max. 60 Zeichen)")
|
||||
if not self.host.strip():
|
||||
raise ValueError(f"{self.name}: Drucker-IP fehlt")
|
||||
if not 1 <= self.ssh_port <= 65535:
|
||||
raise ValueError(f"{self.name}: SSH-Port ungültig")
|
||||
if not self.cc2_url.startswith(("http://", "https://")):
|
||||
raise ValueError(f"{self.name}: CC2-Control-URL muss mit http:// beginnen")
|
||||
self.cc2_url = self.cc2_url.rstrip("/")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
spoolman_url: str = "http://10.10.6.10:7912"
|
||||
id_offset: int = 10000
|
||||
poll_interval: float = 5.0
|
||||
location_slot: str = "{printer} Slot {n}"
|
||||
location_storage: str = "Lager"
|
||||
spoolman_fields: bool = True # also write the extra fields "drucker" and "slot"
|
||||
empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty
|
||||
settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking
|
||||
dry_run: bool = True
|
||||
printers: list[PrinterConfig] = field(default_factory=list)
|
||||
|
||||
# environment variable -> field (only used as defaults before settings.json exists)
|
||||
ENV = {
|
||||
"SPOOLMAN_URL": "spoolman_url", "ID_OFFSET": "id_offset", "POLL_INTERVAL": "poll_interval",
|
||||
"DRY_RUN": "dry_run",
|
||||
}
|
||||
PRINTER_ENV = {
|
||||
"PRINTER_NAME": "name", "PRINTER_HOST": "host", "SSH_PORT": "ssh_port", "SSH_USER": "ssh_user",
|
||||
"CC2_URL": "cc2_url",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Settings":
|
||||
s = cls()
|
||||
s.update(migrate_settings(d))
|
||||
return s
|
||||
|
||||
def update(self, d: dict) -> None:
|
||||
for f in fields(self):
|
||||
if f.name == "printers" or f.name not in d or d[f.name] is None:
|
||||
continue
|
||||
setattr(self, f.name, _coerce(f.type, d[f.name], f.name))
|
||||
if d.get("printers") is not None:
|
||||
self.printers = [p if isinstance(p, PrinterConfig) else PrinterConfig.from_dict(p) for p in d["printers"]]
|
||||
self.validate()
|
||||
|
||||
def validate(self) -> None:
|
||||
if not 0 <= self.id_offset <= 65000:
|
||||
raise ValueError("ID-Offset muss zwischen 0 und 65000 liegen")
|
||||
if not 1 <= self.poll_interval <= 300:
|
||||
raise ValueError("Abfrage-Intervall muss zwischen 1 und 300 s liegen")
|
||||
if "{n}" not in self.location_slot:
|
||||
raise ValueError("Ortsname für Slots muss {n} enthalten")
|
||||
if len(self.printers) > 1 and "{printer}" not in self.location_slot:
|
||||
raise ValueError("Bei mehreren Druckern muss der Ortsname {printer} enthalten")
|
||||
if not self.spoolman_url.startswith(("http://", "https://")):
|
||||
raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen")
|
||||
self.spoolman_url = self.spoolman_url.rstrip("/")
|
||||
if not self.location_storage.strip():
|
||||
raise ValueError("Ortsname für das Lager fehlt")
|
||||
ids = [p.id for p in self.printers]
|
||||
names = [p.name.strip().lower() for p in self.printers]
|
||||
if len(set(ids)) != len(ids) or len(set(names)) != len(names):
|
||||
raise ValueError("Druckernamen müssen eindeutig sein")
|
||||
if len(self.printers) > 8:
|
||||
raise ValueError("Höchstens 8 Drucker")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
d["printers"] = [p.to_dict() for p in self.printers]
|
||||
return d
|
||||
|
||||
def printer(self, pid: str) -> PrinterConfig | None:
|
||||
return next((p for p in self.printers if p.id == pid), None)
|
||||
|
||||
def slot_location(self, printer: PrinterConfig | str, slot: int) -> str:
|
||||
name = printer.name if isinstance(printer, PrinterConfig) else printer
|
||||
return self.location_slot.replace("{printer}", name).replace("{n}", str(slot + 1))
|
||||
|
||||
|
||||
def migrate_settings(d: dict) -> dict:
|
||||
"""v1 had one printer at the top level; v2 keeps a list of printers."""
|
||||
d = dict(d)
|
||||
if "printers" not in d and "printer_host" in d:
|
||||
d["printers"] = [{
|
||||
"id": "p1", "name": d.get("printer_name") or "CC2", "host": d.get("printer_host"),
|
||||
"ssh_port": d.get("ssh_port", 22), "ssh_user": d.get("ssh_user", "root"),
|
||||
"cc2_url": d.get("cc2_url"), "enabled": True,
|
||||
}]
|
||||
if d.get("location_slot") == V1_LOCATION_SLOT:
|
||||
d["location_slot"] = Settings.location_slot
|
||||
for key in ("printer_host", "printer_name", "ssh_port", "ssh_user", "cc2_url"):
|
||||
d.pop(key, None)
|
||||
return d
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
def __init__(self, data_dir: Path, env: dict | None = None):
|
||||
self.path = data_dir / "settings.json"
|
||||
@@ -129,9 +207,13 @@ class SettingsStore:
|
||||
if stored is None:
|
||||
env = os.environ if env is None else env
|
||||
defaults = {f: env[k] for k, f in Settings.ENV.items() if env.get(k)}
|
||||
printer = {f: env[k] for k, f in Settings.PRINTER_ENV.items() if env.get(k)}
|
||||
defaults["printers"] = [{"id": "p1", **printer}]
|
||||
self.settings = Settings.from_dict(defaults)
|
||||
else:
|
||||
self.settings = Settings.from_dict(stored)
|
||||
if "printer_host" in stored: # write the migrated form once
|
||||
atomic_write_json(self.path, self.settings.to_dict())
|
||||
|
||||
def get(self) -> Settings:
|
||||
with self._lock:
|
||||
@@ -146,16 +228,39 @@ class SettingsStore:
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Bridge state: slot mappings, booked consumption, last record."""
|
||||
"""Bridge state: per printer slot mappings, booked consumption and last record; Spoolman locations."""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
def __init__(self, data_dir: Path, first_printer: str = "p1"):
|
||||
self.path = data_dir / "state.json"
|
||||
self.data = read_json(self.path, {})
|
||||
self.data.setdefault("version", 1)
|
||||
self.data.setdefault("slots", {})
|
||||
self.data.setdefault("booked", {})
|
||||
self.data.setdefault("locations", {})
|
||||
self.data.setdefault("last_record", None)
|
||||
self.data = migrate_state(read_json(self.path, {}), first_printer)
|
||||
|
||||
def printer(self, pid: str) -> dict:
|
||||
p = self.data["printers"].setdefault(pid, {})
|
||||
p.setdefault("slots", {})
|
||||
p.setdefault("booked", {})
|
||||
p.setdefault("last_record", None)
|
||||
p.setdefault("scans", [])
|
||||
return p
|
||||
|
||||
def save(self) -> None:
|
||||
atomic_write_json(self.path, self.data)
|
||||
|
||||
|
||||
def migrate_state(data: dict, first_printer: str) -> dict:
|
||||
data.setdefault("printers", {})
|
||||
if data.get("version", 1) < 2:
|
||||
if any(k in data for k in ("slots", "booked", "last_record", "scans")):
|
||||
data["printers"][first_printer] = {
|
||||
"slots": data.pop("slots", {}), "booked": data.pop("booked", {}),
|
||||
"last_record": data.pop("last_record", None), "scans": data.pop("scans", []),
|
||||
}
|
||||
applied = {}
|
||||
for sid, loc in (data.pop("locations", None) or {}).items():
|
||||
if loc.get("done"):
|
||||
applied[sid] = {"location": loc["done"], "printer": None, "slot": None}
|
||||
data["applied"] = applied
|
||||
data["written_locations"] = sorted({a["location"] for a in applied.values()})
|
||||
data["version"] = 2
|
||||
data.setdefault("applied", {})
|
||||
data.setdefault("written_locations", [])
|
||||
return data
|
||||
+94
-55
@@ -1,38 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Einstellungen{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Einstellungen</h2>
|
||||
<form id="settings" class="panel" onsubmit="saveSettings(event)">
|
||||
<h2>Drucker</h2>
|
||||
<div id="printers"></div>
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<button onclick="addPrinter()">Drucker hinzufügen</button>
|
||||
<button class="primary" onclick="saveSettings()">Speichern</button>
|
||||
<span id="saved1" class="small muted"></span>
|
||||
</div>
|
||||
<p class="small muted">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p>
|
||||
|
||||
<h2>Allgemein</h2>
|
||||
<form id="settings" class="panel" onsubmit="event.preventDefault(); saveSettings()">
|
||||
<div class="row">
|
||||
<div class="field"><label for="printer_host">Drucker-IP</label><input id="printer_host" required></div>
|
||||
<div class="field"><label for="ssh_port">SSH-Port</label><input id="ssh_port" type="number" min="1" max="65535"></div>
|
||||
<div class="field"><label for="ssh_user">SSH-Benutzer</label><input id="ssh_user"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="cc2_url">CC2-Control-URL</label><input id="cc2_url" required></div>
|
||||
<div class="field"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required></div>
|
||||
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
|
||||
<div class="field"><label for="poll_interval">Abfrage-Intervall (s)</label><input id="poll_interval" type="number" step="0.5" min="1" max="300"></div>
|
||||
<div class="field"><label for="empty_grace">Slot leer nach (s)</label><input id="empty_grace" type="number" min="0"></div>
|
||||
<div class="field"><label for="settle_seconds">Buchen nach Ruhe (s)</label><input id="settle_seconds" type="number" min="0"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="location_slot">Ort im Slot (<code>{n}</code> = 1–4)</label><input id="location_slot" required></div>
|
||||
<div class="field"><label for="location_slot">Ort im Slot (<code>{printer}</code> = Druckername, <code>{n}</code> = 1–4)</label><input id="location_slot" required></div>
|
||||
<div class="field"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" id="dry_run" style="width:auto"> Dry-Run (Schreibaktionen nur protokollieren)</label>
|
||||
<label style="color:var(--text)"><input type="checkbox" id="spoolman_fields" style="width:auto"> Zusatzfelder „Drucker“ und „Slot“ in Spoolman pflegen</label>
|
||||
</div>
|
||||
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved" class="small muted"></span></div>
|
||||
<div class="field">
|
||||
<label style="color:var(--text)"><input type="checkbox" id="dry_run" style="width:auto"> Dry-Run (Schreibaktionen nur protokollieren)</label>
|
||||
</div>
|
||||
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved2" class="small muted"></span></div>
|
||||
</form>
|
||||
|
||||
<h2>SSH-Schlüssel für den Drucker</h2>
|
||||
<h2>SSH-Schlüssel für die Drucker</h2>
|
||||
<div class="panel">
|
||||
<div id="keyinfo" class="field muted">Lade …</div>
|
||||
<div class="field">
|
||||
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen</label>
|
||||
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen – gilt für alle Drucker</label>
|
||||
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -42,39 +48,75 @@
|
||||
</div>
|
||||
<p class="small muted">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p>
|
||||
</div>
|
||||
|
||||
<h2>Verbindung testen</h2>
|
||||
<div class="panel">
|
||||
<div id="hostkey" class="field muted"></div>
|
||||
<div class="row"><button class="primary" onclick="testSSH()">Verbindung testen</button>
|
||||
<button class="danger" onclick="forgetHost()">Host-Key vergessen</button></div>
|
||||
<div id="testresult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const FIELDS = ['printer_host', 'ssh_port', 'ssh_user', 'cc2_url', 'spoolman_url', 'id_offset', 'poll_interval',
|
||||
'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
|
||||
let current = {};
|
||||
const FIELDS = ['spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
|
||||
const PFIELDS = [['name', 'Name'], ['host', 'Drucker-IP'], ['ssh_port', 'SSH-Port'], ['ssh_user', 'SSH-Benutzer'], ['cc2_url', 'CC2-Control-URL']];
|
||||
let current = {}, printers = [], hostKeys = {}, saved = new Set();
|
||||
|
||||
function printerCard(p, i) {
|
||||
const hk = hostKeys[p.id];
|
||||
const isSaved = saved.has(p.id);
|
||||
return `<div class="panel" style="margin-bottom:12px" id="pc${i}">
|
||||
<div class="row">${PFIELDS.map(([f, l]) => `<div class="field"><label>${l}</label>
|
||||
<input data-i="${i}" data-f="${f}" value="${esc(p[f])}" ${f === 'ssh_port' ? 'type="number" min="1" max="65535"' : ''}></div>`).join('')}</div>
|
||||
<div class="row" style="align-items:center">
|
||||
<label style="margin:0;color:var(--text);flex:0 0 auto"><input type="checkbox" data-i="${i}" data-f="enabled" ${p.enabled ? 'checked' : ''} style="width:auto"> Abfrage aktiv</label>
|
||||
<span class="small" style="flex:1 1 200px">${isSaved ? (hk ? `Host-Key <code>${esc(hk.fingerprint)}</code>` : '<span class="tag warn">Host-Key nicht bestätigt</span>') : '<span class="tag">noch nicht gespeichert</span>'}</span>
|
||||
${isSaved ? `<button class="primary" onclick="testSSH(${i})">Verbindung testen</button>
|
||||
<button onclick="forgetHost(${i})">Host-Key vergessen</button>` : ''}
|
||||
<button class="danger" onclick="removePrinter(${i})">Entfernen</button>
|
||||
</div>
|
||||
<div id="test${i}" style="margin-top:10px"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPrinters() {
|
||||
$('printers').innerHTML = printers.map(printerCard).join('') || '<div class="panel muted">Kein Drucker eingerichtet.</div>';
|
||||
document.querySelectorAll('#printers input[data-f]').forEach((el) => el.addEventListener('input', () => {
|
||||
const p = printers[el.dataset.i];
|
||||
p[el.dataset.f] = el.type === 'checkbox' ? el.checked : el.value;
|
||||
}));
|
||||
}
|
||||
|
||||
function addPrinter() {
|
||||
printers.push({ id: '', name: '', host: '', ssh_port: 22, ssh_user: 'root', cc2_url: 'http://', enabled: true });
|
||||
renderPrinters();
|
||||
}
|
||||
|
||||
function removePrinter(i) {
|
||||
const p = printers[i];
|
||||
if (!confirm(`Drucker „${p.name || 'neu'}“ entfernen?\n\nSeine Spulen werden nach dem Speichern in Spoolman ins Lager gebucht.`)) return;
|
||||
printers.splice(i, 1);
|
||||
renderPrinters();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
current = await api('GET', '/api/settings');
|
||||
for (const f of FIELDS) $(f).value = current[f];
|
||||
$('dry_run').checked = current.dry_run;
|
||||
$('spoolman_fields').checked = current.spoolman_fields;
|
||||
printers = current.printers.map((p) => ({ ...p }));
|
||||
saved = new Set(printers.map((p) => p.id));
|
||||
setMode(current.dry_run);
|
||||
}
|
||||
|
||||
async function saveSettings(ev) {
|
||||
ev.preventDefault();
|
||||
const body = {};
|
||||
async function saveSettings() {
|
||||
const body = { printers };
|
||||
for (const f of FIELDS) body[f] = $(f).value;
|
||||
body.dry_run = $('dry_run').checked;
|
||||
body.spoolman_fields = $('spoolman_fields').checked;
|
||||
if (current.dry_run && !body.dry_run && !confirm(
|
||||
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, setzt Orte in Spoolman und bucht Verbrauch.')) return;
|
||||
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, pflegt Ort und Zusatzfelder in Spoolman und bucht Verbrauch.')) return;
|
||||
try {
|
||||
current = await api('POST', '/api/settings', body);
|
||||
printers = current.printers.map((p) => ({ ...p }));
|
||||
saved = new Set(printers.map((p) => p.id));
|
||||
setMode(current.dry_run);
|
||||
$('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
|
||||
const t = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
|
||||
$('saved1').textContent = t; $('saved2').textContent = t;
|
||||
await loadSSH();
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
@@ -82,21 +124,19 @@ function renderKey(info) {
|
||||
const k = info.key;
|
||||
if (!k) { $('keyinfo').innerHTML = '<span class="tag warn">Kein Schlüssel hinterlegt</span>'; return; }
|
||||
if (k.error) { $('keyinfo').innerHTML = `<span class="tag err">${esc(k.error)}</span>`; return; }
|
||||
const host = $('printer_host').value || '10.10.2.202';
|
||||
$('keyinfo').innerHTML = `<dl class="kv"><dt>Typ</dt><dd>${esc(k.type)}</dd><dt>Fingerprint</dt><dd><code>${esc(k.fingerprint)}</code></dd></dl>
|
||||
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf dem Drucker (öffentlicher Schlüssel, eingeschränkt):</p>
|
||||
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf jedem Drucker (öffentlicher Schlüssel, eingeschränkt):</p>
|
||||
<pre id="authline">command="cat /opt/usr/canvas_record_info.json",from="10.10.6.10",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ${esc(k.public)}</pre>
|
||||
<button style="margin-top:6px" onclick="copyText($('authline').textContent)">Kopieren</button>`;
|
||||
}
|
||||
|
||||
function renderHost(hk) {
|
||||
$('hostkey').innerHTML = hk
|
||||
? `<dl class="kv"><dt>Bestätigter Host-Key</dt><dd><code>${esc(hk.fingerprint)}</code> <span class="muted small">(${esc(hk.type)}, ${esc(hk.host)})</span></dd></dl>`
|
||||
: '<span class="tag warn">Host-Key des Druckers noch nicht bestätigt</span>';
|
||||
async function loadSSH() {
|
||||
const info = await api('GET', '/api/ssh');
|
||||
hostKeys = info.host_keys || {};
|
||||
renderKey(info);
|
||||
renderPrinters();
|
||||
}
|
||||
|
||||
async function loadSSH() { const info = await api('GET', '/api/ssh'); renderKey(info); renderHost(info.host_key); }
|
||||
|
||||
$('keyfile').addEventListener('change', async (ev) => {
|
||||
const f = ev.target.files[0];
|
||||
if (f) $('keytext').value = await f.text();
|
||||
@@ -117,19 +157,18 @@ async function deleteKey() {
|
||||
await api('DELETE', '/api/ssh/key'); loadSSH();
|
||||
}
|
||||
|
||||
async function forgetHost() {
|
||||
if (!confirm('Gespeicherten Host-Key vergessen? Danach muss er beim nächsten Test neu bestätigt werden.')) return;
|
||||
await api('DELETE', '/api/ssh/hostkey'); loadSSH();
|
||||
async function forgetHost(i) {
|
||||
const p = printers[i];
|
||||
if (!confirm(`Host-Key von ${p.name} vergessen? Danach muss er beim nächsten Test neu bestätigt werden.`)) return;
|
||||
await api('DELETE', `/api/printers/${p.id}/ssh/hostkey`); loadSSH();
|
||||
}
|
||||
|
||||
async function testSSH() {
|
||||
$('testresult').innerHTML = '<span class="muted">Verbinde …</span>';
|
||||
async function testSSH(i) {
|
||||
const p = printers[i], out = $('test' + i);
|
||||
out.innerHTML = '<span class="muted">Verbinde …</span>';
|
||||
let r;
|
||||
try { r = await api('POST', '/api/ssh/test'); } catch (e) { $('testresult').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
if (!r.ok) {
|
||||
$('testresult').innerHTML = `<div class="banner err">${esc(r.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
try { r = await api('POST', `/api/printers/${p.id}/ssh/test`); } catch (e) { out.innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
if (!r.ok) { out.innerHTML = `<div class="banner err">${esc(r.error)}</div>`; return; }
|
||||
const rows = r.slots.map((s) => {
|
||||
const ch = s.channel || {};
|
||||
const c = (ch.color || '').replace(/^0x/i, '');
|
||||
@@ -138,16 +177,16 @@ async function testSSH() {
|
||||
}).join('');
|
||||
let host = `<div class="banner ok">Verbunden. Host-Key <code>${esc(r.host_key.fingerprint)}</code> stimmt mit dem gespeicherten überein.</div>`;
|
||||
if (!r.host_key_pinned) {
|
||||
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}).
|
||||
Mit <code>ssh-keygen -lf /etc/dropbear/…</code> bzw. <code>ssh-keyscan</code> vergleichen und bestätigen.
|
||||
<button class="primary" onclick="acceptHost('${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
|
||||
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}). Vergleichen und bestätigen.
|
||||
<button class="primary" onclick="acceptHost(${i}, '${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
|
||||
}
|
||||
$('testresult').innerHTML = host + `<div class="table-wrap"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
out.innerHTML = host + `<div class="table-wrap"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
async function acceptHost(fp) {
|
||||
if (!confirm('Host-Key ' + fp + ' als vertrauenswürdig speichern?')) return;
|
||||
try { const r = await api('POST', '/api/ssh/hostkey', { fingerprint: fp }); renderHost(r.host_key); testSSH(); }
|
||||
async function acceptHost(i, fp) {
|
||||
const p = printers[i];
|
||||
if (!confirm(`Host-Key ${fp} für ${p.name} als vertrauenswürdig speichern?`)) return;
|
||||
try { await api('POST', `/api/printers/${p.id}/ssh/hostkey`, { fingerprint: fp }); await loadSSH(); testSSH(i); }
|
||||
catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
|
||||
+30
-17
@@ -2,8 +2,7 @@
|
||||
{% block title %}CC2 Bridge – Status{% endblock %}
|
||||
{% block content %}
|
||||
<div id="banners"></div>
|
||||
<h2>Canvas-Slots</h2>
|
||||
<div class="grid" id="slots"><div class="panel muted">Lade …</div></div>
|
||||
<div id="printers"><div class="panel muted">Lade …</div></div>
|
||||
<p class="small muted" id="lastpoll"></p>
|
||||
<h2>Protokoll</h2>
|
||||
<div class="panel table-wrap">
|
||||
@@ -21,7 +20,7 @@ function colorOf(ch) {
|
||||
return /^[0-9a-f]{6}$/i.test(c) ? c : null;
|
||||
}
|
||||
|
||||
function slotCard(s) {
|
||||
function slotCard(p, s) {
|
||||
const ch = s.record.channel || {};
|
||||
const tray = TRAY[s.tray_status];
|
||||
const col = colorOf(ch);
|
||||
@@ -37,9 +36,11 @@ function slotCard(s) {
|
||||
: (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);
|
||||
const lb = s.last_booking;
|
||||
return `<div class="panel">
|
||||
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
|
||||
<p class="small muted" style="margin:-4px 0 8px">Ort in Spoolman: ${esc(s.location)}</p>
|
||||
<dl class="kv">
|
||||
<dt>Tag</dt><dd>${tag}</dd>
|
||||
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd>
|
||||
@@ -48,25 +49,30 @@ function slotCard(s) {
|
||||
<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>
|
||||
</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>` : ''}
|
||||
${m ? `<div class="row" style="margin-top:10px">
|
||||
<button onclick="slotAction(${s.slot}, 'reassign')">Zuordnung erneut senden</button>
|
||||
<button class="danger" onclick="slotAction(${s.slot}, 'release')">Aufheben</button></div>` : ''}
|
||||
<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>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function slotAction(slot, action) {
|
||||
let NAMES = {};
|
||||
async function slotAction(pid, slot, action) {
|
||||
const name = NAMES[pid] || pid;
|
||||
const text = action === 'release'
|
||||
? `Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
|
||||
: `Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
|
||||
? `${name}: Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
|
||||
: `${name}: Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
|
||||
if (!confirm(text)) return;
|
||||
try { await api('POST', `/api/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
|
||||
try { await api('POST', `/api/printers/${pid}/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
|
||||
catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function enableCC2() {
|
||||
if (!confirm('Spulenverwaltung in CC2 Control einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.')) return;
|
||||
async function enableCC2(pid) {
|
||||
const name = NAMES[pid] || pid;
|
||||
if (!confirm(`Spulenverwaltung in CC2 Control von ${name} einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.`)) return;
|
||||
try {
|
||||
const r = await api('POST', '/api/cc2/enable', { confirm: true });
|
||||
const r = await api('POST', `/api/printers/${pid}/cc2/enable`, { confirm: true });
|
||||
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
|
||||
setTimeout(load, 1500);
|
||||
} catch (e) { toast(e.message); }
|
||||
@@ -79,12 +85,19 @@ async function load() {
|
||||
const b = [];
|
||||
if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>');
|
||||
if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>');
|
||||
else if (!st.host_key) b.push('<div class="banner warn">Host-Key des Druckers noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
|
||||
if (st.cc2_enabled === false) b.push('<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2()">Einschalten …</button></div>');
|
||||
if (st.job_active) b.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
|
||||
for (const [k, v] of Object.entries(st.errors || {})) if (v) b.push(`<div class="banner err">${esc(v)}</div>`);
|
||||
if (st.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</div>`);
|
||||
$('banners').innerHTML = b.join('');
|
||||
if (st.slots) $('slots').innerHTML = st.slots.map(slotCard).join('');
|
||||
NAMES = Object.fromEntries((st.printers || []).map((p) => [p.id, p.name]));
|
||||
if (st.printers) $('printers').innerHTML = st.printers.map((p) => {
|
||||
const pb = [];
|
||||
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>');
|
||||
else if (st.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
|
||||
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`);
|
||||
if (p.job_active) pb.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
|
||||
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`);
|
||||
return `<h2>${esc(p.name)} <span class="small muted" style="font-weight:400">${esc(p.host)}</span></h2>${pb.join('')}
|
||||
<div class="grid">${p.slots.map((s) => slotCard(p, s)).join('')}</div>`;
|
||||
}).join('') || '<div class="panel muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>';
|
||||
$('lastpoll').textContent = st.last_poll ? 'Letzte Abfrage: ' + fmtTime(st.last_poll) : 'Noch keine Abfrage';
|
||||
$('log').innerHTML = (st.log || []).map((e) => {
|
||||
const l = LEVEL[e.level] || [e.level, ''];
|
||||
|
||||
+48
-33
@@ -36,7 +36,7 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
keys = bridge.keys if bridge else KeyStore(data_dir)
|
||||
bridge = bridge or Bridge(data_dir, settings, keys)
|
||||
stop = threading.Event()
|
||||
tested_host_keys: dict[str, object] = {} # fingerprint -> key from the last connection test
|
||||
tested_host_keys: dict[str, dict] = {} # printer id -> host key from its last connection test
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
@@ -105,20 +105,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
def status():
|
||||
return {**bridge.snapshot, "dry_run": settings.get().dry_run, "log": bridge.log.recent(200)}
|
||||
|
||||
@app.post("/api/cc2/enable")
|
||||
async def cc2_enable(request: Request):
|
||||
def printer_or_404(pid: str):
|
||||
p = settings.get().printer(pid)
|
||||
if p is None:
|
||||
raise HTTPException(404, "Unbekannter Drucker")
|
||||
return p
|
||||
|
||||
def close_readers():
|
||||
for w in list(bridge.workers.values()):
|
||||
w.reader.close()
|
||||
|
||||
@app.post("/api/printers/{pid}/cc2/enable")
|
||||
async def cc2_enable(pid: str, request: Request):
|
||||
printer_or_404(pid)
|
||||
body = await _json(request)
|
||||
if body.get("confirm") is not True:
|
||||
raise HTTPException(400, "Bestätigung fehlt")
|
||||
bridge.commands.put(("enable", None))
|
||||
bridge.commands.put(("enable", pid, None))
|
||||
bridge.wake.set()
|
||||
return {"ok": True, "dry_run": settings.get().dry_run}
|
||||
|
||||
@app.post("/api/slots/{slot}/{action}")
|
||||
def slot_action(slot: int, action: str):
|
||||
@app.post("/api/printers/{pid}/slots/{slot}/{action}")
|
||||
def slot_action(pid: str, slot: int, action: str):
|
||||
printer_or_404(pid)
|
||||
if slot not in range(4) or action not in ("reassign", "release"):
|
||||
raise HTTPException(404)
|
||||
bridge.commands.put((action, slot))
|
||||
bridge.commands.put((action, pid, slot))
|
||||
bridge.wake.set()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -131,11 +143,14 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
@app.post("/api/settings")
|
||||
async def post_settings(request: Request):
|
||||
body = await _json(request)
|
||||
before = {p.id for p in settings.get().printers}
|
||||
allowed = set(settings.get().to_dict())
|
||||
try:
|
||||
s = settings.save({k: v for k, v in body.items() if k in allowed})
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
for pid in before - {p.id for p in s.printers}:
|
||||
keys.forget_host_key(pid)
|
||||
bridge.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
|
||||
bridge.wake.set()
|
||||
return s.to_dict()
|
||||
@@ -144,7 +159,8 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
|
||||
@app.get("/api/ssh")
|
||||
def ssh_info():
|
||||
return {"key": keys.key_info(), "host_key": keys.host_key()}
|
||||
return {"key": keys.key_info(),
|
||||
"host_keys": {p.id: keys.host_key(p.id) for p in settings.get().printers}}
|
||||
|
||||
@app.post("/api/ssh/key")
|
||||
async def ssh_key(request: Request):
|
||||
@@ -157,22 +173,20 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
except SSHError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
bridge.log.add("info", "SSH-Schlüssel gespeichert")
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
close_readers()
|
||||
return {"key": keys.key_info()}
|
||||
|
||||
@app.delete("/api/ssh/key")
|
||||
def ssh_key_delete():
|
||||
keys.delete_key()
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
close_readers()
|
||||
bridge.log.add("info", "SSH-Schlüssel gelöscht")
|
||||
return {"key": None}
|
||||
|
||||
@app.post("/api/ssh/test")
|
||||
def ssh_test():
|
||||
s = settings.get()
|
||||
reader = CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user)
|
||||
@app.post("/api/printers/{pid}/ssh/test")
|
||||
def ssh_test(pid: str):
|
||||
p = printer_or_404(pid)
|
||||
reader = CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user)
|
||||
try:
|
||||
result = reader.test()
|
||||
except HostKeyMismatch as e:
|
||||
@@ -182,31 +196,32 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
|
||||
return {"ok": False, "error": str(e)}
|
||||
hk = result["host_key"]
|
||||
if not result["host_key_pinned"]:
|
||||
tested_host_keys.clear()
|
||||
tested_host_keys[hk["fingerprint"]] = hk
|
||||
slots = tagcodec.parse_record(result["record"], s.id_offset)
|
||||
tested_host_keys.pop(pid, None)
|
||||
tested_host_keys[pid] = hk
|
||||
slots = tagcodec.parse_record(result["record"], settings.get().id_offset)
|
||||
return {"ok": True, "host_key": {"fingerprint": hk["fingerprint"], "type": hk["type"]},
|
||||
"host_key_pinned": result["host_key_pinned"], "slots": slots}
|
||||
|
||||
@app.post("/api/ssh/hostkey")
|
||||
async def ssh_hostkey(request: Request):
|
||||
@app.post("/api/printers/{pid}/ssh/hostkey")
|
||||
async def ssh_hostkey(pid: str, request: Request):
|
||||
p = printer_or_404(pid)
|
||||
body = await _json(request)
|
||||
hk = tested_host_keys.get(body.get("fingerprint"))
|
||||
if not hk:
|
||||
hk = tested_host_keys.get(pid)
|
||||
if not hk or hk["fingerprint"] != body.get("fingerprint"):
|
||||
raise HTTPException(400, "Bitte zuerst „Verbindung testen“ und dann diesen Fingerprint bestätigen")
|
||||
key = paramiko.PKey.from_type_string(hk["type"], base64.b64decode(hk["key"]))
|
||||
keys.pin_host_key(settings.get().printer_host, key)
|
||||
tested_host_keys.clear()
|
||||
bridge.log.add("info", f"Host-Key des Druckers bestätigt ({hk['fingerprint']})")
|
||||
keys.pin_host_key(pid, p.host, key)
|
||||
tested_host_keys.pop(pid, None)
|
||||
bridge.log.add("info", f"{p.name}: Host-Key bestätigt ({hk['fingerprint']})")
|
||||
bridge.wake.set()
|
||||
return {"host_key": keys.host_key()}
|
||||
return {"host_key": keys.host_key(pid)}
|
||||
|
||||
@app.delete("/api/ssh/hostkey")
|
||||
def ssh_hostkey_delete():
|
||||
keys.forget_host_key()
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
bridge.log.add("info", "Gespeicherter Host-Key des Druckers gelöscht")
|
||||
@app.delete("/api/printers/{pid}/ssh/hostkey")
|
||||
def ssh_hostkey_delete(pid: str):
|
||||
p = printer_or_404(pid)
|
||||
keys.forget_host_key(pid)
|
||||
close_readers()
|
||||
bridge.log.add("info", f"{p.name}: gespeicherter Host-Key gelöscht")
|
||||
return {"host_key": None}
|
||||
|
||||
# ---------- tag generator ----------
|
||||
|
||||
Reference in new issue
Block a user