CC2 ↔ Spoolman bridge with NFC tag generator
- Reads /opt/usr/canvas_record_info.json over SSH (pinned host key, restricted key), decodes Spoolman spool ids from the tag weight - Mirrors spools into CC2 Control's spool library and assigns slots, keeps its own slot mapping, sets Spoolman locations - Books the drop of CC2 Control's measured remaining weight to Spoolman after each print, idempotent across restarts and interrupted bookings - Dry-run by default; web UI for settings, SSH key, status and log - Tag generator for NFC Tools command chains (NTAG213/215/216) - Tests, mock environment, Dockerfile, Gitea Actions CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
commit
bf32667f77
32 files changed
+4216
No files matched your search
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitea
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.pytest_cache
|
||||
data
|
||||
.env
|
||||
*.md
|
||||
!README.md
|
||||
docker-compose.yml
|
||||
tools
|
||||
@@ -0,0 +1,17 @@
|
||||
# Startwerte. Nach dem ersten Start gelten die Einstellungen aus der Oberfläche (/data/settings.json).
|
||||
PRINTER_HOST=10.10.2.202
|
||||
SSH_PORT=22
|
||||
SSH_USER=root
|
||||
CC2_URL=http://10.10.2.202:8081
|
||||
SPOOLMAN_URL=http://10.10.6.10:7912
|
||||
ID_OFFSET=10000
|
||||
POLL_INTERVAL=5
|
||||
# Dry-Run ist Standard; ausschalten nur über die Oberfläche.
|
||||
DRY_RUN=true
|
||||
|
||||
# Optionaler Basic-Auth-Schutz der Oberfläche (beide setzen, um ihn zu aktivieren):
|
||||
# BRIDGE_USER=admin
|
||||
# BRIDGE_PASSWORD=ein-langes-passwort
|
||||
|
||||
# PORT=5066
|
||||
# LOG_LEVEL=INFO
|
||||
@@ -0,0 +1,97 @@
|
||||
name: CI
|
||||
|
||||
# Jeder Push und jeder Pull Request: Tests, dann Image-Build.
|
||||
# Veröffentlicht wird nur auf main und nur, wenn die Tests grün sind (`needs:`):
|
||||
#
|
||||
# Push auf main → :latest, :sha-<kurz>
|
||||
#
|
||||
# Actions auf Commit-SHAs festgelegt (Kommentar = Version), wie in den anderen Repos.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Gitea hat keinen Artefakt-Dienst für Build-Records → Upload/Summary abschalten.
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
IMAGE: git.menzel.center/menzeljonas/cc2-spoolman-bridge
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12" # wie im Dockerfile
|
||||
|
||||
- name: Abhängigkeiten installieren
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: pytest
|
||||
run: python -m pytest
|
||||
|
||||
image:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Veröffentlichen?
|
||||
id: publish
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ]]; then
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "short=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: An der Registry anmelden
|
||||
if: steps.publish.outputs.push == 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: git.menzel.center
|
||||
username: ${{ gitea.actor }}
|
||||
# Bevorzugt das Repo-Secret REGISTRY_TOKEN (Token mit package: write);
|
||||
# fehlt es, wird der automatische Actions-Token verwendet.
|
||||
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Bauen (und auf main hochladen)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
target: runtime
|
||||
platforms: linux/amd64
|
||||
push: ${{ steps.publish.outputs.push == 'true' }}
|
||||
tags: |
|
||||
${{ env.IMAGE }}:latest
|
||||
${{ env.IMAGE }}:sha-${{ steps.publish.outputs.short }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.publish.outputs.short }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=cc2-spoolman-bridge
|
||||
org.opencontainers.image.source=https://git.menzel.center/menzeljonas/cc2-spoolman-bridge
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
# Registry-Cache: der Gitea-Runner hat keinen Actions-Cache-Dienst.
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||
cache-to: ${{ steps.publish.outputs.push == 'true' && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE) || '' }}
|
||||
provenance: false
|
||||
|
||||
- name: Hinweis bei fehlgeschlagener Anmeldung
|
||||
if: failure() && steps.publish.outputs.push == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "Build oder Push nach git.menzel.center fehlgeschlagen."
|
||||
echo ""
|
||||
echo "Bei 'unauthorized'/'denied' ein Repo-Secret **REGISTRY_TOKEN** anlegen:"
|
||||
echo "1. Benutzereinstellungen → Anwendungen → Neuen Token erzeugen, *package*: Lesen und Schreiben"
|
||||
echo "2. Repository → Einstellungen → Actions → Secrets → REGISTRY_TOKEN"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
.env
|
||||
data/
|
||||
*.key
|
||||
id_ed25519*
|
||||
id_rsa*
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.12-slim AS base
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
FROM base AS test
|
||||
COPY requirements-dev.txt pytest.ini ./
|
||||
RUN pip install -r requirements-dev.txt
|
||||
COPY app ./app
|
||||
COPY tests ./tests
|
||||
RUN python -m pytest
|
||||
|
||||
FROM base AS runtime
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION} DATA_DIR=/data PORT=5066
|
||||
COPY app ./app
|
||||
RUN useradd --uid 1000 --create-home bridge && mkdir -p /data && chown bridge:bridge /data
|
||||
USER bridge
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 5066
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/healthz' % os.environ.get('PORT','5066'), timeout=4)"
|
||||
CMD ["python", "-m", "app.web"]
|
||||
@@ -0,0 +1,155 @@
|
||||
# cc2-spoolman-bridge
|
||||
|
||||
Bridge zwischen der Canvas des **Elegoo Centauri Carbon 2** (Community-Firmware, CC2 Control) und **Spoolman**,
|
||||
mit Weboberfläche und **NFC-Tag-Generator**.
|
||||
|
||||
- Liegt eine Spule mit eigenem Tag in der Canvas, erkennt die Bridge die Spoolman-Spule am Tag, legt sie in
|
||||
CC2 Control an (bzw. findet sie wieder), weist sie dem Slot zu und setzt in Spoolman den Ort („CC2 Slot 1–4“,
|
||||
beim Entnehmen „Lager“).
|
||||
- Den von CC2 Control gemessenen Verbrauch bucht sie nach jedem Druck auf genau diese Spoolman-Spule.
|
||||
- Der Tag-Generator gibt für jede Spoolman-Spule die vollständige Befehlskette zum Beschreiben eines leeren NTAG213 aus.
|
||||
|
||||
## Was die Bridge darf und was nicht
|
||||
|
||||
| System | Zugriff |
|
||||
|---|---|
|
||||
| Drucker | **nur lesen**: `cat /opt/usr/canvas_record_info.json` per SSH. Kein MQTT, keine Druckerbefehle, keine Registrierung als MQTT-Client. |
|
||||
| CC2 Control | Spulenverwaltung: `GET /api/spools`, `POST /api/spools/save`, `/assign`, `/enable` (nur nach Bestätigung) |
|
||||
| Spoolman | `GET /spool`, `PATCH /spool/{id}` (`location`), `PUT /spool/{id}/use` |
|
||||
|
||||
**Dry-Run ist Standard.** Solange er aktiv ist, werden alle Schreibaktionen nur als „würde …“ protokolliert.
|
||||
Ausschalten unter *Einstellungen* (mit Rückfrage).
|
||||
|
||||
## Einrichtung
|
||||
|
||||
### 1. SSH-Schlüssel auf dem Drucker einschränken
|
||||
|
||||
Eigenen Schlüssel erzeugen (ohne Passphrase):
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -N "" -C cc2-spoolman-bridge -f cc2_bridge_key
|
||||
```
|
||||
|
||||
Auf dem Drucker in `/root/.ssh/authorized_keys` (bzw. dem Pfad, den dein SSH-Server nutzt) eintragen. Jede
|
||||
Ausführung liefert dann nur noch den Canvas-Datensatz, egal welcher Befehl gesendet wird, und nur von `10.10.6.10`:
|
||||
|
||||
```
|
||||
command="cat /opt/usr/canvas_record_info.json",from="10.10.6.10",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA… cc2-spoolman-bridge
|
||||
```
|
||||
|
||||
Die Oberfläche zeigt diese Zeile mit dem passenden öffentlichen Schlüssel nach dem Hochladen an. Container auf
|
||||
`10.10.6.10` verbinden sich über Docker-NAT mit der Host-IP, `from="10.10.6.10"` passt also.
|
||||
|
||||
### 2. Container starten (Docker-Host 10.10.6.10)
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/cc2-spoolman-bridge && cd /opt/cc2-spoolman-bridge
|
||||
# docker-compose.yml und .env.example aus dem Repo hierher kopieren
|
||||
cp .env.example .env
|
||||
mkdir -p data && sudo chown 1000:1000 data
|
||||
# privates Image: einmalig mit einem Gitea-Token (package: Lesen) anmelden
|
||||
docker login git.menzel.center -u menzeljonas
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Oberfläche: `http://10.10.6.10:5066`
|
||||
|
||||
### 3. In der Oberfläche
|
||||
|
||||
1. *Einstellungen* prüfen (Drucker-IP, CC2-Control- und Spoolman-URL, ID-Offset 10000).
|
||||
2. Privaten Schlüssel einfügen oder hochladen und speichern. Er liegt dann mit Rechten 600 in
|
||||
`/data/ssh/id_key`; die Oberfläche zeigt nur Fingerprint und öffentlichen Schlüssel.
|
||||
3. **Verbindung testen**: zeigt die 4 Slots und den Host-Key-Fingerprint des Druckers. Fingerprint vergleichen und
|
||||
bestätigen. Danach wird er bei jeder Verbindung geprüft; ein geänderter Host-Key wird abgelehnt.
|
||||
4. *Status* im Dry-Run beobachten. Wenn die „würde …“-Einträge stimmen, Dry-Run ausschalten.
|
||||
|
||||
### Umgebungsvariablen
|
||||
|
||||
Siehe [`.env.example`](.env.example). `PRINTER_HOST`, `SSH_PORT`, `SSH_USER`, `CC2_URL`, `SPOOLMAN_URL`,
|
||||
`ID_OFFSET`, `POLL_INTERVAL` und `DRY_RUN` sind nur Startwerte, bis die Einstellungen einmal in der Oberfläche
|
||||
gespeichert wurden (`/data/settings.json`). `BRIDGE_USER` und `BRIDGE_PASSWORD` schalten Basic-Auth ein
|
||||
(`/healthz` bleibt offen). `PORT` (Standard 5066), `LOG_LEVEL`.
|
||||
|
||||
## Funktionsweise
|
||||
|
||||
**Tag erkennen.** Die Bridge liest alle ~5 s über eine stehende SSH-Verbindung den Canvas-Datensatz.
|
||||
`channels0`–`channels3` sind Slot 1–4. Ist `weight` ≥ `ID_OFFSET`, ist `weight − ID_OFFSET` die Spoolman-Spulen-ID;
|
||||
kleinere Werte sind echte Gewichte von Original- oder Fremdtags und werden ignoriert. Neu ist ein Tag, wenn sich die ID
|
||||
im Slot gegenüber dem letzten Datensatz ändert.
|
||||
|
||||
**Zuordnung.** Die Bridge speichert Slot ↔ Spule selbst (`/data/state.json`). Fällt `weight` nach einer
|
||||
Bearbeitung am Display auf 1000 zurück, bleibt die Zuordnung. Aufgehoben wird sie nur, wenn
|
||||
- der Slot länger als 20 s leer ist (Tray-Status 0),
|
||||
- CC2 Control die Spule ins Lager legt (Slot ohne Spule und ohne offene Rückfrage),
|
||||
- im Slot ein Tag mit einer anderen ID gescannt wird oder
|
||||
- die Spule in einem anderen Slot gescannt wird (Umzug).
|
||||
|
||||
Weist du eine gespiegelte Spule in CC2 Control von Hand einem belegten Slot zu, übernimmt die Bridge das.
|
||||
|
||||
**CC2 Control.** Gespiegelte Spulen erkennt die Bridge an `note` = `spoolman:<id>`. Fehlt die Spule, legt sie sie
|
||||
mit `name` = Label (`lot_nr`, sonst Hersteller + ID dreistellig), `brand`, `material`, `color`, `diameter`,
|
||||
`density`, `net`, `remaining`, `tare` und `price` aus Spoolman an und weist sie dem Slot zu. Damit ist auch die
|
||||
Rückfrage beantwortet. Passt das Spoolman-Material nicht zum Tag (CC2-Regel: gleich Typ/Name oder Produktlinie wie
|
||||
`PLA+` zu `PLA`), nimmt sie den Untertyp vom Tag, damit CC2 Control nicht erneut fragt.
|
||||
|
||||
**Verbrauch.** CC2 Control zieht den gemessenen Verbrauch vom `remaining` der zugewiesenen Spule ab. (`used` ist in
|
||||
CC2 Control der Zeitstempel der letzten Nutzung, keine Menge.) Die Bridge bucht für jede gespiegelte Spule die
|
||||
Abnahme von `remaining` seit der letzten Buchung per `PUT /spool/{id}/use` nach Spoolman. Das geschieht erst, wenn kein
|
||||
Druck läuft (`job.active`) und `remaining` 60 s unverändert ist. Der gebuchte Stand wird persistent gespeichert.
|
||||
Vor jeder Buchung schreibt die Bridge die Absicht in den Zustand. Bricht eine Buchung ab, prüft sie nach dem
|
||||
Neustart an `used_weight` in Spoolman, ob sie schon angekommen ist, statt doppelt zu buchen. Steigt `remaining`
|
||||
(z. B. durch Nachwiegen in CC2 Control), wird nichts gebucht, nur protokolliert, und der neue Stand gilt als
|
||||
Ausgangspunkt.
|
||||
|
||||
**Spulenverwaltung aus.** Ist sie in CC2 Control ausgeschaltet, zählt CC2 Control keinen Verbrauch. Die Oberfläche
|
||||
zeigt das deutlich an und bietet einen Button zum Einschalten (mit Rückfrage, im Dry-Run nur protokolliert).
|
||||
|
||||
## Tag-Format (Canvas, NTAG213/215/216)
|
||||
|
||||
NFC Tools → *Other* → *Advanced NFC commands*, Befehle `A2:PP:XXXXXXXX` (PP = Seite hex), kommagetrennt.
|
||||
Seiten 0–3 werden nie geschrieben. **Nie „Lock tag“ oder „Set password“ verwenden.**
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| 04–09 | NDEF-URL (`https://www.elegoo.com`; der Drucker ignoriert sie) |
|
||||
| 10 | `36` + Herstellercode `EEEEEE` |
|
||||
| 11 | `EE` + Filament-Code (2 Byte) + `00` |
|
||||
| 12 | Material (4 Byte) |
|
||||
| 13 | Untertyp (2 Byte) + `0000`; danach wählt der Elegoo Slicer das Profil |
|
||||
| 14 | Farbe RRGGBB + Finish-Byte (`FF`) |
|
||||
| 15 | Düse min / max (je 2 Byte, °C) |
|
||||
| 16 | `00000000` |
|
||||
| 17 | Durchmesser in 1/100 mm + **Gewicht = Spulen-ID + 10000** |
|
||||
| 18 | `0036C800` |
|
||||
|
||||
Die Farbe wird auf die nächste Farbe der Display-Palette vorgeschlagen (Abstand in CIELAB; gesättigtes Blau wird auf
|
||||
`2850DF` abgebildet). Material und Untertyp werden aus dem Spoolman-Material vorgeschlagen; alles ist änderbar.
|
||||
Optional kann statt der Elegoo-URL ein Link auf die Spule in Spoolman auf den Tag (noch nicht am Drucker getestet).
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
docker build --target test . # Tests (pytest) im Container
|
||||
docker build -t cc2-spoolman-bridge:dev . # Image
|
||||
python tools/mock_env.py --host-header mock:8081 # Mock für Drucker (SSH :2222), CC2 Control, Spoolman
|
||||
```
|
||||
|
||||
Die Tests decken ab: Tag-Kodierung gegen die verifizierte Vorlage, ID-Dekodierung, Palette und Materialvorschlag,
|
||||
Zuordnung bei verschwindender ID, Verbrauchs-Delta und Idempotenz (auch nach Abbruch), Dry-Run, Request-Format für
|
||||
CC2 Control (Host-Header, kein Origin, `X-CC2-Request`, `key=value`-Body, `enable` als nacktes `on`/`off`) und SSH
|
||||
mit Host-Key-Prüfung gegen einen lokalen SSH-Server.
|
||||
|
||||
CI (Gitea Actions, `.gitea/workflows/ci.yml`): pytest → Build `linux/amd64` → auf `main` Push nach
|
||||
`git.menzel.center/menzeljonas/cc2-spoolman-bridge` mit den Tags `latest` und `sha-<commit>`. Für den Push braucht
|
||||
das Repo ggf. ein Actions-Secret `REGISTRY_TOKEN` (Token mit *package*: Lesen und Schreiben).
|
||||
|
||||
## Quellen und Lizenzen
|
||||
|
||||
- Material- und Untertyp-Codes (`app/materials.py`, Lizenztext in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)) aus
|
||||
[cjtallman/canvas-rfid-tool](https://github.com/cjtallman/canvas-rfid-tool) (`index.html`), **MIT License**,
|
||||
Copyright (c) cjtallman. Dort wiederum nach
|
||||
[DnG-Crafts/ELG-RFID](https://github.com/DnG-Crafts/ELG-RFID) und
|
||||
[Savion/elegoo-rfid-editor](https://github.com/Savion/elegoo-rfid-editor).
|
||||
- Spool-API und HTTP-Guard von CC2 Control:
|
||||
[Neme77/centauri-carbon-2-community](https://github.com/Neme77/centauri-carbon-2-community)
|
||||
(`cc2-control/docs/API.md`, Abschnitt „Spool tracking“, und `src/spools.h`, Branch `develop`).
|
||||
@@ -0,0 +1,30 @@
|
||||
# Third-party notices
|
||||
|
||||
## cjtallman/canvas-rfid-tool
|
||||
|
||||
`app/materials.py` enthält die Tabellen `MATERIALS` und `SUBTYPES` aus
|
||||
https://github.com/cjtallman/canvas-rfid-tool (`index.html`).
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 cjtallman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
Whitespace-only changes.
+614
@@ -0,0 +1,614 @@
|
||||
"""Bridge between the Canvas record (SSH), CC2 Control's spool library and Spoolman.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from . import tagcodec
|
||||
from .cc2control import CC2Control, CC2Error, slot_spool_id, spoolman_id_of, tray_statuses
|
||||
from .spoolman import Spoolman, SpoolmanError, SpoolmanNotFound, color_hex, label, summary, vendor_name
|
||||
from .sshsource import CanvasRecordReader, KeyStore, SSHError
|
||||
from .storage import Settings, SettingsStore, StateStore
|
||||
|
||||
log = logging.getLogger("bridge")
|
||||
|
||||
MIN_BOOKING = 0.05 # grams; smaller changes accumulate until they reach this
|
||||
SPOOL_CACHE_SECONDS = 300
|
||||
LOG_KEEP = 1000
|
||||
SOURCE_NAMES = {"ssh": "Drucker (SSH)", "cc2": "CC2 Control", "spoolman": "Spoolman"}
|
||||
|
||||
|
||||
class ActionLog:
|
||||
def __init__(self, data_dir: Path, clock=time.time):
|
||||
self.path = data_dir / "actions.log"
|
||||
self.clock = clock
|
||||
self.entries: deque[dict] = deque(maxlen=300)
|
||||
self._lock = threading.Lock()
|
||||
try:
|
||||
lines = self.path.read_text().splitlines()[-LOG_KEEP:]
|
||||
self.path.write_text("".join(line + "\n" for line in lines))
|
||||
for line in lines[-300:]:
|
||||
try:
|
||||
self.entries.append(json.loads(line))
|
||||
except ValueError:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def add(self, level: str, msg: str) -> None:
|
||||
entry = {"time": self.clock(), "level": level, "msg": msg}
|
||||
getattr(log, {"error": "error", "warn": "warning"}.get(level, "info"))("%s", msg)
|
||||
with self._lock:
|
||||
self.entries.append(entry)
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def recent(self, n: int = 200) -> list[dict]:
|
||||
with self._lock:
|
||||
return list(self.entries)[-n:][::-1]
|
||||
|
||||
|
||||
def material_fits(material: str, tray_type: str, tray_name: str) -> bool:
|
||||
"""CC2 Control's rule: same as the tray's type or name, or a product line of the type."""
|
||||
m, t, n = material.upper().strip(), tray_type.upper().strip(), tray_name.upper().strip()
|
||||
if not m:
|
||||
return False
|
||||
if m in (t, n):
|
||||
return True
|
||||
return bool(t) and m.startswith(t) and len(m) > len(t) and m[len(t)] in " -+"
|
||||
|
||||
|
||||
def cc2_spool_fields(sm: dict, channel: dict | None) -> tuple[dict, list[str]]:
|
||||
"""Fields for /api/spools/save from a Spoolman spool (and the tray it sits in)."""
|
||||
notes = []
|
||||
fil = sm.get("filament") or {}
|
||||
channel = channel or {}
|
||||
tray_type = str(channel.get("type") or "")
|
||||
tray_name = str(channel.get("detailed_type") or "")
|
||||
material = (fil.get("material") or "").strip()
|
||||
if tray_type and not material_fits(material, tray_type, tray_name):
|
||||
new = tray_name or tray_type
|
||||
notes.append(f"Material {material or '(leer)'} passt nicht zum Tag ({tray_type}); verwende {new}")
|
||||
material = new
|
||||
if not material:
|
||||
material = "PLA"
|
||||
color = color_hex(sm)
|
||||
if color is None:
|
||||
color = tagcodec.normalize_hex_color(str(channel.get("color") or "").replace("0x", "")) or "000000"
|
||||
notes.append(f"Spoolman-Spule hat keine Farbe; verwende #{color}")
|
||||
fields: dict = {
|
||||
"name": label(sm),
|
||||
"brand": vendor_name(sm),
|
||||
"material": material,
|
||||
"color": "#" + color,
|
||||
"note": f"spoolman:{sm['id']}",
|
||||
}
|
||||
|
||||
def num(key, value, lo, hi):
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if lo <= v <= hi:
|
||||
fields[key] = round(v, 3)
|
||||
else:
|
||||
notes.append(f"{key}={v} außerhalb {lo}-{hi}, weggelassen")
|
||||
|
||||
num("diameter", fil.get("diameter"), 1, 3.5)
|
||||
num("density", fil.get("density"), 0.5, 3)
|
||||
num("net", sm.get("initial_weight") or fil.get("weight"), 1, 20000)
|
||||
if sm.get("remaining_weight") is not None:
|
||||
num("remaining", max(0.0, float(sm["remaining_weight"])), 0, 20000)
|
||||
tare = sm.get("spool_weight") or fil.get("spool_weight")
|
||||
if tare:
|
||||
num("tare", tare, 0, 5000)
|
||||
price = sm.get("price") or fil.get("price")
|
||||
if price:
|
||||
num("price", price, 0, 1e7)
|
||||
return fields, notes
|
||||
|
||||
|
||||
class Bridge:
|
||||
def __init__(self, data_dir: Path, settings: SettingsStore, keys: KeyStore, *,
|
||||
reader_factory=None, cc2_factory=None, spoolman_factory=None, clock=time.time):
|
||||
self.data_dir = data_dir
|
||||
self.settings_store = settings
|
||||
self.keys = keys
|
||||
self.clock = clock
|
||||
self.state = StateStore(data_dir)
|
||||
self.log = ActionLog(data_dir, clock)
|
||||
self.reader_factory = reader_factory or (
|
||||
lambda s: CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.ssh_user))
|
||||
self.cc2_factory = cc2_factory or (lambda s: CC2Control(s.cc2_url))
|
||||
self.spoolman_factory = spoolman_factory or (lambda s: Spoolman(s.spoolman_url))
|
||||
self._clients_for: dict | None = None
|
||||
self.reader = self.cc2 = self.spoolman = None
|
||||
self.lock = threading.RLock()
|
||||
self.commands: queue.Queue = queue.Queue()
|
||||
self.wake = threading.Event()
|
||||
self.errors: dict[str, str | None] = {"ssh": None, "cc2": None, "spoolman": None}
|
||||
self.empty_since: dict[int, float] = {}
|
||||
self.remaining_seen: dict[str, tuple[float, float]] = {} # cc2 id -> (value, since)
|
||||
self.dry_logged: dict[str, str] = {}
|
||||
self.spool_cache: dict[int, tuple[float, dict]] = {}
|
||||
self.record_slots: list[dict] = []
|
||||
self.tray_status: dict[int, int] = {}
|
||||
self.snapshot: dict = {"last_poll": None}
|
||||
self.settings: Settings = settings.get()
|
||||
|
||||
# ---------- infrastructure ----------
|
||||
|
||||
def _clients(self) -> None:
|
||||
s = self.settings
|
||||
key = {k: getattr(s, k) for k in ("printer_host", "ssh_port", "ssh_user", "cc2_url", "spoolman_url")}
|
||||
if key == self._clients_for:
|
||||
return
|
||||
for c in (self.reader, self.cc2, self.spoolman):
|
||||
if c is not None:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.reader = self.reader_factory(s)
|
||||
self.cc2 = self.cc2_factory(s)
|
||||
self.spoolman = self.spoolman_factory(s)
|
||||
self._clients_for = key
|
||||
|
||||
def _error(self, source: str, msg: str | None) -> None:
|
||||
if msg != self.errors.get(source):
|
||||
if msg:
|
||||
self.log.add("error", msg)
|
||||
elif self.errors.get(source):
|
||||
self.log.add("info", f"{SOURCE_NAMES[source]} wieder erreichbar")
|
||||
self.errors[source] = msg
|
||||
|
||||
def _write(self, key: str, msg: str, fn):
|
||||
"""Run a write, or in dry-run only log it once per key/message."""
|
||||
if self.settings.dry_run:
|
||||
if self.dry_logged.get(key) != msg:
|
||||
self.dry_logged[key] = msg
|
||||
self.log.add("dry", f"würde {msg}")
|
||||
return None, False
|
||||
self.dry_logged.pop(key, None)
|
||||
result = fn()
|
||||
self.log.add("info", f"erledigt: {msg}")
|
||||
return result, True
|
||||
|
||||
def run(self, stop: threading.Event) -> None:
|
||||
while not stop.is_set():
|
||||
started = self.clock()
|
||||
try:
|
||||
self.poll_once()
|
||||
except Exception: # never let the loop die
|
||||
log.exception("poll failed")
|
||||
self.log.add("error", "Interner Fehler im Abfragezyklus (Details im Container-Log)")
|
||||
interval = self.settings.poll_interval
|
||||
self.wake.wait(max(0.5, interval - (self.clock() - started)))
|
||||
self.wake.clear()
|
||||
|
||||
# ---------- one cycle ----------
|
||||
|
||||
def poll_once(self) -> None:
|
||||
with self.lock:
|
||||
self.settings = self.settings_store.get()
|
||||
self._clients()
|
||||
self._run_commands()
|
||||
record = cc2 = None
|
||||
try:
|
||||
record = self.reader.read()
|
||||
self._error("ssh", None)
|
||||
except SSHError as e:
|
||||
self._error("ssh", str(e))
|
||||
try:
|
||||
cc2 = self.cc2.spools()
|
||||
self._error("cc2", None)
|
||||
except CC2Error as e:
|
||||
self._error("cc2", str(e))
|
||||
|
||||
if record is not None:
|
||||
self._process_record(record)
|
||||
self._process_scans()
|
||||
if cc2 is not None:
|
||||
self._process_trays(cc2)
|
||||
self._process_cc2_slots(cc2)
|
||||
self._reconcile_cc2(cc2)
|
||||
self._consumption(cc2)
|
||||
self._reconcile_locations()
|
||||
self._refresh_spool_cache()
|
||||
self.state.save()
|
||||
self._build_snapshot(record, cc2)
|
||||
|
||||
def _run_commands(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
cmd, arg = self.commands.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
try:
|
||||
if cmd == "enable":
|
||||
self._write("enable", "Spulenverwaltung in CC2 Control einschalten", lambda: self.cc2.enable(True))
|
||||
elif cmd == "reassign":
|
||||
m = self.state.data["slots"].get(str(arg))
|
||||
if m:
|
||||
m["assigned"] = False
|
||||
self.dry_logged.pop(f"assign:{arg}", None)
|
||||
self.log.add("info", f"Slot {arg + 1}: Zuordnung wird erneut an CC2 Control gesendet")
|
||||
elif cmd == "release":
|
||||
if str(arg) in self.state.data["slots"]:
|
||||
self._release(arg, "manuell aufgehoben")
|
||||
except (CC2Error, SpoolmanError) as e:
|
||||
self.log.add("error", str(e))
|
||||
|
||||
# ---------- record / scans ----------
|
||||
|
||||
def _process_record(self, record: dict) -> None:
|
||||
offset = self.settings.id_offset
|
||||
slots = tagcodec.parse_record(record, offset)
|
||||
prev_record = self.state.data.get("last_record")
|
||||
prev = tagcodec.parse_record(prev_record, offset) if prev_record else None
|
||||
mappings = self.state.data["slots"]
|
||||
for s in slots:
|
||||
n, tid = s["slot"], s["spool_id"]
|
||||
if tid is None:
|
||||
continue
|
||||
if prev is not None:
|
||||
is_new = tid != prev[n]["spool_id"]
|
||||
else:
|
||||
m = mappings.get(str(n))
|
||||
is_new = m is None or m["spool"] != tid
|
||||
if is_new:
|
||||
self.state.data.setdefault("scans", []).append({"slot": n, "spool": tid, "time": self.clock()})
|
||||
self.log.add("info", f"Slot {n + 1}: Tag mit Spoolman-ID {tid} erkannt")
|
||||
self.state.data["last_record"] = record
|
||||
self.record_slots = slots
|
||||
|
||||
def _process_scans(self) -> None:
|
||||
pending = []
|
||||
for scan in self.state.data.get("scans", []):
|
||||
n, tid = scan["slot"], scan["spool"]
|
||||
try:
|
||||
sm = self.spoolman.spool(tid)
|
||||
self._error("spoolman", None)
|
||||
except SpoolmanNotFound:
|
||||
self.log.add("warn", f"Slot {n + 1}: Spoolman-Spule {tid} existiert nicht; nichts geändert")
|
||||
continue
|
||||
except SpoolmanError as e:
|
||||
self._error("spoolman", str(e))
|
||||
pending.append(scan) # retry next cycle
|
||||
continue
|
||||
self.spool_cache[tid] = (self.clock(), sm)
|
||||
self._map(n, tid, label(sm), reason="Tag gescannt")
|
||||
self.state.data["scans"] = pending
|
||||
|
||||
def _map(self, n: int, tid: int, name: str, reason: str, cc2_id: str | None = None) -> None:
|
||||
mappings = self.state.data["slots"]
|
||||
cur = mappings.get(str(n))
|
||||
if cur and cur["spool"] == tid:
|
||||
if cc2_id is None:
|
||||
cur["assigned"] = False # rescan: make sure CC2 Control has it in the slot
|
||||
self.dry_logged.pop(f"assign:{n}", None)
|
||||
self._want_location(tid, self.settings.slot_location(n))
|
||||
return
|
||||
for k, m in list(mappings.items()):
|
||||
if k != str(n) and m["spool"] == tid:
|
||||
del mappings[k]
|
||||
self.log.add("info", f"Spule {name} von Slot {int(k) + 1} nach Slot {n + 1} umgezogen")
|
||||
if cur:
|
||||
self._release(n, f"andere Spule ({name})")
|
||||
mappings[str(n)] = {
|
||||
"spool": tid, "name": name, "since": self.clock(),
|
||||
"cc2": cc2_id, "assigned": cc2_id is not None, "cc2_seen": cc2_id is not None,
|
||||
}
|
||||
self.empty_since.pop(n, None)
|
||||
self.log.add("info", f"Slot {n + 1}: {name} (Spoolman {tid}) zugeordnet ({reason})")
|
||||
self._want_location(tid, self.settings.slot_location(n))
|
||||
|
||||
def _release(self, n: int, reason: str) -> None:
|
||||
m = self.state.data["slots"].pop(str(n), None)
|
||||
if not m:
|
||||
return
|
||||
self.dry_logged.pop(f"assign:{n}", None)
|
||||
self.log.add("info", f"Slot {n + 1}: Zuordnung zu {m.get('name') or m['spool']} aufgehoben ({reason})")
|
||||
if not any(o["spool"] == m["spool"] for o in self.state.data["slots"].values()):
|
||||
self._want_location(m["spool"], self.settings.location_storage)
|
||||
|
||||
# ---------- CC2 Control ----------
|
||||
|
||||
def _process_trays(self, cc2: dict) -> None:
|
||||
statuses = {}
|
||||
for slot in cc2.get("slots") or []:
|
||||
printer = slot.get("printer")
|
||||
if isinstance(printer, dict) and slot.get("slot") in (0, 1, 2, 3):
|
||||
try:
|
||||
statuses[slot["slot"]] = int(printer.get("status"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not statuses:
|
||||
try:
|
||||
statuses = tray_statuses(self.cc2.canvas())
|
||||
except CC2Error:
|
||||
statuses = {}
|
||||
self.tray_status = statuses
|
||||
now = self.clock()
|
||||
for n in range(4):
|
||||
st = statuses.get(n)
|
||||
if st is None:
|
||||
continue
|
||||
if st == 0:
|
||||
since = self.empty_since.setdefault(n, now)
|
||||
if now - since >= self.settings.empty_grace and str(n) in self.state.data["slots"]:
|
||||
self._release(n, f"Slot seit {int(now - since)} s leer")
|
||||
else:
|
||||
self.empty_since.pop(n, None)
|
||||
|
||||
def _process_cc2_slots(self, cc2: dict) -> None:
|
||||
by_id = {str(s.get("id")): s for s in cc2.get("spools") or []}
|
||||
mappings = self.state.data["slots"]
|
||||
for slot in cc2.get("slots") or []:
|
||||
n = slot.get("slot")
|
||||
if n not in (0, 1, 2, 3):
|
||||
continue
|
||||
sid = slot_spool_id(slot)
|
||||
m = mappings.get(str(n))
|
||||
mirrored = spoolman_id_of(by_id[sid]) if sid in by_id else None
|
||||
# A spool we already map to another slot (fresh scan) wins over CC2 Control's older binding.
|
||||
elsewhere = mirrored is not None and any(
|
||||
o["spool"] == mirrored for k, o in mappings.items() if k != str(n))
|
||||
if m is None:
|
||||
if mirrored is not None and not elsewhere and self.tray_status.get(n) in (1, 2):
|
||||
# assigned in CC2 Control by hand
|
||||
self._map(n, mirrored, by_id[sid].get("name") or str(mirrored), "in CC2 Control zugewiesen", sid)
|
||||
continue
|
||||
if m.get("cc2") and sid == m["cc2"]:
|
||||
m["cc2_seen"] = m["assigned"] = True
|
||||
continue
|
||||
if not m.get("cc2_seen"):
|
||||
continue
|
||||
if sid:
|
||||
if elsewhere:
|
||||
continue
|
||||
if mirrored is not None and mirrored != m["spool"]:
|
||||
self._map(n, mirrored, by_id[sid].get("name") or str(mirrored), "in CC2 Control zugewiesen", sid)
|
||||
elif mirrored is None:
|
||||
self._release(n, "in CC2 Control mit anderer Spule belegt")
|
||||
elif not slot.get("question"):
|
||||
self._release(n, "CC2 Control hat die Spule ins Lager gelegt")
|
||||
|
||||
def _reconcile_cc2(self, cc2: dict) -> None:
|
||||
mirrors: dict[int, dict] = {}
|
||||
for sp in cc2.get("spools") or []:
|
||||
smid = spoolman_id_of(sp)
|
||||
if smid is not None and (smid not in mirrors or mirrors[smid].get("archived")):
|
||||
mirrors[smid] = sp
|
||||
slots = {s.get("slot"): s for s in cc2.get("slots") or []}
|
||||
record_slots = {s["slot"]: s for s in self.record_slots}
|
||||
for k, m in list(self.state.data["slots"].items()):
|
||||
n = int(k)
|
||||
if m.get("assigned"):
|
||||
continue
|
||||
try:
|
||||
mirror = mirrors.get(m["spool"])
|
||||
if mirror is None:
|
||||
sm = self._spool(m["spool"])
|
||||
if sm is None:
|
||||
continue
|
||||
channel = (record_slots.get(n) or {}).get("channel")
|
||||
fields, notes = cc2_spool_fields(sm, channel)
|
||||
for note in notes:
|
||||
self.log.add("warn", f"{fields['name']}: {note}")
|
||||
resp, done = self._write(
|
||||
f"create:{m['spool']}", f"in CC2 Control Spule {fields['name']} anlegen "
|
||||
f"({fields['material']}, {fields['color']}, Rest {fields.get('remaining', '?')} g)",
|
||||
lambda: self.cc2.save_spool(fields))
|
||||
if not done:
|
||||
self._write(f"assign:{n}", f"in CC2 Control {fields['name']} in Slot {n + 1} legen", lambda: None)
|
||||
continue
|
||||
cc2_id = str((resp or {}).get("id") or "")
|
||||
if not cc2_id:
|
||||
self.log.add("error", "CC2 Control hat keine Spulen-ID zurückgegeben")
|
||||
continue
|
||||
self.state.data["booked"][cc2_id] = {
|
||||
"spoolman": m["spool"], "remaining": float(fields.get("remaining", fields.get("net", 1000))),
|
||||
"pending": None, "last": None,
|
||||
}
|
||||
m["cc2"] = cc2_id
|
||||
else:
|
||||
m["cc2"] = str(mirror["id"])
|
||||
if mirror.get("archived"):
|
||||
_, done = self._write(f"unarchive:{m['cc2']}", f"in CC2 Control {mirror.get('name')} aus dem Archiv holen",
|
||||
lambda: self.cc2.save_spool({"id": m["cc2"], "archived": "0"}))
|
||||
if not done:
|
||||
continue
|
||||
if slot_spool_id(slots.get(n) or {}) == m["cc2"]:
|
||||
m["assigned"] = m["cc2_seen"] = True
|
||||
continue
|
||||
_, done = self._write(f"assign:{n}", f"in CC2 Control {m.get('name')} in Slot {n + 1} legen",
|
||||
lambda: self.cc2.assign(n, m["cc2"]))
|
||||
if done:
|
||||
m["assigned"] = m["cc2_seen"] = True
|
||||
except CC2Error as e:
|
||||
self.log.add("error", f"Slot {n + 1}: {e}")
|
||||
|
||||
# ---------- consumption ----------
|
||||
|
||||
def _consumption(self, cc2: dict) -> None:
|
||||
job_active = bool((cc2.get("job") or {}).get("active"))
|
||||
booked = self.state.data["booked"]
|
||||
now = self.clock()
|
||||
for sp in cc2.get("spools") or []:
|
||||
smid = spoolman_id_of(sp)
|
||||
if smid is None:
|
||||
continue
|
||||
cid = str(sp.get("id"))
|
||||
try:
|
||||
rem = float(sp.get("remaining"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
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.add("info", f"{sp.get('name')}: Ausgangsstand {rem:.1f} g übernommen (Spoolman {smid})")
|
||||
continue
|
||||
try:
|
||||
if b.get("pending") and not self._resolve_pending(cid, b):
|
||||
continue
|
||||
delta = b["remaining"] - rem
|
||||
if delta < -MIN_BOOKING:
|
||||
self.log.add("warn", f"{sp.get('name')}: Rest in CC2 Control um {-delta:.2f} g gestiegen; "
|
||||
"nicht gebucht, neuer Ausgangsstand")
|
||||
b["remaining"] = rem
|
||||
continue
|
||||
if delta < MIN_BOOKING or job_active:
|
||||
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)
|
||||
except SpoolmanNotFound:
|
||||
self._write_error(f"book:{cid}", f"{sp.get('name')}: Spoolman-Spule {smid} existiert nicht; Verbrauch nicht gebucht")
|
||||
except SpoolmanError as e:
|
||||
self._error("spoolman", str(e))
|
||||
|
||||
def _write_error(self, key: str, msg: str) -> None:
|
||||
if self.dry_logged.get(key) != msg:
|
||||
self.dry_logged[key] = msg
|
||||
self.log.add("error", msg)
|
||||
|
||||
def _book(self, cid: str, b: dict, name: str, smid: int, delta: float, rem: float) -> None:
|
||||
if self.settings.dry_run:
|
||||
self._write(f"book:{cid}", f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} buchen", lambda: None)
|
||||
return
|
||||
before = float(self.spoolman.spool(smid).get("used_weight") or 0)
|
||||
b["pending"] = {"grams": round(delta, 3), "target": rem, "before": before, "time": self.clock()}
|
||||
self.state.save() # intent first: a crash after the PUT must not book twice
|
||||
self.spoolman.use(smid, delta)
|
||||
self._booked(b, delta, rem, f"{delta:.2f} g von {name} auf Spoolman-Spule {smid} gebucht")
|
||||
|
||||
def _booked(self, b: dict, grams: float, target: float, msg: str) -> None:
|
||||
b["remaining"] = target
|
||||
b["pending"] = None
|
||||
b["last"] = {"time": self.clock(), "grams": round(grams, 3)}
|
||||
self.state.save()
|
||||
self.log.add("info", msg)
|
||||
|
||||
def _resolve_pending(self, cid: str, b: dict) -> bool:
|
||||
"""A booking was interrupted: check whether Spoolman already has it."""
|
||||
p = b["pending"]
|
||||
smid = b["spoolman"]
|
||||
used = float(self.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")
|
||||
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.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")
|
||||
return True
|
||||
|
||||
# ---------- Spoolman locations ----------
|
||||
|
||||
def _want_location(self, smid: int, location: str) -> None:
|
||||
locs = self.state.data["locations"]
|
||||
entry = locs.setdefault(str(smid), {"want": None, "done": None})
|
||||
entry["want"] = location
|
||||
|
||||
def _reconcile_locations(self) -> None:
|
||||
for k, entry in self.state.data["locations"].items():
|
||||
if entry["want"] == entry["done"]:
|
||||
continue
|
||||
smid = int(k)
|
||||
name = self._name(smid)
|
||||
try:
|
||||
_, done = self._write(f"location:{k}", f"in Spoolman den Ort von {name} auf „{entry['want']}“ setzen",
|
||||
lambda: self.spoolman.set_location(smid, entry["want"]))
|
||||
if done:
|
||||
entry["done"] = entry["want"]
|
||||
self._error("spoolman", None)
|
||||
except SpoolmanNotFound:
|
||||
self.log.add("warn", f"Spoolman-Spule {smid} existiert nicht mehr; Ort nicht gesetzt")
|
||||
entry["done"] = entry["want"]
|
||||
except SpoolmanError as e:
|
||||
self._error("spoolman", str(e))
|
||||
|
||||
# ---------- Spoolman cache ----------
|
||||
|
||||
def _spool(self, smid: int) -> dict | None:
|
||||
cached = self.spool_cache.get(smid)
|
||||
if cached and self.clock() - cached[0] < SPOOL_CACHE_SECONDS:
|
||||
return cached[1]
|
||||
try:
|
||||
sm = self.spoolman.spool(smid)
|
||||
except SpoolmanNotFound:
|
||||
self._write_error(f"missing:{smid}", f"Spoolman-Spule {smid} existiert nicht")
|
||||
return None
|
||||
except SpoolmanError as e:
|
||||
self._error("spoolman", str(e))
|
||||
return cached[1] if cached else None
|
||||
self.spool_cache[smid] = (self.clock(), sm)
|
||||
return sm
|
||||
|
||||
def _name(self, smid: int) -> str:
|
||||
cached = self.spool_cache.get(smid)
|
||||
return label(cached[1]) if cached else f"Spoolman-Spule {smid}"
|
||||
|
||||
def _refresh_spool_cache(self) -> None:
|
||||
for m in self.state.data["slots"].values():
|
||||
self._spool(m["spool"])
|
||||
|
||||
# ---------- UI snapshot ----------
|
||||
|
||||
def _build_snapshot(self, record: dict | None, cc2: dict | None) -> None:
|
||||
record_slots = tagcodec.parse_record(record or self.state.data.get("last_record") or {}, self.settings.id_offset)
|
||||
cc2_slots = {s.get("slot"): s for s in (cc2 or {}).get("slots") or []}
|
||||
cc2_spools = {str(s.get("id")): s for s in (cc2 or {}).get("spools") or []}
|
||||
booked = self.state.data["booked"]
|
||||
slots = []
|
||||
for n in range(4):
|
||||
m = self.state.data["slots"].get(str(n))
|
||||
cs = cc2_slots.get(n) or {}
|
||||
sid = slot_spool_id(cs)
|
||||
cached = self.spool_cache.get(m["spool"]) if m else None
|
||||
b = booked.get(m["cc2"]) if m and m.get("cc2") else None
|
||||
slots.append({
|
||||
"slot": n,
|
||||
"record": record_slots[n],
|
||||
"tray_status": self.tray_status.get(n),
|
||||
"mapping": m,
|
||||
"spoolman": summary(cached[1]) if cached else None,
|
||||
"cc2_spool": cc2_spools.get(sid) if sid else None,
|
||||
"cc2_question": cs.get("question") or "",
|
||||
"cc2_matches": bool(m and m.get("cc2") and sid == m["cc2"]),
|
||||
"last_booking": (b or {}).get("last"),
|
||||
})
|
||||
self.snapshot = {
|
||||
"last_poll": self.clock(),
|
||||
"dry_run": self.settings.dry_run,
|
||||
"errors": dict(self.errors),
|
||||
"cc2_enabled": None if cc2 is None else bool(cc2.get("enabled")),
|
||||
"job_active": None if cc2 is None else bool((cc2.get("job") or {}).get("active")),
|
||||
"slots": slots,
|
||||
"ssh_key": self.keys.has_key(),
|
||||
"host_key": self.keys.host_key() is not None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""CC2 Control HTTP client (spool management only).
|
||||
|
||||
Request rules from CC2 Control's HTTP guard: the Host header must be exactly the
|
||||
printer's host:port, no Origin header, and writes need `X-CC2-Request: 1` with a
|
||||
plain-text body of `key=value` lines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
TEXT_LIMITS = {"name": 96, "brand": 96, "note": 96, "material": 32}
|
||||
_FORBIDDEN = re.compile(r'["\\\x00-\x1f\x7f]')
|
||||
|
||||
NOTE_PREFIX = "spoolman:"
|
||||
|
||||
|
||||
class CC2Error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def clean_text(value, limit: int | None = None) -> str:
|
||||
"""UTF-8 without quotes, backslashes and control characters, cut to `limit` bytes."""
|
||||
text = _FORBIDDEN.sub(" ", str(value)).strip()
|
||||
text = re.sub(r" {2,}", " ", text)
|
||||
if limit is not None:
|
||||
raw = text.encode("utf-8")[:limit]
|
||||
text = raw.decode("utf-8", "ignore").rstrip()
|
||||
return text
|
||||
|
||||
|
||||
def fmt_number(value) -> str:
|
||||
v = float(value)
|
||||
if v == int(v):
|
||||
return str(int(v))
|
||||
return f"{v:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def encode_body(fields: dict) -> str:
|
||||
lines = []
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
if not re.fullmatch(r"[a-z_]+", key):
|
||||
raise ValueError(f"invalid key {key!r}")
|
||||
if isinstance(value, bool):
|
||||
value = "1" if value else "0"
|
||||
elif isinstance(value, (int, float)):
|
||||
value = fmt_number(value)
|
||||
else:
|
||||
value = clean_text(value, TEXT_LIMITS.get(key))
|
||||
lines.append(f"{key}={value}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def spoolman_id_of(cc2_spool: dict) -> int | None:
|
||||
note = str(cc2_spool.get("note") or "")
|
||||
m = re.search(r"\bspoolman:(\d+)\b", note)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def slot_spool_id(slot: dict) -> str | None:
|
||||
"""CC2 Control spool ids are strings; an empty string means the slot holds no spool."""
|
||||
s = slot.get("spool")
|
||||
if isinstance(s, dict):
|
||||
s = s.get("id")
|
||||
s = "" if s is None else str(s)
|
||||
return s or None
|
||||
|
||||
|
||||
class CC2Control:
|
||||
def __init__(self, base_url: str, timeout: float = 8.0, transport: httpx.BaseTransport | None = None):
|
||||
parts = urlsplit(base_url)
|
||||
self.host_header = parts.netloc
|
||||
self.client = httpx.Client(
|
||||
base_url=base_url.rstrip("/"), timeout=timeout, transport=transport,
|
||||
headers={"Host": self.host_header, "Accept": "application/json"},
|
||||
)
|
||||
# httpx never adds Origin by itself; make sure nothing else does either.
|
||||
self.client.headers.pop("Origin", None)
|
||||
|
||||
def close(self):
|
||||
self.client.close()
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
try:
|
||||
r = self.client.get(path)
|
||||
except httpx.HTTPError as e:
|
||||
raise CC2Error(f"CC2 Control nicht erreichbar ({type(e).__name__})") from None
|
||||
if r.status_code != 200:
|
||||
raise CC2Error(f"CC2 Control {path}: HTTP {r.status_code}")
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
raise CC2Error(f"CC2 Control {path}: keine JSON-Antwort") from None
|
||||
|
||||
def _post(self, path: str, fields: dict | str) -> dict:
|
||||
# /api/spools/enable takes the bare word "on"/"off" as its body.
|
||||
body = fields if isinstance(fields, str) else encode_body(fields)
|
||||
try:
|
||||
r = self.client.post(
|
||||
path, content=body.encode("utf-8"),
|
||||
headers={"X-CC2-Request": "1", "Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise CC2Error(f"CC2 Control nicht erreichbar ({type(e).__name__})") from None
|
||||
if r.status_code not in (200, 201):
|
||||
detail = ""
|
||||
try:
|
||||
detail = str(r.json().get("error") or "")[:200]
|
||||
except ValueError:
|
||||
pass
|
||||
raise CC2Error(f"CC2 Control {path}: HTTP {r.status_code} {detail}".strip())
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def canvas(self) -> dict:
|
||||
return self._get("/api/canvas")
|
||||
|
||||
def printer(self) -> dict:
|
||||
return self._get("/api/printer")
|
||||
|
||||
def spools(self) -> dict:
|
||||
return self._get("/api/spools")
|
||||
|
||||
def save_spool(self, fields: dict) -> dict:
|
||||
return self._post("/api/spools/save", fields)
|
||||
|
||||
def assign(self, slot: int, spool_id: int | None) -> dict:
|
||||
return self._post("/api/spools/assign", {"slot": slot, "spool": "" if spool_id is None else spool_id})
|
||||
|
||||
def enable(self, on: bool) -> dict:
|
||||
return self._post("/api/spools/enable", "on" if on else "off")
|
||||
|
||||
|
||||
def tray_statuses(canvas: dict) -> dict[int, int]:
|
||||
"""Slot -> tray status (0 = no filament tip at the feeder, 1 = loaded, 2 = feeding)."""
|
||||
try:
|
||||
trays = canvas["telemetry"]["result"]["canvas_info"]["canvas_list"][0]["tray_list"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return {}
|
||||
out = {}
|
||||
for i, tray in enumerate(trays or []):
|
||||
if not isinstance(tray, dict):
|
||||
continue
|
||||
slot = tray.get("tray_id", i)
|
||||
try:
|
||||
out[int(slot)] = int(tray.get("status"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Elegoo Canvas material and subtype codes.
|
||||
|
||||
Taken verbatim from cjtallman/canvas-rfid-tool (index.html, MIT License,
|
||||
Copyright (c) 2026 cjtallman, see THIRD_PARTY_NOTICES.md), which in turn credits
|
||||
DnG-Crafts/ELG-RFID and Savion/elegoo-rfid-editor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# name -> (4-byte material code, subtype family byte, suggested nozzle min/max)
|
||||
MATERIALS: dict[str, dict] = {
|
||||
"PLA": {"code": 0x00807665, "family": 0x00, "temps": (190, 230)},
|
||||
"PETG": {"code": 0x80698471, "family": 0x01, "temps": (230, 260)},
|
||||
"ABS": {"code": 0x00656683, "family": 0x02, "temps": (240, 270)},
|
||||
"TPU": {"code": 0x00848085, "family": 0x03, "temps": (210, 240)},
|
||||
"PA": {"code": 0x00008065, "family": 0x04, "temps": (260, 290)},
|
||||
"CPE": {"code": 0x00678069, "family": 0x05, "temps": (240, 270)},
|
||||
"PC": {"code": 0x00008067, "family": 0x06, "temps": (260, 290)},
|
||||
"PVA": {"code": 0x00808665, "family": 0x07, "temps": (190, 220)},
|
||||
"ASA": {"code": 0x00658365, "family": 0x08, "temps": (240, 270)},
|
||||
"BVOH": {"code": 0x42564F48, "family": 0x09, "temps": (190, 220)},
|
||||
"EVA": {"code": 0x00455641, "family": 0x0A, "temps": (190, 220)},
|
||||
"HIPS": {"code": 0x48495053, "family": 0x0B, "temps": (230, 250)},
|
||||
"PP": {"code": 0x00005050, "family": 0x0C, "temps": (220, 250)},
|
||||
"PPA": {"code": 0x00505041, "family": 0x0D, "temps": (280, 310)},
|
||||
"PPS": {"code": 0x00505053, "family": 0x0E, "temps": (300, 330)},
|
||||
}
|
||||
|
||||
# (code, name, hidden on the CC2 display)
|
||||
SUBTYPES: list[tuple[int, str, bool]] = [
|
||||
(0x0000, "PLA", False), (0x0001, "PLA+", False), (0x0002, "PLA Pro", False),
|
||||
(0x0003, "PLA Silk", False), (0x0004, "PLA-CF", False), (0x0005, "PLA Carbon", True),
|
||||
(0x0006, "PLA Matte", False), (0x0007, "PLA Fluo", False), (0x0008, "PLA Wood", False),
|
||||
(0x0009, "PLA Basic", False), (0x000A, "RAPID PLA+", False), (0x000B, "PLA Marble", False),
|
||||
(0x000C, "PLA Galaxy", False), (0x000D, "PLA Red Copper", False), (0x000E, "PLA Sparkle", True),
|
||||
(0x0100, "PETG", False), (0x0101, "PETG-CF", False), (0x0102, "PETG-GF", False),
|
||||
(0x0103, "PETG Pro", True), (0x0104, "PETG Translucent", False), (0x0105, "RAPID PETG", False),
|
||||
(0x0200, "ABS", False), (0x0201, "ABS-GF", True),
|
||||
(0x0300, "TPU", False), (0x0301, "TPU 95A", False), (0x0302, "RAPID TPU 95A", False),
|
||||
(0x0400, "PA", False), (0x0401, "PA-CF", True), (0x0403, "PAHT-CF", False),
|
||||
(0x0404, "PA6", True), (0x0405, "PA6-CF", True), (0x0406, "PA12", True), (0x0407, "PA12-CF", True),
|
||||
(0x0500, "CPE", False),
|
||||
(0x0600, "PC", False), (0x0601, "PCTG", True), (0x0602, "PC-FR", False),
|
||||
(0x0700, "PVA", False), (0x0800, "ASA", False), (0x0900, "BVOH", False), (0x0A00, "EVA", False),
|
||||
(0x0B00, "HIPS", False),
|
||||
(0x0C00, "PP", False), (0x0C01, "PP-CF", True), (0x0C02, "PP-GF", True),
|
||||
(0x0D00, "PPA", False), (0x0D01, "PPA-CF", True), (0x0D02, "PPA-GF", True),
|
||||
(0x0E00, "PPS", False), (0x0E02, "PPS-CF", True),
|
||||
]
|
||||
|
||||
SUBTYPE_BY_CODE = {code: name for code, name, _ in SUBTYPES}
|
||||
MATERIAL_BY_CODE = {m["code"]: name for name, m in MATERIALS.items()}
|
||||
|
||||
|
||||
def material_of_subtype(code: int) -> str | None:
|
||||
family = code >> 8
|
||||
for name, m in MATERIALS.items():
|
||||
if m["family"] == family:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def subtypes_for(material: str) -> list[tuple[int, str, bool]]:
|
||||
family = MATERIALS[material]["family"]
|
||||
return [s for s in SUBTYPES if s[0] >> 8 == family]
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
text = text.upper().replace("+", " PLUS ")
|
||||
return set(re.findall(r"[A-Z0-9]+", text))
|
||||
|
||||
|
||||
# Spoolman material spellings that do not name the Elegoo base material directly.
|
||||
_ALIASES = {"PCTG": "PC", "PA6": "PA", "PA12": "PA", "PAHT": "PA", "NYLON": "PA", "PET": "PETG"}
|
||||
|
||||
_MODIFIERS = [
|
||||
# (tokens that must all be present, subtype name suffix)
|
||||
({"RAPID", "PLUS"}, "RAPID {m}+"),
|
||||
({"RAPID"}, "RAPID {m}"),
|
||||
({"95A"}, "{m} 95A"),
|
||||
({"SILK"}, "{m} Silk"),
|
||||
({"MATTE"}, "{m} Matte"),
|
||||
({"MATT"}, "{m} Matte"),
|
||||
({"CF"}, "{m}-CF"),
|
||||
({"CARBON"}, "{m}-CF"),
|
||||
({"GF"}, "{m}-GF"),
|
||||
({"WOOD"}, "{m} Wood"),
|
||||
({"MARBLE"}, "{m} Marble"),
|
||||
({"GALAXY"}, "{m} Galaxy"),
|
||||
({"SPARKLE"}, "{m} Sparkle"),
|
||||
({"FLUO"}, "{m} Fluo"),
|
||||
({"TRANSLUCENT"}, "{m} Translucent"),
|
||||
({"TRANSPARENT"}, "{m} Translucent"),
|
||||
({"PRO"}, "{m} Pro"),
|
||||
({"BASIC"}, "{m} Basic"),
|
||||
({"PLUS"}, "{m}+"),
|
||||
]
|
||||
|
||||
|
||||
def suggest(material: str | None, name: str | None = "") -> dict:
|
||||
"""Suggest Elegoo material, subtype and nozzle temps from Spoolman text."""
|
||||
material = material or ""
|
||||
tokens = _tokens(f"{material} {name or ''}")
|
||||
mat_tokens = _tokens(material)
|
||||
base = None
|
||||
# Prefer the Spoolman material field; fall back to the filament name.
|
||||
for pool in (mat_tokens, tokens):
|
||||
for tok in sorted(pool, key=len, reverse=True):
|
||||
cand = _ALIASES.get(tok, tok)
|
||||
if cand in MATERIALS:
|
||||
base = cand
|
||||
break
|
||||
if tok.startswith("PLA"): # e.g. "PLAPLUS"
|
||||
base = "PLA"
|
||||
break
|
||||
if base:
|
||||
break
|
||||
if base is None:
|
||||
base = "PLA"
|
||||
subtype = MATERIALS[base]["family"] << 8
|
||||
names = {n.upper(): c for c, n, _ in subtypes_for(base)}
|
||||
# Exact subtype names first (e.g. "PCTG", "PAHT-CF").
|
||||
for tok in tokens:
|
||||
if tok in names and tok != base:
|
||||
subtype = names[tok]
|
||||
break
|
||||
else:
|
||||
for needed, pattern in _MODIFIERS:
|
||||
if needed <= tokens:
|
||||
cand = pattern.format(m=base).upper()
|
||||
if cand in names:
|
||||
subtype = names[cand]
|
||||
break
|
||||
return {"material": base, "subtype": subtype, "temps": MATERIALS[base]["temps"]}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Minimal Spoolman REST client (/api/v1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class SpoolmanError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SpoolmanNotFound(SpoolmanError):
|
||||
pass
|
||||
|
||||
|
||||
class Spoolman:
|
||||
def __init__(self, base_url: str, timeout: float = 8.0, transport: httpx.BaseTransport | None = None):
|
||||
self.client = httpx.Client(base_url=base_url.rstrip("/") + "/api/v1", timeout=timeout, transport=transport)
|
||||
|
||||
def close(self):
|
||||
self.client.close()
|
||||
|
||||
def _request(self, method: str, path: str, **kw):
|
||||
try:
|
||||
r = self.client.request(method, path, **kw)
|
||||
except httpx.HTTPError as e:
|
||||
raise SpoolmanError(f"Spoolman nicht erreichbar ({type(e).__name__})") from None
|
||||
if r.status_code == 404:
|
||||
raise SpoolmanNotFound(f"Spoolman {path}: nicht gefunden")
|
||||
if r.status_code >= 400:
|
||||
raise SpoolmanError(f"Spoolman {method} {path}: HTTP {r.status_code}")
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
raise SpoolmanError(f"Spoolman {path}: keine JSON-Antwort") from None
|
||||
|
||||
def spool(self, spool_id: int) -> dict:
|
||||
return self._request("GET", f"/spool/{int(spool_id)}")
|
||||
|
||||
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 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)})
|
||||
|
||||
|
||||
def vendor_name(spool: dict) -> str:
|
||||
return ((spool.get("filament") or {}).get("vendor") or {}).get("name") or ""
|
||||
|
||||
|
||||
def label(spool: dict) -> str:
|
||||
"""lot_nr like SUNLU001; otherwise built from vendor + id the same way."""
|
||||
lot = (spool.get("lot_nr") or "").strip()
|
||||
if lot:
|
||||
return lot
|
||||
vendor = re.sub(r"[^A-Z0-9]", "", vendor_name(spool).upper()) or "SPOOL"
|
||||
return f"{vendor}{int(spool['id']):03d}"
|
||||
|
||||
|
||||
def color_hex(spool: dict) -> str | None:
|
||||
fil = spool.get("filament") or {}
|
||||
color = fil.get("color_hex")
|
||||
if not color and fil.get("multi_color_hexes"):
|
||||
color = str(fil["multi_color_hexes"]).split(",")[0]
|
||||
if not color:
|
||||
return None
|
||||
color = color.strip().lstrip("#").upper()[:6]
|
||||
return color if re.fullmatch(r"[0-9A-F]{6}", color) else None
|
||||
|
||||
|
||||
def summary(spool: dict) -> dict:
|
||||
fil = spool.get("filament") or {}
|
||||
return {
|
||||
"id": spool.get("id"),
|
||||
"label": label(spool),
|
||||
"vendor": vendor_name(spool),
|
||||
"name": fil.get("name") or "",
|
||||
"material": fil.get("material") or "",
|
||||
"color": color_hex(spool),
|
||||
"diameter": fil.get("diameter"),
|
||||
"density": fil.get("density"),
|
||||
"remaining": spool.get("remaining_weight"),
|
||||
"initial": spool.get("initial_weight") or fil.get("weight"),
|
||||
"spool_weight": spool.get("spool_weight") or fil.get("spool_weight"),
|
||||
"extruder_temp": fil.get("settings_extruder_temp"),
|
||||
"location": spool.get("location") or "",
|
||||
"archived": bool(spool.get("archived")),
|
||||
"used_weight": spool.get("used_weight"),
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Read /opt/usr/canvas_record_info.json from the printer over SSH.
|
||||
|
||||
The printer's authorized_keys entry forces `cat /opt/usr/canvas_record_info.json`
|
||||
for this key, so every exec returns that file whatever command is sent. One
|
||||
transport is kept open and a new channel is opened per read; nothing keeps
|
||||
running on the printer between reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
from .storage import atomic_write, atomic_write_json, read_json
|
||||
|
||||
REMOTE_COMMAND = "cat /opt/usr/canvas_record_info.json"
|
||||
MAX_RECORD_BYTES = 256 * 1024
|
||||
|
||||
|
||||
class SSHError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HostKeyUnknown(SSHError):
|
||||
def __init__(self, key: paramiko.PKey):
|
||||
self.key = key
|
||||
super().__init__(f"Host-Key des Druckers noch nicht bestätigt ({fingerprint(key)})")
|
||||
|
||||
|
||||
class HostKeyMismatch(SSHError):
|
||||
def __init__(self, key: paramiko.PKey, expected: str):
|
||||
self.key = key
|
||||
super().__init__(
|
||||
f"Host-Key des Druckers hat sich geändert: {fingerprint(key)}, erwartet {expected}. "
|
||||
"Verbindung abgelehnt."
|
||||
)
|
||||
|
||||
|
||||
def fingerprint(key: paramiko.PKey) -> str:
|
||||
digest = hashlib.sha256(key.asbytes()).digest()
|
||||
return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")
|
||||
|
||||
|
||||
def load_private_key(text: str) -> paramiko.PKey:
|
||||
"""Parse an OpenSSH/PEM private key (no passphrase)."""
|
||||
text = text.replace("\r\n", "\n").strip() + "\n"
|
||||
errors = []
|
||||
for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
|
||||
try:
|
||||
return cls.from_private_key(io.StringIO(text))
|
||||
except paramiko.PasswordRequiredException:
|
||||
raise SSHError("Der Schlüssel ist passwortgeschützt; bitte einen Schlüssel ohne Passphrase verwenden") from None
|
||||
except (paramiko.SSHException, ValueError, IndexError, TypeError) as e:
|
||||
errors.append(type(e).__name__)
|
||||
raise SSHError("Kein gültiger privater SSH-Schlüssel (ed25519, ecdsa oder rsa)")
|
||||
|
||||
|
||||
class KeyStore:
|
||||
"""Private key and pinned printer host key in <data>/ssh."""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
self.dir = data_dir / "ssh"
|
||||
self.key_path = self.dir / "id_key"
|
||||
self.host_path = self.dir / "host_key.json"
|
||||
|
||||
def has_key(self) -> bool:
|
||||
return self.key_path.exists()
|
||||
|
||||
def save_key(self, text: str) -> paramiko.PKey:
|
||||
text = text.replace("\r\n", "\n").strip() + "\n"
|
||||
key = load_private_key(text)
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.dir, 0o700)
|
||||
atomic_write(self.key_path, text.encode(), mode=0o600)
|
||||
return key
|
||||
|
||||
def delete_key(self) -> None:
|
||||
try:
|
||||
self.key_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def load_key(self) -> paramiko.PKey:
|
||||
try:
|
||||
os.chmod(self.key_path, 0o600)
|
||||
return load_private_key(self.key_path.read_text())
|
||||
except FileNotFoundError:
|
||||
raise SSHError("Kein SSH-Schlüssel hinterlegt") from None
|
||||
|
||||
def key_info(self) -> dict | None:
|
||||
if not self.has_key():
|
||||
return None
|
||||
try:
|
||||
key = self.load_key()
|
||||
except SSHError as e:
|
||||
return {"error": str(e)}
|
||||
return {
|
||||
"type": key.get_name(),
|
||||
"fingerprint": fingerprint(key),
|
||||
"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 pin_host_key(self, host: str, key: paramiko.PKey) -> None:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(self.host_path, {
|
||||
"host": host, "type": key.get_name(), "key": key.get_base64(), "fingerprint": fingerprint(key),
|
||||
}, mode=0o600)
|
||||
|
||||
def forget_host_key(self) -> None:
|
||||
try:
|
||||
self.host_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class CanvasRecordReader:
|
||||
def __init__(self, keys: KeyStore, host: str, port: int = 22, user: str = "root", timeout: float = 10.0):
|
||||
self.keys = keys
|
||||
self.host, self.port, self.user, self.timeout = host, port, user, timeout
|
||||
self._transport: paramiko.Transport | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._close()
|
||||
|
||||
def _close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._transport = None
|
||||
|
||||
def _connect(self, accept_unknown: bool = False) -> paramiko.PKey:
|
||||
"""Open and authenticate a transport; returns the server's host key."""
|
||||
key = self.keys.load_key()
|
||||
try:
|
||||
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||
except OSError as e:
|
||||
raise SSHError(f"Drucker {self.host}:{self.port} nicht erreichbar ({e.strerror or type(e).__name__})") from None
|
||||
t = paramiko.Transport(sock)
|
||||
t.banner_timeout = t.auth_timeout = self.timeout
|
||||
try:
|
||||
t.start_client(timeout=self.timeout)
|
||||
server_key = t.get_remote_server_key()
|
||||
pinned = self.keys.host_key()
|
||||
if pinned is None:
|
||||
if not accept_unknown:
|
||||
raise HostKeyUnknown(server_key)
|
||||
elif pinned.get("type") != server_key.get_name() or pinned.get("key") != server_key.get_base64():
|
||||
raise HostKeyMismatch(server_key, pinned.get("fingerprint", "?"))
|
||||
t.auth_publickey(self.user, key)
|
||||
if not t.is_authenticated():
|
||||
raise SSHError("SSH-Anmeldung fehlgeschlagen")
|
||||
t.set_keepalive(30)
|
||||
except SSHError:
|
||||
t.close()
|
||||
raise
|
||||
except paramiko.AuthenticationException:
|
||||
t.close()
|
||||
raise SSHError("SSH-Anmeldung mit dem hinterlegten Schlüssel abgelehnt") from None
|
||||
except (paramiko.SSHException, OSError, EOFError) as e:
|
||||
t.close()
|
||||
raise SSHError(f"SSH-Fehler ({type(e).__name__})") from None
|
||||
self._transport = t
|
||||
return server_key
|
||||
|
||||
def _exec(self) -> bytes:
|
||||
assert self._transport is not None
|
||||
chan = self._transport.open_session(timeout=self.timeout)
|
||||
try:
|
||||
chan.settimeout(self.timeout)
|
||||
chan.exec_command(REMOTE_COMMAND)
|
||||
chunks, size = [], 0
|
||||
while True:
|
||||
data = chan.recv(32768)
|
||||
if not data:
|
||||
break
|
||||
size += len(data)
|
||||
if size > MAX_RECORD_BYTES:
|
||||
raise SSHError("Antwort des Druckers ist zu groß")
|
||||
chunks.append(data)
|
||||
status = chan.recv_exit_status()
|
||||
if status not in (0, -1):
|
||||
raise SSHError(f"Befehl auf dem Drucker endete mit Status {status}")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
chan.close()
|
||||
|
||||
def read(self) -> dict:
|
||||
"""Read and parse the canvas record; reconnects once on a dead transport."""
|
||||
with self._lock:
|
||||
for attempt in (0, 1):
|
||||
if self._transport is None or not self._transport.is_active():
|
||||
self._close()
|
||||
self._connect()
|
||||
try:
|
||||
raw = self._exec()
|
||||
break
|
||||
except (paramiko.SSHException, OSError, EOFError, socket.timeout) as e:
|
||||
self._close()
|
||||
if attempt:
|
||||
raise SSHError(f"SSH-Lesefehler ({type(e).__name__})") from None
|
||||
return parse_record_json(raw)
|
||||
|
||||
def test(self) -> dict:
|
||||
"""Fresh connection; an unknown host key is reported, not trusted."""
|
||||
with self._lock:
|
||||
self._close()
|
||||
try:
|
||||
server_key = self._connect(accept_unknown=True)
|
||||
raw = self._exec()
|
||||
except HostKeyMismatch:
|
||||
raise
|
||||
finally:
|
||||
self._close()
|
||||
pinned = self.keys.host_key()
|
||||
return {
|
||||
"host_key": {"type": server_key.get_name(), "key": server_key.get_base64(),
|
||||
"fingerprint": fingerprint(server_key)},
|
||||
"host_key_pinned": pinned is not None,
|
||||
"record": parse_record_json(raw),
|
||||
}
|
||||
|
||||
|
||||
def parse_record_json(raw: bytes) -> dict:
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise SSHError("canvas_record_info.json ist kein gültiges JSON") from None
|
||||
if not isinstance(data, dict):
|
||||
raise SSHError("canvas_record_info.json hat ein unerwartetes Format")
|
||||
return data
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
"""Settings and state persistence in the data volume (atomic JSON writes)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: bytes, mode: int = 0o644) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
dfd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, obj, mode: int = 0o644) -> None:
|
||||
atomic_write(path, json.dumps(obj, indent=2, sort_keys=True, ensure_ascii=False).encode(), mode)
|
||||
|
||||
|
||||
def read_json(path: Path, default):
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except FileNotFoundError:
|
||||
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:
|
||||
if typ == "bool":
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("1", "true", "yes", "on", "ja")
|
||||
return bool(value)
|
||||
if typ == "int":
|
||||
return int(value)
|
||||
if typ == "float":
|
||||
return float(value)
|
||||
return str(value).strip()
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"Ungültiger Wert für {name}") from None
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
def __init__(self, data_dir: Path, env: dict | None = None):
|
||||
self.path = data_dir / "settings.json"
|
||||
self._lock = threading.Lock()
|
||||
stored = read_json(self.path, None)
|
||||
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)}
|
||||
self.settings = Settings.from_dict(defaults)
|
||||
else:
|
||||
self.settings = Settings.from_dict(stored)
|
||||
|
||||
def get(self) -> Settings:
|
||||
with self._lock:
|
||||
return Settings.from_dict(self.settings.to_dict())
|
||||
|
||||
def save(self, changes: dict) -> Settings:
|
||||
with self._lock:
|
||||
new = Settings.from_dict({**self.settings.to_dict(), **changes})
|
||||
atomic_write_json(self.path, new.to_dict())
|
||||
self.settings = new
|
||||
return Settings.from_dict(new.to_dict())
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Bridge state: slot mappings, booked consumption, last record."""
|
||||
|
||||
def __init__(self, data_dir: Path):
|
||||
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)
|
||||
|
||||
def save(self) -> None:
|
||||
atomic_write_json(self.path, self.data)
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
"""Encode/decode Elegoo Canvas NFC tags (NTAG213/215/216) and canvas records.
|
||||
|
||||
Page numbers are hex, as used by NFC Tools ("A2:PP:XXXXXXXX"). All values are
|
||||
big-endian. Pages 0-3 (UID, lock bits, capability container) are never written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .materials import MATERIAL_BY_CODE, MATERIALS, SUBTYPE_BY_CODE, material_of_subtype
|
||||
|
||||
DEFAULT_URL = "https://www.elegoo.com"
|
||||
DEFAULT_ID_OFFSET = 10000
|
||||
MAX_WEIGHT = 0xFFFF
|
||||
|
||||
NDEF_FIRST_PAGE = 0x04
|
||||
NDEF_LAST_PAGE = 0x0F # canvas data starts at page 0x10
|
||||
CANVAS_PAGES = range(0x10, 0x19)
|
||||
|
||||
PAGE_18_DEFAULT = 0x0036C800 # copied from original Elegoo spools; meaning unknown
|
||||
|
||||
# Display palette from /opt/inst/filament_color_set.json
|
||||
PALETTE = [
|
||||
"FFFFFF", "FFF242", "DBF47A", "09CC3A", "077747", "0B6283", "0BE2A0", "74D9F3",
|
||||
"48A7FA", "2850DF", "433089", "A03BF7", "F32FF8", "D4B1DD", "F95D77", "F72221",
|
||||
"7C4C00", "F88D36", "FCEBD7", "D2C5A3", "AF7832", "898989", "BCBCBC", "000000",
|
||||
]
|
||||
BLUE = "2850DF"
|
||||
|
||||
_URI_PREFIXES = [
|
||||
("https://www.", 0x02), ("http://www.", 0x01), ("https://", 0x04), ("http://", 0x03),
|
||||
]
|
||||
|
||||
|
||||
class TagError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagData:
|
||||
spool_id: int
|
||||
color: str # RRGGBB
|
||||
material: str = "PLA"
|
||||
subtype: int = 0x0001
|
||||
temp_min: int = 190
|
||||
temp_max: int = 230
|
||||
diameter: float = 1.75
|
||||
finish: int = 0xFF
|
||||
filament_code: int = 0x0000
|
||||
id_offset: int = DEFAULT_ID_OFFSET
|
||||
url: str = DEFAULT_URL
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def normalize_hex_color(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
v = value.strip().lstrip("#").upper()
|
||||
if re.fullmatch(r"[0-9A-F]{8}", v): # RRGGBBAA
|
||||
v = v[:6]
|
||||
if re.fullmatch(r"[0-9A-F]{3}", v):
|
||||
v = "".join(c * 2 for c in v)
|
||||
return v if re.fullmatch(r"[0-9A-F]{6}", v) else None
|
||||
|
||||
|
||||
# ---------- NDEF ----------
|
||||
|
||||
def encode_ndef_url(url: str) -> bytes:
|
||||
"""Lock-control TLV + NDEF URI record + terminator, as on the original tags."""
|
||||
prefix_code, rest = 0x00, url
|
||||
for prefix, code in _URI_PREFIXES:
|
||||
if url.startswith(prefix):
|
||||
prefix_code, rest = code, url[len(prefix):]
|
||||
break
|
||||
body = rest.encode("utf-8")
|
||||
payload = bytes([prefix_code]) + body
|
||||
if len(payload) > 255:
|
||||
raise TagError("URL zu lang")
|
||||
record = bytes([0xD1, 0x01, len(payload), 0x55]) + payload
|
||||
data = bytes([0x01, 0x03, 0xA0, 0x0C, 0x34, 0x03, len(record)]) + record + b"\xFE"
|
||||
max_len = (NDEF_LAST_PAGE - NDEF_FIRST_PAGE + 1) * 4
|
||||
if len(data) > max_len:
|
||||
raise TagError(f"URL zu lang: {len(data)} Byte, maximal {max_len} (Seiten 04-0F)")
|
||||
pad = (-len(data)) % 4
|
||||
return data + b"\x00" * pad
|
||||
|
||||
|
||||
def decode_ndef_url(data: bytes) -> str | None:
|
||||
i = 0
|
||||
while i < len(data):
|
||||
t = data[i]
|
||||
if t == 0x00:
|
||||
i += 1
|
||||
continue
|
||||
if t == 0xFE or i + 1 >= len(data):
|
||||
return None
|
||||
length = data[i + 1]
|
||||
value = data[i + 2:i + 2 + length]
|
||||
if t == 0x03 and len(value) >= 5 and value[3] == 0x55:
|
||||
plen = value[2]
|
||||
payload = value[4:4 + plen]
|
||||
prefix = {code: p for p, code in _URI_PREFIXES}.get(payload[0], "")
|
||||
return prefix + payload[1:].decode("utf-8", "replace")
|
||||
i += 2 + length
|
||||
return None
|
||||
|
||||
|
||||
# ---------- canvas pages ----------
|
||||
|
||||
def encode_pages(tag: TagData) -> dict[int, bytes]:
|
||||
if tag.material not in MATERIALS:
|
||||
raise TagError(f"Unbekanntes Material {tag.material}")
|
||||
if tag.subtype not in SUBTYPE_BY_CODE:
|
||||
raise TagError(f"Unbekannter Untertyp 0x{tag.subtype:04X}")
|
||||
if material_of_subtype(tag.subtype) != tag.material:
|
||||
raise TagError(f"Untertyp {SUBTYPE_BY_CODE[tag.subtype]} passt nicht zu {tag.material}")
|
||||
color = normalize_hex_color(tag.color)
|
||||
if color is None:
|
||||
raise TagError("Farbe muss RRGGBB sein")
|
||||
weight = tag.spool_id + tag.id_offset
|
||||
if tag.spool_id < 0 or weight > MAX_WEIGHT:
|
||||
raise TagError(f"Spulen-ID {tag.spool_id} passt nicht in 2 Byte (max. {MAX_WEIGHT - tag.id_offset})")
|
||||
for t in (tag.temp_min, tag.temp_max):
|
||||
if not 0 <= t <= 500:
|
||||
raise TagError("Düsentemperatur außerhalb 0-500 °C")
|
||||
if tag.temp_min > tag.temp_max:
|
||||
raise TagError("Düse min ist größer als max")
|
||||
diameter = round(tag.diameter * 100)
|
||||
if not 0 < diameter <= 0xFFFF:
|
||||
raise TagError("Ungültiger Durchmesser")
|
||||
|
||||
pages: dict[int, bytes] = {}
|
||||
ndef = encode_ndef_url(tag.url)
|
||||
for i in range(0, len(ndef), 4):
|
||||
pages[NDEF_FIRST_PAGE + i // 4] = ndef[i:i + 4]
|
||||
pages[0x10] = bytes([0x36, 0xEE, 0xEE, 0xEE])
|
||||
pages[0x11] = bytes([0xEE]) + tag.filament_code.to_bytes(2, "big") + b"\x00"
|
||||
pages[0x12] = MATERIALS[tag.material]["code"].to_bytes(4, "big")
|
||||
pages[0x13] = tag.subtype.to_bytes(2, "big") + b"\x00\x00"
|
||||
pages[0x14] = bytes.fromhex(color) + bytes([tag.finish & 0xFF])
|
||||
pages[0x15] = tag.temp_min.to_bytes(2, "big") + tag.temp_max.to_bytes(2, "big")
|
||||
pages[0x16] = b"\x00\x00\x00\x00"
|
||||
pages[0x17] = diameter.to_bytes(2, "big") + weight.to_bytes(2, "big")
|
||||
pages[0x18] = PAGE_18_DEFAULT.to_bytes(4, "big")
|
||||
return pages
|
||||
|
||||
|
||||
def pages_to_commands(pages: dict[int, bytes]) -> str:
|
||||
for p in pages:
|
||||
if p < 4:
|
||||
raise TagError("Seiten 0-3 dürfen nicht geschrieben werden")
|
||||
return ",".join(f"A2:{p:02X}:{pages[p].hex().upper()}" for p in sorted(pages))
|
||||
|
||||
|
||||
def encode_commands(tag: TagData) -> str:
|
||||
return pages_to_commands(encode_pages(tag))
|
||||
|
||||
|
||||
def parse_commands(text: str) -> dict[int, bytes]:
|
||||
pages: dict[int, bytes] = {}
|
||||
for part in re.split(r"[,\s]+", text.strip()):
|
||||
if not part:
|
||||
continue
|
||||
m = re.fullmatch(r"A2:([0-9A-Fa-f]{2}):([0-9A-Fa-f]{8})", part)
|
||||
if not m:
|
||||
raise TagError(f"Ungültiger Befehl: {part}")
|
||||
pages[int(m.group(1), 16)] = bytes.fromhex(m.group(2))
|
||||
return pages
|
||||
|
||||
|
||||
def decode_pages(pages: dict[int, bytes], id_offset: int = DEFAULT_ID_OFFSET) -> dict:
|
||||
def page(n: int) -> bytes:
|
||||
return pages.get(n, b"\x00\x00\x00\x00")
|
||||
|
||||
ndef = b"".join(page(p) for p in range(NDEF_FIRST_PAGE, NDEF_LAST_PAGE + 1))
|
||||
mat_code = int.from_bytes(page(0x12), "big")
|
||||
subtype = int.from_bytes(page(0x13)[:2], "big")
|
||||
weight = int.from_bytes(page(0x17)[2:], "big")
|
||||
return {
|
||||
"url": decode_ndef_url(ndef),
|
||||
"header": page(0x10)[0],
|
||||
"manufacturer": page(0x10)[1:].hex().upper() + page(0x11)[:1].hex().upper(),
|
||||
"filament_code": int.from_bytes(page(0x11)[1:3], "big"),
|
||||
"material": MATERIAL_BY_CODE.get(mat_code, f"0x{mat_code:08X}"),
|
||||
"subtype": subtype,
|
||||
"subtype_name": SUBTYPE_BY_CODE.get(subtype, "?"),
|
||||
"color": page(0x14)[:3].hex().upper(),
|
||||
"finish": page(0x14)[3],
|
||||
"temp_min": int.from_bytes(page(0x15)[:2], "big"),
|
||||
"temp_max": int.from_bytes(page(0x15)[2:], "big"),
|
||||
"diameter": int.from_bytes(page(0x17)[:2], "big") / 100,
|
||||
"weight": weight,
|
||||
"spool_id": spool_id_from_weight(weight, id_offset),
|
||||
}
|
||||
|
||||
|
||||
# ---------- canvas record (/opt/usr/canvas_record_info.json) ----------
|
||||
|
||||
def spool_id_from_weight(weight, id_offset: int = DEFAULT_ID_OFFSET) -> int | None:
|
||||
"""weight >= offset encodes a Spoolman spool id; smaller values are real weights."""
|
||||
try:
|
||||
w = int(float(str(weight).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if w < id_offset or w > MAX_WEIGHT:
|
||||
return None
|
||||
return w - id_offset
|
||||
|
||||
|
||||
def parse_record(record: dict, id_offset: int = DEFAULT_ID_OFFSET) -> list[dict]:
|
||||
"""Return 4 slot dicts (slot 0-3) with the raw channel and the decoded spool id."""
|
||||
slots = []
|
||||
for n in range(4):
|
||||
ch = record.get(f"channels{n}") or {}
|
||||
if not isinstance(ch, dict):
|
||||
ch = {}
|
||||
occupied = any(str(v).strip() for v in ch.values())
|
||||
slots.append({
|
||||
"slot": n,
|
||||
"channel": ch,
|
||||
"occupied": occupied,
|
||||
"spool_id": spool_id_from_weight(ch.get("weight"), id_offset) if occupied else None,
|
||||
})
|
||||
return slots
|
||||
|
||||
|
||||
# ---------- colour suggestion ----------
|
||||
|
||||
def _srgb_to_lab(hexcolor: str) -> tuple[float, float, float]:
|
||||
rgb = [int(hexcolor[i:i + 2], 16) / 255 for i in (0, 2, 4)]
|
||||
lin = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb]
|
||||
r, g, b = lin
|
||||
x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||
y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||
|
||||
def f(t):
|
||||
return t ** (1 / 3) if t > 216 / 24389 else (24389 / 27 * t + 16) / 116
|
||||
|
||||
fx, fy, fz = f(x), f(y), f(z)
|
||||
return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)
|
||||
|
||||
|
||||
def _lab_distance(a: str, b: str) -> float:
|
||||
return math.dist(_srgb_to_lab(a), _srgb_to_lab(b))
|
||||
|
||||
|
||||
def _is_saturated_blue(hexcolor: str) -> bool:
|
||||
r, g, b = (int(hexcolor[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||||
return 215 <= h * 360 <= 245 and s >= 0.6 and v >= 0.35
|
||||
|
||||
|
||||
def nearest_palette_color(hexcolor: str | None) -> str:
|
||||
color = normalize_hex_color(hexcolor)
|
||||
if color is None:
|
||||
return "000000"
|
||||
if color in PALETTE:
|
||||
return color
|
||||
if _is_saturated_blue(color):
|
||||
# Lab distance lands on the violet entries for saturated blues.
|
||||
return BLUE
|
||||
return min(PALETTE, key=lambda p: _lab_distance(color, p))
|
||||
@@ -0,0 +1,131 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}CC2 Bridge{% endblock %}</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9; --panel: #ffffff; --text: #1d2330; --muted: #5d6677; --line: #dde1e8;
|
||||
--accent: #2856d9; --accent-text: #fff; --ok: #1b7f3b; --warn: #a15c00; --err: #b3261e;
|
||||
--ok-bg: #e7f5ec; --warn-bg: #fff4e0; --err-bg: #fde8e7; --dry-bg: #eef1ff; --code: #f0f2f5;
|
||||
--radius: 8px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14171c; --panel: #1c2027; --text: #e6e9ef; --muted: #9aa3b2; --line: #2e343e;
|
||||
--accent: #6d8dff; --accent-text: #0d1020; --ok: #5cc982; --warn: #f0b55c; --err: #ff8a80;
|
||||
--ok-bg: #16301f; --warn-bg: #33270f; --err-bg: #3a1a18; --dry-bg: #1e2340; --code: #232832;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
header { background: var(--panel); border-bottom: 1px solid var(--line); }
|
||||
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px; }
|
||||
header .wrap { display: flex; align-items: center; gap: 24px; min-height: 52px; flex-wrap: wrap; }
|
||||
header h1 { font-size: 16px; margin: 0; font-weight: 650; }
|
||||
nav { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
nav a { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; }
|
||||
nav a.on, nav a:hover { color: var(--text); background: var(--code); }
|
||||
.mode { margin-left: auto; font-size: 13px; padding: 3px 10px; border-radius: 99px; font-weight: 600; }
|
||||
.mode.dry { background: var(--dry-bg); color: var(--accent); }
|
||||
.mode.live { background: var(--err-bg); color: var(--err); }
|
||||
main { padding: 20px 0 48px; }
|
||||
h2 { font-size: 18px; margin: 28px 0 12px; }
|
||||
h2:first-child { margin-top: 0; }
|
||||
h3 { font-size: 15px; margin: 0 0 8px; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
|
||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 13px; }
|
||||
.banner { border-radius: var(--radius); padding: 10px 14px; margin-bottom: 12px; display: flex; gap: 12px;
|
||||
align-items: center; flex-wrap: wrap; }
|
||||
.banner.err { background: var(--err-bg); color: var(--err); }
|
||||
.banner.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.banner.ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.banner.dry { background: var(--dry-bg); color: var(--accent); }
|
||||
button, .btn { font: inherit; border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; }
|
||||
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
button.danger { color: var(--err); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 6px 8px; width: 100%; }
|
||||
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
||||
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.row > .field { flex: 1 1 160px; }
|
||||
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
||||
code { background: var(--code); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
|
||||
pre { background: var(--code); padding: 10px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap;
|
||||
word-break: break-all; margin: 0; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
th { font-size: 13px; color: var(--muted); font-weight: 600; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line);
|
||||
vertical-align: -2px; }
|
||||
.tag { display: inline-block; font-size: 12px; padding: 1px 7px; border-radius: 99px; background: var(--code); }
|
||||
.tag.ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.tag.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.tag.err { background: var(--err-bg); color: var(--err); }
|
||||
.tag.dry { background: var(--dry-bg); color: var(--accent); }
|
||||
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 3px 12px; margin: 0; font-size: 14px; }
|
||||
dl.kv dt { color: var(--muted); }
|
||||
dl.kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--text);
|
||||
color: var(--bg); padding: 8px 14px; border-radius: 6px; opacity: 0; transition: opacity .2s; pointer-events: none; }
|
||||
.toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="wrap">
|
||||
<h1>CC2 ↔ Spoolman</h1>
|
||||
<nav>
|
||||
<a href="/" class="{{ 'on' if active == 'status.html' }}">Status</a>
|
||||
<a href="/tags" class="{{ 'on' if active == 'tags.html' }}">Tag-Generator</a>
|
||||
<a href="/settings" class="{{ 'on' if active == 'settings.html' }}">Einstellungen</a>
|
||||
</nav>
|
||||
<span id="mode" class="mode"></span>
|
||||
</div></header>
|
||||
<main><div class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</div></main>
|
||||
<div id="toast" class="toast"></div>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(method, url, body) {
|
||||
const opt = { method, headers: { 'X-Bridge-Request': '1' } };
|
||||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opt);
|
||||
let data = null;
|
||||
try { data = await r.json(); } catch (e) {}
|
||||
if (!r.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + r.status));
|
||||
return data;
|
||||
}
|
||||
function toast(msg) {
|
||||
const t = $('toast'); t.textContent = msg; t.classList.add('show');
|
||||
clearTimeout(toast.t); toast.t = setTimeout(() => t.classList.remove('show'), 2200);
|
||||
}
|
||||
async function copyText(text) {
|
||||
try { await navigator.clipboard.writeText(text); }
|
||||
catch (e) {
|
||||
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta);
|
||||
ta.select(); document.execCommand('copy'); ta.remove();
|
||||
}
|
||||
toast('Kopiert');
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString('de-DE') : '–'; }
|
||||
function setMode(dry) {
|
||||
const m = $('mode');
|
||||
m.textContent = dry ? 'Dry-Run' : 'Schreiben aktiv';
|
||||
m.className = 'mode ' + (dry ? 'dry' : 'live');
|
||||
}
|
||||
api('GET', '/api/settings').then((s) => setMode(s.dry_run)).catch(() => {});
|
||||
</script>
|
||||
{% block script %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Einstellungen{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Einstellungen</h2>
|
||||
<form id="settings" class="panel" onsubmit="saveSettings(event)">
|
||||
<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>
|
||||
<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_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>
|
||||
</div>
|
||||
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved" class="small muted"></span></div>
|
||||
</form>
|
||||
|
||||
<h2>SSH-Schlüssel für den 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>
|
||||
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="file" id="keyfile" style="flex:1 1 220px">
|
||||
<button class="primary" onclick="saveKey()">Schlüssel speichern</button>
|
||||
<button class="danger" onclick="deleteKey()">Schlüssel löschen</button>
|
||||
</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 = {};
|
||||
|
||||
async function loadSettings() {
|
||||
current = await api('GET', '/api/settings');
|
||||
for (const f of FIELDS) $(f).value = current[f];
|
||||
$('dry_run').checked = current.dry_run;
|
||||
setMode(current.dry_run);
|
||||
}
|
||||
|
||||
async function saveSettings(ev) {
|
||||
ev.preventDefault();
|
||||
const body = {};
|
||||
for (const f of FIELDS) body[f] = $(f).value;
|
||||
body.dry_run = $('dry_run').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;
|
||||
try {
|
||||
current = await api('POST', '/api/settings', body);
|
||||
setMode(current.dry_run);
|
||||
$('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
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>
|
||||
<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'); renderKey(info); renderHost(info.host_key); }
|
||||
|
||||
$('keyfile').addEventListener('change', async (ev) => {
|
||||
const f = ev.target.files[0];
|
||||
if (f) $('keytext').value = await f.text();
|
||||
});
|
||||
|
||||
async function saveKey() {
|
||||
const key = $('keytext').value.trim();
|
||||
if (!key) { toast('Kein Schlüssel eingegeben'); return; }
|
||||
try {
|
||||
const r = await api('POST', '/api/ssh/key', { key });
|
||||
$('keytext').value = ''; $('keyfile').value = '';
|
||||
renderKey(r); toast('Schlüssel gespeichert');
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function deleteKey() {
|
||||
if (!confirm('SSH-Schlüssel löschen?')) return;
|
||||
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 testSSH() {
|
||||
$('testresult').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;
|
||||
}
|
||||
const rows = r.slots.map((s) => {
|
||||
const ch = s.channel || {};
|
||||
const c = (ch.color || '').replace(/^0x/i, '');
|
||||
return `<tr><td>Slot ${s.slot + 1}</td><td>${s.occupied ? `${/^[0-9a-f]{6}$/i.test(c) ? `<span class="swatch" style="background:#${c}"></span> ` : ''}${esc(ch.detailed_type || ch.type)} <span class="muted small">${esc(ch.manufacturer)}</span>` : '<span class="muted">leer</span>'}</td>
|
||||
<td class="mono">${esc(ch.weight)}</td><td>${s.spool_id ?? '<span class="muted">–</span>'}</td></tr>`;
|
||||
}).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>`;
|
||||
}
|
||||
$('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>`;
|
||||
}
|
||||
|
||||
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(); }
|
||||
catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
loadSettings().then(loadSSH);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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>
|
||||
<p class="small muted" id="lastpoll"></p>
|
||||
<h2>Protokoll</h2>
|
||||
<div class="panel table-wrap">
|
||||
<table><thead><tr><th style="width:170px">Zeit</th><th style="width:80px"></th><th>Aktion</th></tr></thead>
|
||||
<tbody id="log"></tbody></table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
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'] };
|
||||
|
||||
function colorOf(ch) {
|
||||
const c = (ch.color || '').replace(/^0x/i, '');
|
||||
return /^[0-9a-f]{6}$/i.test(c) ? c : null;
|
||||
}
|
||||
|
||||
function slotCard(s) {
|
||||
const ch = s.record.channel || {};
|
||||
const tray = TRAY[s.tray_status];
|
||||
const col = colorOf(ch);
|
||||
const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool;
|
||||
let tag = '<span class="muted">kein Tag</span>';
|
||||
if (s.record.occupied) {
|
||||
tag = `${col ? `<span class="swatch" style="background:#${col}"></span> ` : ''}${esc(ch.detailed_type || ch.type)}` +
|
||||
` <span class="muted small">${esc(ch.manufacturer)}, weight ${esc(ch.weight)}</span>`;
|
||||
}
|
||||
let assign = '<span class="muted">–</span>';
|
||||
if (m) {
|
||||
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 lb = s.last_booking;
|
||||
return `<div class="panel">
|
||||
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
|
||||
<dl class="kv">
|
||||
<dt>Tag</dt><dd>${tag}</dd>
|
||||
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd>
|
||||
<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>
|
||||
</dl>
|
||||
${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>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function slotAction(slot, action) {
|
||||
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?`;
|
||||
if (!confirm(text)) return;
|
||||
try { await api('POST', `/api/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;
|
||||
try {
|
||||
const r = await api('POST', '/api/cc2/enable', { confirm: true });
|
||||
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
|
||||
setTimeout(load, 1500);
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let st;
|
||||
try { st = await api('GET', '/api/status'); } catch (e) { $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
setMode(st.dry_run);
|
||||
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>`);
|
||||
$('banners').innerHTML = b.join('');
|
||||
if (st.slots) $('slots').innerHTML = st.slots.map(slotCard).join('');
|
||||
$('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, ''];
|
||||
return `<tr><td class="small muted">${fmtTime(e.time)}</td><td><span class="tag ${l[1]}">${l[0]}</span></td><td>${esc(e.msg)}</td></tr>`;
|
||||
}).join('') || '<tr><td colspan="3" class="muted">Noch keine Einträge</td></tr>';
|
||||
}
|
||||
load();
|
||||
setInterval(load, 3000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,171 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Tag-Generator{% endblock %}
|
||||
{% block content %}
|
||||
<div class="banner err"><b>Wichtig:</b> In NFC Tools nie „Lock tag“ oder „Set password“ verwenden – der Tag wäre danach nicht mehr beschreibbar.</div>
|
||||
<h2>Spoolman-Spulen</h2>
|
||||
<div class="panel">
|
||||
<div class="row" style="margin-bottom:10px">
|
||||
<div class="field" style="margin:0"><label for="filter">Filter</label><input id="filter" placeholder="Label, Hersteller, Material …"></div>
|
||||
<label style="flex:0 0 auto;margin:0 0 8px"><input type="checkbox" id="archived" style="width:auto"> archivierte zeigen</label>
|
||||
</div>
|
||||
<div class="table-wrap" style="max-height:340px;overflow-y:auto">
|
||||
<table><thead><tr><th>ID</th><th>Label</th><th>Hersteller</th><th>Material</th><th>Farbe</th><th>Rest</th><th>Ort</th><th></th></tr></thead>
|
||||
<tbody id="spools"><tr><td colspan="8" class="muted">Lade …</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editor" style="display:none">
|
||||
<h2 id="edtitle">Tag</h2>
|
||||
<div class="panel">
|
||||
<div class="row">
|
||||
<div class="field"><label for="material">Elegoo-Material</label><select id="material"></select></div>
|
||||
<div class="field"><label for="subtype">Untertyp (wählt das Slicer-Profil)</label><select id="subtype"></select></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field"><label for="color">Tag-Farbe (RRGGBB)</label>
|
||||
<div class="row" style="gap:6px;flex-wrap:nowrap"><input id="color" maxlength="7" class="mono"><input type="color" id="picker" style="width:48px;padding:2px"></div></div>
|
||||
<div class="field"><label for="temp_min">Düse min (°C)</label><input id="temp_min" type="number" min="0" max="500"></div>
|
||||
<div class="field"><label for="temp_max">Düse max (°C)</label><input id="temp_max" type="number" min="0" max="500"></div>
|
||||
<div class="field"><label for="finish">Finish-Byte (hex)</label><input id="finish" maxlength="2" class="mono" value="FF"></div>
|
||||
</div>
|
||||
<div class="field"><label>Display-Palette</label><div id="palette" style="display:flex;flex-wrap:wrap;gap:4px"></div>
|
||||
<div class="small muted" id="colorhint" style="margin-top:4px"></div></div>
|
||||
<div class="field"><label>URL auf dem Tag (nur fürs Handy, der Drucker ignoriert sie)</label>
|
||||
<label style="color:var(--text)"><input type="radio" name="url" value="elegoo" checked style="width:auto"> <code>https://www.elegoo.com</code> (verifizierte Vorlage)</label>
|
||||
<label style="color:var(--text)"><input type="radio" name="url" value="spoolman" style="width:auto"> Link auf die Spule in Spoolman <span class="muted">(nicht am Drucker getestet)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Befehlskette für NFC Tools</h2>
|
||||
<div class="panel">
|
||||
<div id="tagerror"></div>
|
||||
<textarea id="commands" rows="5" readonly></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="primary" onclick="copyText($('commands').value)">Kopieren</button>
|
||||
<label style="margin:0;color:var(--text)"><input type="checkbox" id="showqr" style="width:auto"> QR-Code zeigen</label>
|
||||
</div>
|
||||
<div id="qr" style="display:none;margin-top:10px;background:#fff;padding:8px;border-radius:6px;width:max-content;max-width:100%;overflow:auto"></div>
|
||||
<ol class="small muted" style="margin:14px 0 0;padding-left:20px">
|
||||
<li>NFC Tools → <i>Other</i> → <i>Advanced NFC commands</i>.</li>
|
||||
<li>Befehlskette einfügen, leeren NTAG213/215/216 ans Handy halten, ausführen.</li>
|
||||
<li>Spule in die Canvas legen und den Scan am Display bestätigen.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>Dekodier-Vorschau</h2>
|
||||
<div class="panel"><dl class="kv" id="decoded"></dl></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
let META = null, SPOOLS = [], current = null, timer = null, spoolmanBase = '';
|
||||
|
||||
async function init() {
|
||||
META = await api('GET', '/api/materials');
|
||||
$('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
|
||||
$('palette').innerHTML = META.palette.map((c) =>
|
||||
`<button type="button" title="#${c}" onclick="setColor('${c}')" style="width:26px;height:26px;padding:0;background:#${c}"></button>`).join('');
|
||||
await loadSpools();
|
||||
}
|
||||
|
||||
async function loadSpools() {
|
||||
try {
|
||||
const r = await api('GET', '/api/spoolman/spools' + ($('archived').checked ? '?archived=true' : ''));
|
||||
SPOOLS = r.spools;
|
||||
} catch (e) {
|
||||
$('spools').innerHTML = `<tr><td colspan="8"><div class="banner err" style="margin:0">${esc(e.message)}</div></td></tr>`;
|
||||
return;
|
||||
}
|
||||
renderSpools();
|
||||
}
|
||||
|
||||
function renderSpools() {
|
||||
const q = $('filter').value.toLowerCase();
|
||||
const rows = SPOOLS.filter((s) => !q || [s.label, s.vendor, s.material, s.name, s.location].join(' ').toLowerCase().includes(q));
|
||||
$('spools').innerHTML = rows.map((s) => `<tr>
|
||||
<td>${s.id}</td><td><b>${esc(s.label)}</b></td><td>${esc(s.vendor)}</td><td>${esc(s.material)} <span class="muted small">${esc(s.name)}</span></td>
|
||||
<td>${s.color ? `<span class="swatch" style="background:#${s.color}"></span> <span class="mono small">${s.color}</span>` : '–'}</td>
|
||||
<td>${s.remaining != null ? Math.round(s.remaining) + ' g' : '–'}</td><td class="small">${esc(s.location)}</td>
|
||||
<td><button onclick="pick(${s.id})">Tag erstellen</button></td></tr>`).join('')
|
||||
|| '<tr><td colspan="8" class="muted">Keine Spulen</td></tr>';
|
||||
}
|
||||
|
||||
function fillSubtypes(material, selected) {
|
||||
const list = META.subtypes.filter((s) => s.material === material);
|
||||
$('subtype').innerHTML = list.map((s) =>
|
||||
`<option value="${s.code}" ${s.code === selected ? 'selected' : ''}>${esc(s.name)} (0x${s.code.toString(16).padStart(4, '0').toUpperCase()})${s.hidden ? ' – am CC2 ausgeblendet' : ''}</option>`).join('');
|
||||
}
|
||||
|
||||
function setColor(c) {
|
||||
c = c.replace('#', '').toUpperCase();
|
||||
$('color').value = c;
|
||||
if (/^[0-9A-F]{6}$/.test(c)) $('picker').value = '#' + c;
|
||||
schedule();
|
||||
}
|
||||
|
||||
function pick(id) {
|
||||
current = SPOOLS.find((s) => s.id === id);
|
||||
const sg = current.suggest;
|
||||
$('edtitle').textContent = `Tag für ${current.label} (Spoolman #${current.id})`;
|
||||
$('material').value = sg.material;
|
||||
fillSubtypes(sg.material, sg.subtype);
|
||||
$('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max;
|
||||
$('finish').value = 'FF';
|
||||
$('colorhint').innerHTML = current.color
|
||||
? `Spoolman-Farbe <span class="swatch" style="background:#${current.color}"></span> <span class="mono">${current.color}</span> → Vorschlag <span class="mono">${sg.color}</span>`
|
||||
: 'Spoolman-Spule hat keine Farbe.';
|
||||
setColor(sg.color);
|
||||
$('editor').style.display = '';
|
||||
$('editor').scrollIntoView({ behavior: 'smooth' });
|
||||
generate();
|
||||
}
|
||||
|
||||
function schedule() { clearTimeout(timer); timer = setTimeout(generate, 250); }
|
||||
|
||||
async function generate() {
|
||||
if (!current) return;
|
||||
const useSpoolman = document.querySelector('input[name=url]:checked').value === 'spoolman';
|
||||
const body = {
|
||||
spool_id: current.id, material: $('material').value, subtype: Number($('subtype').value),
|
||||
color: $('color').value, temp_min: Number($('temp_min').value), temp_max: Number($('temp_max').value),
|
||||
finish: parseInt($('finish').value || 'FF', 16), diameter: current.diameter || 1.75,
|
||||
url: useSpoolman ? current.spoolman_url : null,
|
||||
};
|
||||
try {
|
||||
const r = await api('POST', '/api/tag', body);
|
||||
$('tagerror').innerHTML = '';
|
||||
$('commands').value = r.commands;
|
||||
$('qr').innerHTML = r.qr_svg;
|
||||
const d = r.decoded;
|
||||
$('decoded').innerHTML = [
|
||||
['Spoolman-ID', `${d.spool_id} <span class="muted small">(weight ${d.weight})</span>`],
|
||||
['Hersteller', `${esc(d.manufacturer)} <span class="muted small">(Drucker zeigt ELEGOO)</span>`],
|
||||
['Material', esc(d.material)],
|
||||
['Untertyp', `${esc(d.subtype_name)} <span class="mono small">0x${d.subtype.toString(16).padStart(4, '0').toUpperCase()}</span>`],
|
||||
['Farbe', `<span class="swatch" style="background:#${d.color}"></span> <span class="mono">${d.color}</span>, Finish ${d.finish.toString(16).toUpperCase()}`],
|
||||
['Düse', `${d.temp_min}–${d.temp_max} °C`],
|
||||
['Durchmesser', `${d.diameter.toFixed(2)} mm`],
|
||||
['URL', esc(d.url || '–')],
|
||||
].map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
|
||||
} catch (e) {
|
||||
$('tagerror').innerHTML = `<div class="banner err">${esc(e.message)}</div>`;
|
||||
$('commands').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
$('material').addEventListener('change', () => {
|
||||
const m = $('material').value;
|
||||
fillSubtypes(m, META.subtypes.find((s) => s.material === m).code);
|
||||
const t = META.materials[m].temps; $('temp_min').value = t[0]; $('temp_max').value = t[1];
|
||||
schedule();
|
||||
});
|
||||
for (const id of ['subtype', 'temp_min', 'temp_max', 'finish']) $(id).addEventListener('input', schedule);
|
||||
$('color').addEventListener('input', () => { const c = $('color').value.replace('#', ''); if (/^[0-9a-f]{6}$/i.test(c)) $('picker').value = '#' + c; schedule(); });
|
||||
$('picker').addEventListener('input', () => setColor($('picker').value));
|
||||
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
|
||||
$('showqr').addEventListener('change', () => { $('qr').style.display = $('showqr').checked ? '' : 'none'; });
|
||||
$('filter').addEventListener('input', renderSpools);
|
||||
$('archived').addEventListener('change', loadSpools);
|
||||
init();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
"""Web UI and JSON API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
import segno
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from . import materials, tagcodec
|
||||
from .bridge import Bridge
|
||||
from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint
|
||||
from .spoolman import SpoolmanError, summary
|
||||
from .storage import SettingsStore
|
||||
|
||||
log = logging.getLogger("web")
|
||||
HERE = Path(__file__).parent
|
||||
VERSION = os.environ.get("APP_VERSION", "dev")
|
||||
|
||||
|
||||
def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, start_loop: bool = True,
|
||||
env: dict | None = None) -> FastAPI:
|
||||
env = os.environ if env is None else env
|
||||
data_dir = Path(data_dir or env.get("DATA_DIR", "/data"))
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings = bridge.settings_store if bridge else SettingsStore(data_dir, env)
|
||||
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
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
thread = None
|
||||
if start_loop:
|
||||
thread = threading.Thread(target=bridge.run, args=(stop,), name="bridge", daemon=True)
|
||||
thread.start()
|
||||
yield
|
||||
stop.set()
|
||||
bridge.wake.set()
|
||||
if thread:
|
||||
thread.join(timeout=15)
|
||||
|
||||
app = FastAPI(title="CC2 Spoolman Bridge", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.state.bridge = bridge
|
||||
templates = Jinja2Templates(directory=str(HERE / "templates"))
|
||||
|
||||
user, password = env.get("BRIDGE_USER", ""), env.get("BRIDGE_PASSWORD", "")
|
||||
|
||||
@app.middleware("http")
|
||||
async def guard(request: Request, call_next):
|
||||
if user and password and request.url.path != "/healthz":
|
||||
ok = False
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.lower().startswith("basic "):
|
||||
try:
|
||||
u, _, p = base64.b64decode(auth[6:]).decode("utf-8").partition(":")
|
||||
ok = hmac.compare_digest(u, user) & hmac.compare_digest(p, password)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
ok = False
|
||||
if not ok:
|
||||
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="CC2 Bridge"'})
|
||||
# Writes only from our own pages: a custom header cannot be sent cross-site without CORS.
|
||||
if request.method not in ("GET", "HEAD") and request.headers.get("x-bridge-request") != "1":
|
||||
return JSONResponse({"error": "Fehlender X-Bridge-Request-Header"}, status_code=403)
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
return response
|
||||
|
||||
def page(request: Request, name: str, **ctx):
|
||||
return templates.TemplateResponse(request, name, {"version": VERSION, "active": name, **ctx})
|
||||
|
||||
# ---------- pages ----------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
return page(request, "status.html")
|
||||
|
||||
@app.get("/tags", response_class=HTMLResponse)
|
||||
def tags(request: Request):
|
||||
return page(request, "tags.html")
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request):
|
||||
return page(request, "settings.html")
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"ok": True, "version": VERSION, "last_poll": bridge.snapshot.get("last_poll")}
|
||||
|
||||
# ---------- status ----------
|
||||
|
||||
@app.get("/api/status")
|
||||
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):
|
||||
body = await _json(request)
|
||||
if body.get("confirm") is not True:
|
||||
raise HTTPException(400, "Bestätigung fehlt")
|
||||
bridge.commands.put(("enable", 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):
|
||||
if slot not in range(4) or action not in ("reassign", "release"):
|
||||
raise HTTPException(404)
|
||||
bridge.commands.put((action, slot))
|
||||
bridge.wake.set()
|
||||
return {"ok": True}
|
||||
|
||||
# ---------- settings ----------
|
||||
|
||||
@app.get("/api/settings")
|
||||
def get_settings():
|
||||
return settings.get().to_dict()
|
||||
|
||||
@app.post("/api/settings")
|
||||
async def post_settings(request: Request):
|
||||
body = await _json(request)
|
||||
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
|
||||
bridge.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
|
||||
bridge.wake.set()
|
||||
return s.to_dict()
|
||||
|
||||
# ---------- SSH ----------
|
||||
|
||||
@app.get("/api/ssh")
|
||||
def ssh_info():
|
||||
return {"key": keys.key_info(), "host_key": keys.host_key()}
|
||||
|
||||
@app.post("/api/ssh/key")
|
||||
async def ssh_key(request: Request):
|
||||
body = await _json(request)
|
||||
text = body.get("key")
|
||||
if not isinstance(text, str) or len(text) > 20000:
|
||||
raise HTTPException(400, "Schlüssel fehlt")
|
||||
try:
|
||||
keys.save_key(text)
|
||||
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()
|
||||
return {"key": keys.key_info()}
|
||||
|
||||
@app.delete("/api/ssh/key")
|
||||
def ssh_key_delete():
|
||||
keys.delete_key()
|
||||
if bridge.reader:
|
||||
bridge.reader.close()
|
||||
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)
|
||||
try:
|
||||
result = reader.test()
|
||||
except HostKeyMismatch as e:
|
||||
return JSONResponse({"ok": False, "error": str(e), "host_key": {"fingerprint": fingerprint(e.key)}},
|
||||
status_code=200)
|
||||
except SSHError as e:
|
||||
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)
|
||||
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):
|
||||
body = await _json(request)
|
||||
hk = tested_host_keys.get(body.get("fingerprint"))
|
||||
if not hk:
|
||||
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']})")
|
||||
bridge.wake.set()
|
||||
return {"host_key": keys.host_key()}
|
||||
|
||||
@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")
|
||||
return {"host_key": None}
|
||||
|
||||
# ---------- tag generator ----------
|
||||
|
||||
@app.get("/api/materials")
|
||||
def get_materials():
|
||||
return {
|
||||
"materials": {k: {"temps": v["temps"], "code": f"{v['code']:08X}"} for k, v in materials.MATERIALS.items()},
|
||||
"subtypes": [{"code": c, "name": n, "hidden": h, "material": materials.material_of_subtype(c)}
|
||||
for c, n, h in materials.SUBTYPES],
|
||||
"palette": tagcodec.PALETTE,
|
||||
}
|
||||
|
||||
@app.get("/api/spoolman/spools")
|
||||
def spoolman_spools(archived: bool = False):
|
||||
s = settings.get()
|
||||
client = bridge.spoolman_factory(s)
|
||||
try:
|
||||
spools = client.spools(archived=archived)
|
||||
except SpoolmanError as e:
|
||||
raise HTTPException(502, str(e)) from None
|
||||
finally:
|
||||
client.close()
|
||||
out = []
|
||||
for sp in spools:
|
||||
info = summary(sp)
|
||||
sug = materials.suggest(info["material"], info["name"])
|
||||
info["suggest"] = {
|
||||
"material": sug["material"], "subtype": sug["subtype"],
|
||||
"temp_min": sug["temps"][0], "temp_max": sug["temps"][1],
|
||||
"color": tagcodec.nearest_palette_color(info["color"]),
|
||||
}
|
||||
info["spoolman_url"] = f"{s.spoolman_url}/spool/show/{info['id']}"
|
||||
out.append(info)
|
||||
out.sort(key=lambda x: x["id"])
|
||||
return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset}
|
||||
|
||||
@app.post("/api/tag")
|
||||
async def tag(request: Request):
|
||||
body = await _json(request)
|
||||
s = settings.get()
|
||||
try:
|
||||
data = tagcodec.TagData(
|
||||
spool_id=int(body["spool_id"]),
|
||||
color=str(body["color"]),
|
||||
material=str(body["material"]),
|
||||
subtype=int(body["subtype"]),
|
||||
temp_min=int(body["temp_min"]),
|
||||
temp_max=int(body["temp_max"]),
|
||||
diameter=float(body.get("diameter") or 1.75),
|
||||
finish=int(body.get("finish", 0xFF)),
|
||||
id_offset=s.id_offset,
|
||||
url=str(body.get("url") or tagcodec.DEFAULT_URL),
|
||||
)
|
||||
commands = tagcodec.encode_commands(data)
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
raise HTTPException(400, str(e) if isinstance(e, tagcodec.TagError) else f"Ungültige Eingabe ({e})") from None
|
||||
decoded = tagcodec.decode_pages(tagcodec.parse_commands(commands), s.id_offset)
|
||||
qr = segno.make(commands, error="l").svg_inline(scale=3, border=2, dark="#000", light="#fff")
|
||||
return {"commands": commands, "decoded": decoded, "qr_svg": qr}
|
||||
|
||||
@app.post("/api/tag/decode")
|
||||
async def tag_decode(request: Request):
|
||||
body = await _json(request)
|
||||
try:
|
||||
pages = tagcodec.parse_commands(str(body.get("commands", "")))
|
||||
except tagcodec.TagError as e:
|
||||
raise HTTPException(400, str(e)) from None
|
||||
return tagcodec.decode_pages(pages, settings.get().id_offset)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _json(request: Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Ungültiges JSON") from None
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Ungültiges JSON")
|
||||
return body
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logging.getLogger("paramiko").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
uvicorn.run(create_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "5066")),
|
||||
log_level="warning", proxy_headers=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
# Beispiel für den Docker-Host 10.10.6.10 (neben Spoolman).
|
||||
services:
|
||||
cc2-spoolman-bridge:
|
||||
image: ${BRIDGE_IMAGE:-git.menzel.center/menzeljonas/cc2-spoolman-bridge:latest}
|
||||
container_name: cc2-spoolman-bridge
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- "5066:5066"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
@@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -q
|
||||
filterwarnings =
|
||||
ignore::DeprecationWarning:fastapi.testclient
|
||||
ignore:Using `httpx`
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.162.0
|
||||
uvicorn==0.54.0
|
||||
jinja2==3.1.6
|
||||
httpx==0.28.1
|
||||
paramiko==5.1.0
|
||||
segno==1.6.6
|
||||
Whitespace-only changes.
+159
@@ -0,0 +1,159 @@
|
||||
"""In-memory stand-ins for CC2 Control, Spoolman and the SSH record reader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import itertools
|
||||
|
||||
from app.cc2control import CC2Error
|
||||
from app.spoolman import SpoolmanError, SpoolmanNotFound
|
||||
|
||||
|
||||
def channel(weight="", type_="PLA", detailed="PLA+", color="0x000000"):
|
||||
if weight == "":
|
||||
return {k: "" for k in ("brand", "code", "color", "date", "detailed_type", "diameter", "manufacturer",
|
||||
"nozzle_max_temp", "nozzle_min_temp", "type", "weight")}
|
||||
return {"brand": "0xEEEEEEEE", "code": "0x0001", "color": color, "date": "2000/00", "detailed_type": detailed,
|
||||
"diameter": "1.750000", "manufacturer": "ELEGOO", "nozzle_max_temp": "230", "nozzle_min_temp": "190",
|
||||
"type": type_, "weight": str(weight)}
|
||||
|
||||
|
||||
def record(*weights):
|
||||
rec = {"auto_plug_in_enable": 0, "color_table_size": 0, "extern_channel": {}}
|
||||
for n in range(4):
|
||||
w = weights[n] if n < len(weights) else ""
|
||||
rec[f"channels{n}"] = w if isinstance(w, dict) else channel(w)
|
||||
return rec
|
||||
|
||||
|
||||
class FakeReader:
|
||||
def __init__(self, rec=None):
|
||||
self.record = rec or record()
|
||||
self.fail = False
|
||||
|
||||
def read(self):
|
||||
from app.sshsource import SSHError
|
||||
if self.fail:
|
||||
raise SSHError("Drucker nicht erreichbar")
|
||||
return copy.deepcopy(self.record)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeCC2:
|
||||
def __init__(self, enabled=True):
|
||||
self.enabled = enabled
|
||||
self.slots = [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)]
|
||||
self.slots[4]["printer"] = None
|
||||
self.library: list[dict] = []
|
||||
self.job = {"active": False}
|
||||
self.calls: list[tuple] = []
|
||||
self.fail = False
|
||||
self._ids = (f"s{i:04d}" for i in itertools.count(1))
|
||||
|
||||
def _check(self):
|
||||
if self.fail:
|
||||
raise CC2Error("CC2 Control nicht erreichbar")
|
||||
|
||||
def spools_json(self):
|
||||
return {"enabled": self.enabled, "slots": self.slots, "spools": self.library, "job": self.job, "log": []}
|
||||
|
||||
def spools(self):
|
||||
self._check()
|
||||
return copy.deepcopy(self.spools_json())
|
||||
|
||||
def canvas(self):
|
||||
self._check()
|
||||
return {}
|
||||
|
||||
def save_spool(self, fields):
|
||||
self._check()
|
||||
self.calls.append(("save", dict(fields)))
|
||||
if fields.get("id"):
|
||||
sp = next(s for s in self.library if s["id"] == fields["id"])
|
||||
if "archived" in fields:
|
||||
sp["archived"] = fields["archived"] in ("1", 1, True)
|
||||
return {"ok": True, "id": sp["id"]}
|
||||
sp = {"id": next(self._ids), "name": fields.get("name", ""), "brand": fields.get("brand", ""),
|
||||
"material": fields["material"], "color": fields["color"], "diameter": fields.get("diameter", 1.75),
|
||||
"density": fields.get("density", 1.24), "net": fields.get("net", 1000),
|
||||
"remaining": fields.get("remaining", fields.get("net", 1000)), "tare": fields.get("tare", 0),
|
||||
"low": 0, "price": 0, "note": fields.get("note", ""), "created": 0, "used": 0, "archived": False}
|
||||
self.library.append(sp)
|
||||
return {"ok": True, "id": sp["id"]}
|
||||
|
||||
def assign(self, slot, spool_id):
|
||||
self._check()
|
||||
self.calls.append(("assign", slot, spool_id))
|
||||
for s in self.slots:
|
||||
if spool_id and s["spool"] == spool_id:
|
||||
s["spool"] = ""
|
||||
self.slots[slot]["spool"] = spool_id or ""
|
||||
self.slots[slot]["question"] = ""
|
||||
return {"ok": True}
|
||||
|
||||
def enable(self, on):
|
||||
self._check()
|
||||
self.calls.append(("enable", on))
|
||||
self.enabled = on
|
||||
return {"ok": True, "enabled": on}
|
||||
|
||||
def consume(self, cc2_id, grams):
|
||||
sp = next(s for s in self.library if s["id"] == cc2_id)
|
||||
sp["remaining"] -= grams
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeSpoolman:
|
||||
def __init__(self):
|
||||
self.db: dict[int, dict] = {}
|
||||
self.calls: list[tuple] = []
|
||||
self.fail = False
|
||||
self.fail_use_after_apply = False
|
||||
|
||||
def add(self, sid, vendor="SUNLU", material="PLA+", color="000000", remaining=800.0, lot=None, **extra):
|
||||
self.db[sid] = {
|
||||
"id": sid, "lot_nr": lot if lot is not None else f"{vendor.upper()}{sid:03d}",
|
||||
"remaining_weight": remaining, "initial_weight": 1000.0, "spool_weight": 150.0, "used_weight": 0.0,
|
||||
"location": "", "archived": False,
|
||||
"filament": {"id": sid, "name": f"{material} {color}", "material": material, "color_hex": color,
|
||||
"diameter": 1.75, "density": 1.24, "weight": 1000.0, "vendor": {"name": vendor}},
|
||||
**extra,
|
||||
}
|
||||
return self.db[sid]
|
||||
|
||||
def _check(self):
|
||||
if self.fail:
|
||||
raise SpoolmanError("Spoolman nicht erreichbar")
|
||||
|
||||
def spool(self, sid):
|
||||
self._check()
|
||||
if sid not in self.db:
|
||||
raise SpoolmanNotFound("nicht gefunden")
|
||||
return copy.deepcopy(self.db[sid])
|
||||
|
||||
def spools(self, archived=False):
|
||||
self._check()
|
||||
return [copy.deepcopy(s) for s in self.db.values() if archived or not s["archived"]]
|
||||
|
||||
def set_location(self, sid, location):
|
||||
self._check()
|
||||
self.calls.append(("location", sid, location))
|
||||
self.db[sid]["location"] = location
|
||||
return self.db[sid]
|
||||
|
||||
def use(self, sid, grams):
|
||||
self._check()
|
||||
self.calls.append(("use", sid, round(grams, 3)))
|
||||
sp = self.db[sid]
|
||||
sp["used_weight"] += grams
|
||||
sp["remaining_weight"] -= grams
|
||||
if self.fail_use_after_apply:
|
||||
raise SpoolmanError("Timeout")
|
||||
return sp
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
@@ -0,0 +1,340 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.bridge import Bridge, cc2_spool_fields, material_fits
|
||||
from app.sshsource import KeyStore
|
||||
from app.storage import SettingsStore
|
||||
|
||||
from .fakes import FakeCC2, FakeReader, FakeSpoolman, channel, record
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1_800_000_000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
def tick(self, s):
|
||||
self.t += s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path):
|
||||
return make_env(tmp_path)
|
||||
|
||||
|
||||
def make_env(tmp_path, dry_run=False, reader=None, cc2=None, spoolman=None, clock=None):
|
||||
settings = SettingsStore(tmp_path, env={})
|
||||
settings.save({"dry_run": dry_run, "settle_seconds": 30, "empty_grace": 20})
|
||||
reader = reader or FakeReader()
|
||||
cc2 = cc2 or FakeCC2()
|
||||
sm = spoolman or FakeSpoolman()
|
||||
clock = clock or Clock()
|
||||
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda s: reader,
|
||||
cc2_factory=lambda s: cc2, spoolman_factory=lambda s: sm, clock=clock)
|
||||
return {"bridge": bridge, "reader": reader, "cc2": cc2, "sm": sm, "clock": clock, "settings": settings,
|
||||
"path": tmp_path}
|
||||
|
||||
|
||||
def insert(e, slot, spool_id, **kw):
|
||||
"""Put a tagged spool into a slot: tray loaded and the record carries the id."""
|
||||
e["cc2"].slots[slot]["printer"] = {"status": 1}
|
||||
e["cc2"].slots[slot]["question"] = "inserted"
|
||||
e["reader"].record[f"channels{slot}"] = channel(10000 + spool_id, **kw)
|
||||
|
||||
|
||||
def poll(e, n=1, step=5):
|
||||
for _ in range(n):
|
||||
e["bridge"].poll_once()
|
||||
e["clock"].tick(step)
|
||||
|
||||
|
||||
def test_scan_creates_assigns_and_sets_location(env):
|
||||
env["sm"].add(5, vendor="Sunlu", material="PLA+", color="1E40AF", remaining=812.5)
|
||||
insert(env, 1, 5)
|
||||
poll(env)
|
||||
cc2 = env["cc2"]
|
||||
saves = [c for c in cc2.calls if c[0] == "save"]
|
||||
assert len(saves) == 1
|
||||
f = saves[0][1]
|
||||
assert f["name"] == "SUNLU005" and f["brand"] == "Sunlu" and f["material"] == "PLA+"
|
||||
assert f["color"] == "#1E40AF" and f["note"] == "spoolman:5" and f["remaining"] == 812.5
|
||||
assert f["net"] == 1000 and f["tare"] == 150 and f["diameter"] == 1.75 and f["density"] == 1.24
|
||||
assert ("assign", 1, "s0001") in cc2.calls
|
||||
assert cc2.slots[1]["spool"] == "s0001" and cc2.slots[1]["question"] == ""
|
||||
assert ("location", 5, "CC2 Slot 2") in env["sm"].calls
|
||||
poll(env, 3)
|
||||
# nothing repeated
|
||||
assert len([c for c in cc2.calls if c[0] in ("save", "assign")]) == 2
|
||||
assert len([c for c in env["sm"].calls if c[0] == "location"]) == 1
|
||||
|
||||
|
||||
def test_existing_mirror_is_reused(env):
|
||||
env["sm"].add(5)
|
||||
env["cc2"].save_spool({"name": "SUNLU005", "material": "PLA+", "color": "#000000", "note": "spoolman:5"})
|
||||
env["cc2"].calls.clear()
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
assert [c for c in env["cc2"].calls if c[0] == "save"] == []
|
||||
assert ("assign", 0, "s0001") in env["cc2"].calls
|
||||
|
||||
|
||||
def test_unknown_spool_id_is_ignored(env):
|
||||
insert(env, 0, 42)
|
||||
poll(env)
|
||||
assert env["cc2"].calls == []
|
||||
assert env["bridge"].state.data["slots"] == {}
|
||||
assert any("42 existiert nicht" in e["msg"] for e in env["bridge"].log.recent())
|
||||
|
||||
|
||||
def test_real_weight_tags_are_ignored(env):
|
||||
env["reader"].record["channels0"] = channel(1000)
|
||||
env["cc2"].slots[0]["printer"] = {"status": 1}
|
||||
poll(env, 2)
|
||||
assert env["cc2"].calls == [] and env["bridge"].state.data["slots"] == {}
|
||||
|
||||
|
||||
def test_mapping_survives_weight_falling_back_to_1000(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 2, 5)
|
||||
poll(env)
|
||||
# touchscreen edit: weight falls back, CC2 asks again ("changed") with no spool in the slot
|
||||
env["reader"].record["channels2"] = channel(1000)
|
||||
env["cc2"].slots[2]["spool"] = ""
|
||||
env["cc2"].slots[2]["question"] = "changed"
|
||||
poll(env, 10)
|
||||
m = env["bridge"].state.data["slots"]["2"]
|
||||
assert m["spool"] == 5
|
||||
assert env["sm"].db[5]["location"] == "CC2 Slot 3"
|
||||
|
||||
|
||||
def test_mapping_survives_restart(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
env["reader"].record["channels0"] = channel(1000)
|
||||
poll(env)
|
||||
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
|
||||
poll(e2, 3)
|
||||
assert e2["bridge"].state.data["slots"]["0"]["spool"] == 5
|
||||
assert len([c for c in env["cc2"].calls if c[0] == "save"]) == 1
|
||||
|
||||
|
||||
def test_empty_slot_releases_to_storage_after_grace(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
env["cc2"].slots[0]["printer"] = {"status": 0}
|
||||
poll(env, 3, step=5) # 0, 5, 10 s
|
||||
assert "0" in env["bridge"].state.data["slots"]
|
||||
poll(env, 3, step=5) # >= 20 s
|
||||
assert "0" not in env["bridge"].state.data["slots"]
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
|
||||
|
||||
def test_cc2_returning_spool_to_storage_releases(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
env["cc2"].slots[0]["spool"] = "" # CC2 gave it back to storage (no question)
|
||||
poll(env)
|
||||
assert "0" not in env["bridge"].state.data["slots"]
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
|
||||
|
||||
def test_new_scan_with_other_id_replaces_mapping(env):
|
||||
env["sm"].add(5)
|
||||
env["sm"].add(8, color="F88D36")
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
insert(env, 0, 8, color="0xF88D36")
|
||||
poll(env, 2)
|
||||
assert env["bridge"].state.data["slots"]["0"]["spool"] == 8
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
assert env["sm"].db[8]["location"] == "CC2 Slot 1"
|
||||
assert env["cc2"].slots[0]["spool"] == "s0002"
|
||||
|
||||
|
||||
def test_spool_moved_to_other_slot(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
env["reader"].record["channels0"] = channel("")
|
||||
insert(env, 3, 5)
|
||||
poll(env, 2)
|
||||
slots = env["bridge"].state.data["slots"]
|
||||
assert "0" not in slots and slots["3"]["spool"] == 5
|
||||
assert env["sm"].db[5]["location"] == "CC2 Slot 4"
|
||||
|
||||
|
||||
def test_consumption_booked_after_print_once(env):
|
||||
env["sm"].add(5, remaining=800)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
cc2 = env["cc2"]
|
||||
cc2.job = {"active": True}
|
||||
for _ in range(10):
|
||||
cc2.consume("s0001", 1.25)
|
||||
poll(env)
|
||||
assert not [c for c in env["sm"].calls if c[0] == "use"] # not during the print
|
||||
cc2.job = {"active": False}
|
||||
poll(env, 3, step=5)
|
||||
assert not [c for c in env["sm"].calls if c[0] == "use"] # waits for settle_seconds
|
||||
poll(env, 6, step=5)
|
||||
uses = [c for c in env["sm"].calls if c[0] == "use"]
|
||||
assert uses == [("use", 5, 12.5)]
|
||||
poll(env, 5)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == uses
|
||||
|
||||
|
||||
def test_no_double_booking_after_restart(env):
|
||||
env["sm"].add(5, remaining=800)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
env["cc2"].consume("s0001", 3.25)
|
||||
poll(env, 10)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 3.25)]
|
||||
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
|
||||
poll(e2, 10)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 3.25)]
|
||||
env["cc2"].consume("s0001", 1.0)
|
||||
poll(e2, 10)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 3.25), ("use", 5, 1.0)]
|
||||
|
||||
|
||||
def test_interrupted_booking_is_not_repeated(env):
|
||||
env["sm"].add(5, remaining=800)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
env["cc2"].consume("s0001", 4.0)
|
||||
env["sm"].fail_use_after_apply = True # PUT reached Spoolman, answer lost
|
||||
poll(env, 10)
|
||||
env["sm"].fail_use_after_apply = False
|
||||
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
|
||||
poll(e2, 10)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 4.0)]
|
||||
assert env["sm"].db[5]["used_weight"] == 4.0
|
||||
b = e2["bridge"].state.data["booked"]["s0001"]
|
||||
assert b["pending"] is None and b["remaining"] == 796.0
|
||||
|
||||
|
||||
def test_interrupted_booking_retried_when_not_applied(env):
|
||||
env["sm"].add(5, remaining=800)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
env["cc2"].consume("s0001", 4.0)
|
||||
poll(env, 7) # settles, but Spoolman is down when booking
|
||||
env["sm"].fail = True
|
||||
poll(env, 3)
|
||||
env["sm"].fail = False
|
||||
poll(env, 3)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 4.0)]
|
||||
|
||||
|
||||
def test_negative_change_logged_not_booked(env):
|
||||
env["sm"].add(5, remaining=800)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
env["cc2"].consume("s0001", -20) # weigh-in raised the remaining weight
|
||||
poll(env, 10)
|
||||
assert not [c for c in env["sm"].calls if c[0] == "use"]
|
||||
assert any("gestiegen" in e["msg"] for e in env["bridge"].log.recent())
|
||||
env["cc2"].consume("s0001", 5)
|
||||
poll(env, 10)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 5.0)]
|
||||
|
||||
|
||||
def test_dry_run_writes_nothing(tmp_path):
|
||||
e = make_env(tmp_path, dry_run=True)
|
||||
e["sm"].add(5)
|
||||
e["cc2"].save_spool({"name": "X", "material": "PLA", "color": "#000000", "note": "spoolman:7"})
|
||||
e["sm"].add(7)
|
||||
e["cc2"].calls.clear()
|
||||
insert(e, 0, 5)
|
||||
poll(e, 3)
|
||||
e["cc2"].consume("s0001", 10)
|
||||
poll(e, 20)
|
||||
assert e["cc2"].calls == []
|
||||
assert e["sm"].calls == []
|
||||
msgs = [x["msg"] for x in e["bridge"].log.recent(500) if x["level"] == "dry"]
|
||||
assert any("Spule SUNLU005 anlegen" in m for m in msgs)
|
||||
assert any("Ort von" in m for m in msgs)
|
||||
assert any("10.00 g" in m for m in msgs)
|
||||
# logged once, not every cycle
|
||||
assert len([m for m in msgs if "10.00 g" in m]) == 1
|
||||
# switching dry-run off applies what is pending
|
||||
e["settings"].save({"dry_run": False})
|
||||
poll(e, 10)
|
||||
assert ("use", 7, 10.0) in e["sm"].calls
|
||||
assert e["sm"].db[5]["location"] == "CC2 Slot 1"
|
||||
assert e["cc2"].slots[0]["spool"] == "s0002"
|
||||
|
||||
|
||||
def test_outages_do_not_lose_scans(env):
|
||||
env["sm"].add(5)
|
||||
env["sm"].fail = True
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
assert env["bridge"].state.data["slots"] == {}
|
||||
assert env["bridge"].state.data["scans"]
|
||||
env["sm"].fail = False
|
||||
poll(env)
|
||||
assert env["bridge"].state.data["slots"]["0"]["spool"] == 5
|
||||
|
||||
|
||||
def test_cc2_outage_retries_assignment(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
env["cc2"].fail = True
|
||||
poll(env, 2)
|
||||
env["cc2"].fail = False
|
||||
poll(env, 2)
|
||||
assert env["cc2"].slots[0]["spool"] == "s0001"
|
||||
|
||||
|
||||
def test_manual_assignment_in_cc2_is_adopted(env):
|
||||
env["sm"].add(9)
|
||||
env["cc2"].save_spool({"name": "SUNLU009", "material": "PLA", "color": "#000000", "note": "spoolman:9"})
|
||||
env["cc2"].assign(3, "s0001")
|
||||
env["cc2"].slots[3]["printer"] = {"status": 1}
|
||||
poll(env)
|
||||
assert env["bridge"].state.data["slots"]["3"]["spool"] == 9
|
||||
assert env["sm"].db[9]["location"] == "CC2 Slot 4"
|
||||
|
||||
|
||||
def test_enable_command_respects_dry_run(tmp_path):
|
||||
e = make_env(tmp_path, dry_run=True, cc2=FakeCC2(enabled=False))
|
||||
e["bridge"].commands.put(("enable", None))
|
||||
poll(e)
|
||||
assert e["cc2"].calls == [] and e["bridge"].snapshot["cc2_enabled"] is False
|
||||
e["settings"].save({"dry_run": False})
|
||||
e["bridge"].commands.put(("enable", None))
|
||||
poll(e)
|
||||
assert e["cc2"].calls == [("enable", True)]
|
||||
|
||||
|
||||
def test_state_file_is_valid_json(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
data = json.loads((env["path"] / "state.json").read_text())
|
||||
assert data["slots"]["0"]["spool"] == 5
|
||||
assert not list(env["path"].glob(".state.json.*"))
|
||||
|
||||
|
||||
def test_material_fits():
|
||||
assert material_fits("PLA+", "PLA", "PLA+")
|
||||
assert material_fits("PLA Matte", "PLA", "PLA")
|
||||
assert material_fits("PLA-CF", "PLA", "")
|
||||
assert not material_fits("Silk PLA", "PLA", "PLA Silk")
|
||||
assert material_fits("PLA Silk", "PLA", "PLA Silk")
|
||||
assert not material_fits("PETG", "PLA", "PLA")
|
||||
|
||||
|
||||
def test_fields_use_tag_material_when_spoolman_material_does_not_fit():
|
||||
sm = FakeSpoolman().add(3, material="Silk PLA", color="FFD700")
|
||||
fields, notes = cc2_spool_fields(sm, channel(10003, type_="PLA", detailed="PLA Silk"))
|
||||
assert fields["material"] == "PLA Silk" and notes
|
||||
assert fields["color"] == "#FFD700"
|
||||
@@ -0,0 +1,115 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.cc2control import CC2Control, CC2Error, clean_text, encode_body, spoolman_id_of, tray_statuses
|
||||
from app.spoolman import Spoolman
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, status=200, body=None):
|
||||
self.requests: list[httpx.Request] = []
|
||||
self.status = status
|
||||
self.body = body if body is not None else {"ok": True}
|
||||
|
||||
def __call__(self, request: httpx.Request):
|
||||
self.requests.append(request)
|
||||
return httpx.Response(self.status, json=self.body)
|
||||
|
||||
|
||||
def client(rec):
|
||||
return CC2Control("http://10.10.2.202:8081", transport=httpx.MockTransport(rec))
|
||||
|
||||
|
||||
def test_write_request_format():
|
||||
rec = Recorder(201, {"ok": True, "id": "a1b2"})
|
||||
resp = client(rec).save_spool({"name": "SUNLU005", "brand": "Sunlu", "material": "PLA+", "color": "#1E40AF",
|
||||
"diameter": 1.75, "net": 1000.0, "remaining": 812.5, "note": "spoolman:5"})
|
||||
assert resp == {"ok": True, "id": "a1b2"}
|
||||
r = rec.requests[0]
|
||||
assert r.method == "POST" and r.url.path == "/api/spools/save"
|
||||
assert r.headers["host"] == "10.10.2.202:8081"
|
||||
assert "origin" not in r.headers and "sec-fetch-site" not in r.headers
|
||||
assert r.headers["x-cc2-request"] == "1"
|
||||
assert "transfer-encoding" not in r.headers
|
||||
assert r.headers["content-type"].startswith("text/plain")
|
||||
assert r.content.decode() == ("name=SUNLU005\nbrand=Sunlu\nmaterial=PLA+\ncolor=#1E40AF\ndiameter=1.75\n"
|
||||
"net=1000\nremaining=812.5\nnote=spoolman:5\n")
|
||||
# every header only once (CC2 Control fails closed on duplicates)
|
||||
names = [k.lower() for k, _ in r.headers.raw]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
|
||||
def test_get_request_has_no_write_marker_or_origin():
|
||||
rec = Recorder(body={"enabled": True, "slots": [], "spools": []})
|
||||
assert client(rec).spools()["enabled"] is True
|
||||
r = rec.requests[0]
|
||||
assert r.method == "GET" and r.url.path == "/api/spools"
|
||||
assert r.headers["host"] == "10.10.2.202:8081" and "origin" not in r.headers
|
||||
|
||||
|
||||
def test_assign_and_empty_slot():
|
||||
rec = Recorder()
|
||||
c = client(rec)
|
||||
c.assign(2, "a1b2")
|
||||
c.assign(2, None)
|
||||
assert rec.requests[0].content == b"slot=2\nspool=a1b2\n"
|
||||
assert rec.requests[1].content == b"slot=2\nspool=\n"
|
||||
|
||||
|
||||
def test_enable_sends_bare_word():
|
||||
rec = Recorder(body={"ok": True, "enabled": True})
|
||||
client(rec).enable(True)
|
||||
client(rec).enable(False)
|
||||
assert rec.requests[0].url.path == "/api/spools/enable"
|
||||
assert rec.requests[0].content == b"on" and rec.requests[1].content == b"off"
|
||||
assert rec.requests[0].headers["x-cc2-request"] == "1"
|
||||
|
||||
|
||||
def test_errors_are_reported_without_crash():
|
||||
rec = Recorder(400, {"ok": False, "error": "Invalid spool"})
|
||||
with pytest.raises(CC2Error, match="400 Invalid spool"):
|
||||
client(rec).save_spool({"material": "PLA", "color": "#000000"})
|
||||
|
||||
def boom(request):
|
||||
raise httpx.ConnectError("refused")
|
||||
with pytest.raises(CC2Error, match="nicht erreichbar"):
|
||||
CC2Control("http://10.10.2.202:8081", transport=httpx.MockTransport(boom)).spools()
|
||||
|
||||
|
||||
def test_text_cleaning():
|
||||
assert clean_text('Sun"lu\\ \x01PLA\n') == "Sun lu PLA"
|
||||
long = "ä" * 60 # 120 bytes
|
||||
out = clean_text(long, 96)
|
||||
assert len(out.encode()) <= 96 and out == "ä" * 48
|
||||
body = encode_body({"name": "x" * 200, "material": "M" * 50})
|
||||
name, material = body.splitlines()
|
||||
assert len(name) == len("name=") + 96 and len(material) == len("material=") + 32
|
||||
assert encode_body({"note": "a=b", "archived": "0", "skip": None}) == "note=a=b\narchived=0\n"
|
||||
|
||||
|
||||
def test_spoolman_note_and_trays():
|
||||
assert spoolman_id_of({"note": "spoolman:17"}) == 17
|
||||
assert spoolman_id_of({"note": "gekauft 2025 spoolman:3 rest"}) == 3
|
||||
assert spoolman_id_of({"note": "spoolman:x"}) is None
|
||||
canvas = {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [
|
||||
{"tray_id": 0, "status": 1}, {"tray_id": 1, "status": 0}, {"tray_id": 2, "status": 2}, {"tray_id": 3}]}]}}}}
|
||||
assert tray_statuses(canvas) == {0: 1, 1: 0, 2: 2}
|
||||
assert tray_statuses({}) == {}
|
||||
|
||||
|
||||
def test_spoolman_requests():
|
||||
seen = []
|
||||
|
||||
def handler(request):
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"id": 5})
|
||||
sm = Spoolman("http://10.10.6.10:7912", transport=httpx.MockTransport(handler))
|
||||
sm.spool(5)
|
||||
sm.set_location(5, "CC2 Slot 1")
|
||||
sm.use(5, 12.3456)
|
||||
assert [(r.method, r.url.path) for r in seen] == [
|
||||
("GET", "/api/v1/spool/5"), ("PATCH", "/api/v1/spool/5"), ("PUT", "/api/v1/spool/5/use")]
|
||||
assert json.loads(seen[1].content) == {"location": "CC2 Slot 1"}
|
||||
assert json.loads(seen[2].content) == {"use_weight": 12.346}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""SSH reader against a local paramiko server that behaves like the restricted printer key."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import stat
|
||||
import threading
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from app.sshsource import CanvasRecordReader, HostKeyMismatch, HostKeyUnknown, KeyStore, SSHError
|
||||
|
||||
from .fakes import record
|
||||
|
||||
RECORD = record(10001, "", 1000, 10008)
|
||||
|
||||
|
||||
class Server(paramiko.ServerInterface):
|
||||
def __init__(self, allowed: paramiko.PKey):
|
||||
self.allowed = allowed
|
||||
self.commands = []
|
||||
|
||||
def check_channel_request(self, kind, chanid):
|
||||
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
|
||||
|
||||
def get_allowed_auths(self, username):
|
||||
return "publickey"
|
||||
|
||||
def check_auth_publickey(self, username, key):
|
||||
ok = username == "root" and key.get_base64() == self.allowed.get_base64()
|
||||
return paramiko.AUTH_SUCCESSFUL if ok else paramiko.AUTH_FAILED
|
||||
|
||||
def check_channel_exec_request(self, channel, command):
|
||||
self.commands.append(command)
|
||||
|
||||
def reply(): # forced command: always the canvas record
|
||||
channel.sendall(json.dumps(RECORD).encode())
|
||||
channel.send_exit_status(0)
|
||||
channel.close()
|
||||
threading.Timer(0.05, reply).start() # after the exec request is acknowledged
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def printer():
|
||||
host_key = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
|
||||
client_key_text = _new_key_text()
|
||||
client_key = paramiko.Ed25519Key.from_private_key(io.StringIO(client_key_text))
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(5)
|
||||
servers = []
|
||||
stop = threading.Event()
|
||||
|
||||
def accept():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
conn, _ = sock.accept()
|
||||
except OSError:
|
||||
return
|
||||
t = paramiko.Transport(conn)
|
||||
t.add_server_key(host_key)
|
||||
srv = Server(client_key)
|
||||
servers.append((t, srv))
|
||||
try:
|
||||
t.start_server(server=srv)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=accept, daemon=True).start()
|
||||
yield {"port": sock.getsockname()[1], "host_key": host_key, "client_key_text": client_key_text,
|
||||
"servers": servers}
|
||||
stop.set()
|
||||
sock.close()
|
||||
for t, _ in servers:
|
||||
t.close()
|
||||
|
||||
|
||||
def _new_key_text():
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
return Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()).decode()
|
||||
|
||||
|
||||
def test_key_store_permissions_and_no_secret_in_info(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
mode = stat.S_IMODE(os.stat(keys.key_path).st_mode)
|
||||
assert mode == 0o600
|
||||
info = keys.key_info()
|
||||
assert info["fingerprint"].startswith("SHA256:") and info["type"] == "ssh-ed25519"
|
||||
assert "PRIVATE" not in json.dumps(info)
|
||||
|
||||
|
||||
def test_rejects_garbage_key(tmp_path):
|
||||
with pytest.raises(SSHError):
|
||||
KeyStore(tmp_path).save_key("not a key")
|
||||
|
||||
|
||||
def test_first_contact_requires_confirmation(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
with pytest.raises(HostKeyUnknown):
|
||||
reader.read()
|
||||
result = reader.test()
|
||||
assert result["host_key_pinned"] is False
|
||||
assert result["record"]["channels0"]["weight"] == "10001"
|
||||
keys.pin_host_key("127.0.0.1", printer["host_key"])
|
||||
rec = reader.read()
|
||||
assert rec["channels3"]["weight"] == "10008"
|
||||
# transport is reused between reads
|
||||
reader.read()
|
||||
assert len(printer["servers"]) == 3 # refused read, test(), then one transport for both reads
|
||||
reader.close()
|
||||
|
||||
|
||||
def test_changed_host_key_is_rejected(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
|
||||
keys.pin_host_key("127.0.0.1", other)
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
with pytest.raises(HostKeyMismatch):
|
||||
reader.read()
|
||||
with pytest.raises(HostKeyMismatch):
|
||||
reader.test()
|
||||
|
||||
|
||||
def test_wrong_client_key_fails_cleanly(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(_new_key_text())
|
||||
keys.pin_host_key("127.0.0.1", printer["host_key"])
|
||||
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
|
||||
CanvasRecordReader(keys, "127.0.0.1", printer["port"]).read()
|
||||
|
||||
|
||||
def test_reconnects_after_drop(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
keys.pin_host_key("127.0.0.1", printer["host_key"])
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
reader.read()
|
||||
for t, _ in printer["servers"]:
|
||||
t.close()
|
||||
assert reader.read()["channels0"]["weight"] == "10001"
|
||||
|
||||
|
||||
def test_unreachable_printer(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
with pytest.raises(SSHError, match="nicht erreichbar"):
|
||||
CanvasRecordReader(keys, "127.0.0.1", port, timeout=2).read()
|
||||
@@ -0,0 +1,118 @@
|
||||
import pytest
|
||||
|
||||
from app import materials, tagcodec
|
||||
from app.tagcodec import TagData, encode_commands, parse_commands
|
||||
|
||||
TEMPLATE = ("A2:04:0103A00C,A2:05:34030FD1,A2:06:010B5502,A2:07:656C6567,A2:08:6F6F2E63,A2:09:6F6DFE00,"
|
||||
"A2:10:36EEEEEE,A2:11:EE000000,A2:12:00807665,A2:13:00010000,A2:14:000000FF,A2:15:00BE00E6,"
|
||||
"A2:16:00000000,A2:17:00AF2711,A2:18:0036C800")
|
||||
|
||||
|
||||
def pla_plus(spool_id, color):
|
||||
return TagData(spool_id=spool_id, color=color, material="PLA", subtype=0x0001, temp_min=190, temp_max=230)
|
||||
|
||||
|
||||
def test_template_reproduced_exactly():
|
||||
assert encode_commands(pla_plus(1, "000000")) == TEMPLATE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spool_id,color,p14,p17", [
|
||||
(1, "000000", "A2:14:000000FF", "A2:17:00AF2711"),
|
||||
(5, "2850DF", "A2:14:2850DFFF", "A2:17:00AF2715"),
|
||||
(8, "F88D36", "A2:14:F88D36FF", "A2:17:00AF2718"),
|
||||
])
|
||||
def test_expected_pages(spool_id, color, p14, p17):
|
||||
cmds = encode_commands(pla_plus(spool_id, color)).split(",")
|
||||
assert p14 in cmds and p17 in cmds
|
||||
# only pages 14 and 17 differ from the template
|
||||
diff = set(cmds) ^ set(TEMPLATE.split(","))
|
||||
assert {c[:5] for c in diff} <= {"A2:14", "A2:17"}
|
||||
|
||||
|
||||
def test_never_writes_pages_0_to_3():
|
||||
pages = tagcodec.encode_pages(pla_plus(1, "000000"))
|
||||
assert min(pages) == 4
|
||||
with pytest.raises(tagcodec.TagError):
|
||||
tagcodec.pages_to_commands({3: b"\0\0\0\0"})
|
||||
|
||||
|
||||
def test_decode_roundtrip():
|
||||
d = tagcodec.decode_pages(parse_commands(TEMPLATE))
|
||||
assert d["url"] == "https://www.elegoo.com"
|
||||
assert d["material"] == "PLA" and d["subtype_name"] == "PLA+"
|
||||
assert (d["temp_min"], d["temp_max"]) == (190, 230)
|
||||
assert d["diameter"] == 1.75 and d["weight"] == 10001 and d["spool_id"] == 1
|
||||
assert d["manufacturer"] == "EEEEEEEE"
|
||||
|
||||
|
||||
def test_rapid_petg_codes():
|
||||
tag = TagData(spool_id=12, color="FFFFFF", material="PETG", subtype=0x0105, temp_min=240, temp_max=255)
|
||||
pages = tagcodec.encode_pages(tag)
|
||||
assert pages[0x12].hex().upper() == "80698471"
|
||||
assert pages[0x13].hex().upper() == "01050000"
|
||||
assert pages[0x15].hex().upper() == "00F000FF"
|
||||
|
||||
|
||||
def test_validation():
|
||||
with pytest.raises(tagcodec.TagError):
|
||||
encode_commands(TagData(spool_id=1, color="000000", material="PLA", subtype=0x0100)) # PETG subtype
|
||||
with pytest.raises(tagcodec.TagError):
|
||||
encode_commands(pla_plus(60000, "000000")) # does not fit 2 bytes
|
||||
with pytest.raises(tagcodec.TagError):
|
||||
encode_commands(pla_plus(1, "zzzzzz"))
|
||||
with pytest.raises(tagcodec.TagError):
|
||||
encode_commands(TagData(spool_id=1, color="000000", temp_min=240, temp_max=200))
|
||||
|
||||
|
||||
def test_custom_url_fits_before_canvas_pages():
|
||||
tag = pla_plus(123, "000000")
|
||||
tag.url = "http://10.10.6.10:7912/spool/show/123"
|
||||
pages = tagcodec.encode_pages(tag)
|
||||
assert max(p for p in pages if p < 0x10) <= 0x0F
|
||||
assert tagcodec.decode_pages(pages)["url"] == tag.url
|
||||
tag.url = "https://example.com/" + "x" * 40
|
||||
with pytest.raises(tagcodec.TagError):
|
||||
tagcodec.encode_pages(tag)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("weight,expected", [
|
||||
("10001", 1), ("10005", 5), ("10000", 0), ("9999", None), ("1000", None), ("", None),
|
||||
("abc", None), ("70000", None), ("10008.000", 8),
|
||||
])
|
||||
def test_spool_id_from_weight(weight, expected):
|
||||
assert tagcodec.spool_id_from_weight(weight) == expected
|
||||
|
||||
|
||||
def test_parse_record():
|
||||
rec = {"channels0": {"weight": "10001", "type": "PLA"}, "channels1": {"weight": "", "type": ""},
|
||||
"channels3": {"weight": "1000", "type": "PETG"}}
|
||||
slots = tagcodec.parse_record(rec)
|
||||
assert [s["spool_id"] for s in slots] == [1, None, None, None]
|
||||
assert [s["occupied"] for s in slots] == [True, False, False, True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color,expected", [
|
||||
("000000", "000000"), ("2850DF", "2850DF"), ("0000FF", "2850DF"), ("0047AB", "2850DF"),
|
||||
("FF8000", "F88D36"), ("FF0000", "F72221"), ("FFFFFF", "FFFFFF"), ("808080", "898989"),
|
||||
("1E90FF", "48A7FA"), ("800080", "433089"), (None, "000000"), ("#ffa500", "F88D36"),
|
||||
])
|
||||
def test_palette_suggestion(color, expected):
|
||||
assert tagcodec.nearest_palette_color(color) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("material,name,mat,sub", [
|
||||
("PLA+", "", "PLA", 0x0001), ("PLA", "Silk Gold", "PLA", 0x0003), ("PETG", "", "PETG", 0x0100),
|
||||
("PETG", "Rapid PETG", "PETG", 0x0105), ("PLA", "Matte Black", "PLA", 0x0006), ("PETG-CF", "", "PETG", 0x0101),
|
||||
("ABS", "", "ABS", 0x0200), ("TPU", "95A", "TPU", 0x0301), ("PCTG", "", "PC", 0x0601),
|
||||
("ASA", "", "ASA", 0x0800), ("", "", "PLA", 0x0000), ("PLA", "Rapid PLA+", "PLA", 0x000A),
|
||||
])
|
||||
def test_material_suggestion(material, name, mat, sub):
|
||||
s = materials.suggest(material, name)
|
||||
assert (s["material"], s["subtype"]) == (mat, sub)
|
||||
|
||||
|
||||
def test_tables_complete():
|
||||
assert len(materials.MATERIALS) == 15
|
||||
assert len(materials.SUBTYPES) == 50
|
||||
for code, name, _ in materials.SUBTYPES:
|
||||
assert materials.material_of_subtype(code) is not None, name
|
||||
@@ -0,0 +1,110 @@
|
||||
import base64
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.bridge import Bridge
|
||||
from app.sshsource import KeyStore
|
||||
from app.storage import SettingsStore
|
||||
from app.web import create_app
|
||||
|
||||
from .fakes import FakeCC2, FakeReader, FakeSpoolman
|
||||
from .test_ssh import _new_key_text
|
||||
|
||||
H = {"X-Bridge-Request": "1"}
|
||||
|
||||
|
||||
def make_client(tmp_path, env=None):
|
||||
settings = SettingsStore(tmp_path, env={})
|
||||
sm = FakeSpoolman()
|
||||
sm.add(5, material="PLA+", color="1E40AF")
|
||||
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda s: FakeReader(),
|
||||
cc2_factory=lambda s: FakeCC2(), spoolman_factory=lambda s: sm)
|
||||
app = create_app(tmp_path, bridge=bridge, start_loop=False, env=env or {})
|
||||
return TestClient(app), bridge
|
||||
|
||||
|
||||
def test_pages_render(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
for path in ("/", "/tags", "/settings"):
|
||||
r = c.get(path)
|
||||
assert r.status_code == 200 and "CC2" in r.text
|
||||
|
||||
|
||||
def test_writes_need_marker_header(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
assert c.post("/api/settings", json={"dry_run": False}).status_code == 403
|
||||
assert c.post("/api/settings", json={"dry_run": True}, headers=H).status_code == 200
|
||||
|
||||
|
||||
def test_settings_roundtrip_and_validation(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
s = c.get("/api/settings").json()
|
||||
assert s["dry_run"] is True and s["id_offset"] == 10000
|
||||
r = c.post("/api/settings", json={"poll_interval": "7", "location_storage": "Regal"}, headers=H)
|
||||
assert r.json()["poll_interval"] == 7.0
|
||||
assert c.get("/api/settings").json()["location_storage"] == "Regal"
|
||||
assert c.post("/api/settings", json={"location_slot": "Slot"}, headers=H).status_code == 400
|
||||
assert SettingsStore(tmp_path).get().location_storage == "Regal"
|
||||
|
||||
|
||||
def test_ssh_key_upload_never_returned(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
text = _new_key_text()
|
||||
r = c.post("/api/ssh/key", json={"key": text}, headers=H)
|
||||
assert r.status_code == 200
|
||||
body = r.text + c.get("/api/ssh").text + c.get("/api/status").text
|
||||
assert "PRIVATE KEY" not in body and text.splitlines()[1] not in body
|
||||
assert r.json()["key"]["fingerprint"].startswith("SHA256:")
|
||||
path = tmp_path / "ssh" / "id_key"
|
||||
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
|
||||
assert c.post("/api/ssh/key", json={"key": "junk"}, headers=H).status_code == 400
|
||||
assert c.delete("/api/ssh/key", headers=H).json() == {"key": None}
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_hostkey_only_from_last_test(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
r = c.post("/api/ssh/hostkey", json={"fingerprint": "SHA256:forged"}, headers=H)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_tag_endpoint(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
r = c.post("/api/tag", json={"spool_id": 5, "color": "2850DF", "material": "PLA", "subtype": 1,
|
||||
"temp_min": 190, "temp_max": 230}, headers=H)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "A2:14:2850DFFF" in d["commands"] and "A2:17:00AF2715" in d["commands"]
|
||||
assert d["decoded"]["spool_id"] == 5 and d["qr_svg"].startswith("<svg")
|
||||
bad = c.post("/api/tag", json={"spool_id": 5, "color": "2850DF", "material": "PLA", "subtype": 0x0100,
|
||||
"temp_min": 190, "temp_max": 230}, headers=H)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_materials_endpoint(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
m = c.get("/api/materials").json()
|
||||
assert m["materials"]["PETG"]["code"] == "80698471" and len(m["subtypes"]) == 50 and len(m["palette"]) == 24
|
||||
|
||||
|
||||
def test_basic_auth(tmp_path):
|
||||
c, _ = make_client(tmp_path, env={"BRIDGE_USER": "admin", "BRIDGE_PASSWORD": "geheim"})
|
||||
assert c.get("/").status_code == 401
|
||||
assert c.get("/healthz").status_code == 200
|
||||
good = {"Authorization": "Basic " + base64.b64encode(b"admin:geheim").decode()}
|
||||
bad = {"Authorization": "Basic " + base64.b64encode(b"admin:nope").decode()}
|
||||
assert c.get("/", headers=bad).status_code == 401
|
||||
assert c.get("/", headers=good).status_code == 200
|
||||
|
||||
|
||||
def test_status_and_commands(tmp_path):
|
||||
c, bridge = make_client(tmp_path)
|
||||
bridge.poll_once()
|
||||
st = c.get("/api/status").json()
|
||||
assert len(st["slots"]) == 4 and st["dry_run"] is True
|
||||
assert c.post("/api/cc2/enable", json={}, headers=H).status_code == 400
|
||||
assert c.post("/api/cc2/enable", json={"confirm": True}, headers=H).json()["dry_run"] is True
|
||||
assert c.post("/api/slots/7/release", headers=H).status_code == 404
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Local stand-in for the printer (SSH), CC2 Control and Spoolman, for manual end-to-end tests.
|
||||
|
||||
python tools/mock_env.py --host-header mock:8081
|
||||
|
||||
Ports: 2222 SSH (any key listed in /mock/authorized), 8081 CC2 Control, 7912 Spoolman, 8099 control.
|
||||
Control endpoints (port 8099): POST /insert?slot=&spool=, /remove?slot=, /consume?slot=&grams=,
|
||||
/job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from tests.fakes import channel, record # noqa: E402
|
||||
|
||||
STATE = {
|
||||
"record": record(),
|
||||
"enabled": True,
|
||||
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
|
||||
"spools": [],
|
||||
"job": {"active": False, "file": "", "slots": []},
|
||||
"spoolman": {},
|
||||
"requests": [],
|
||||
}
|
||||
STATE["slots"][4]["printer"] = None
|
||||
IDS = (f"m{i:04d}" for i in itertools.count(1))
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
def seed_spoolman():
|
||||
for sid, vendor, mat, color, rem in [(1, "Sunlu", "PLA+", "000000", 640.0), (5, "Sunlu", "PLA+", "1E40AF", 812.5),
|
||||
(8, "Elegoo", "PLA", "FF7F00", 990.0), (12, "Elegoo", "PETG", "F5F5F5", 455.0),
|
||||
(14, "Polymaker", "PLA", "C0A060", 300.0)]:
|
||||
STATE["spoolman"][sid] = {
|
||||
"id": sid, "lot_nr": f"{vendor.upper()}{sid:03d}", "remaining_weight": rem, "used_weight": 1000 - rem,
|
||||
"initial_weight": 1000.0, "spool_weight": 160.0, "location": "Lager", "archived": False, "price": 19.9,
|
||||
"filament": {"id": sid, "name": f"{mat} {'Silk Gold' if sid == 14 else color}", "material": mat,
|
||||
"color_hex": color, "diameter": 1.75, "density": 1.24, "weight": 1000.0,
|
||||
"settings_extruder_temp": 215, "vendor": {"id": 1, "name": vendor}},
|
||||
}
|
||||
|
||||
|
||||
# ---------- CC2 Control ----------
|
||||
|
||||
def make_cc2(host_header: str) -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def guard(request: Request, call_next):
|
||||
raw = [k.decode().lower() for k, _ in request.scope["headers"]]
|
||||
if len(raw) != len(set(raw)):
|
||||
return JSONResponse({"error": "duplicate header"}, 403)
|
||||
if request.headers.get("host") != host_header or "origin" in request.headers \
|
||||
or "transfer-encoding" in request.headers:
|
||||
return JSONResponse({"error": "guard"}, 403)
|
||||
if request.method == "POST" and request.headers.get("x-cc2-request") != "1":
|
||||
return JSONResponse({"error": "marker"}, 403)
|
||||
return await call_next(request)
|
||||
|
||||
def form(body: bytes) -> dict:
|
||||
text = body.decode("utf-8").rstrip("\r\n")
|
||||
out = {}
|
||||
for line in text.split("\n"):
|
||||
if "=" not in line:
|
||||
raise HTTPException(400, "Invalid form")
|
||||
k, v = line.split("=", 1)
|
||||
if not k or k in out or len(k) >= 16 or len(v) > 96:
|
||||
raise HTTPException(400, "Invalid form")
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
@app.get("/api/spools")
|
||||
def spools():
|
||||
with LOCK:
|
||||
STATE["requests"].append("GET /api/spools")
|
||||
return {"available": True, "enabled": STATE["enabled"], "slots": STATE["slots"], "spools": STATE["spools"],
|
||||
"job": STATE["job"], "log": []}
|
||||
|
||||
@app.get("/api/canvas")
|
||||
def canvas():
|
||||
return {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [
|
||||
{"tray_id": s["slot"], "status": s["printer"]["status"]} for s in STATE["slots"][:4]]}]}}}}
|
||||
|
||||
@app.post("/api/spools/enable")
|
||||
async def enable(request: Request):
|
||||
body = await request.body()
|
||||
if body not in (b"on", b"off"):
|
||||
raise HTTPException(400, "Invalid switch")
|
||||
with LOCK:
|
||||
STATE["enabled"] = body == b"on"
|
||||
STATE["requests"].append(f"POST enable {body.decode()}")
|
||||
return {"ok": True, "enabled": STATE["enabled"]}
|
||||
|
||||
@app.post("/api/spools/save")
|
||||
async def save(request: Request):
|
||||
f = form(await request.body())
|
||||
with LOCK:
|
||||
STATE["requests"].append("POST save " + json.dumps(f, ensure_ascii=False))
|
||||
if f.get("id"):
|
||||
sp = next((s for s in STATE["spools"] if s["id"] == f["id"]), None)
|
||||
if not sp:
|
||||
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
|
||||
if "remaining" in f:
|
||||
return JSONResponse({"ok": False, "error": "Correct the remaining weight with a weigh-in"}, 400)
|
||||
for k in ("name", "brand", "material", "color", "note"):
|
||||
if k in f:
|
||||
sp[k] = f[k]
|
||||
if "archived" in f:
|
||||
sp["archived"] = f["archived"] == "1"
|
||||
return {"ok": True, "id": sp["id"]}
|
||||
if not f.get("material") or not f.get("color"):
|
||||
return JSONResponse({"ok": False, "error": "A spool needs a material and a colour"}, 400)
|
||||
sp = {"id": next(IDS), "name": f.get("name", ""), "brand": f.get("brand", ""), "material": f["material"],
|
||||
"color": f["color"], "diameter": float(f.get("diameter", 1.75)), "density": float(f.get("density", 1.24)),
|
||||
"net": float(f.get("net", 1000)), "remaining": float(f.get("remaining", f.get("net", 1000))),
|
||||
"tare": float(f.get("tare", 0)), "low": 0.0, "price": float(f.get("price", 0)),
|
||||
"note": f.get("note", ""), "created": 0, "used": 0, "archived": False}
|
||||
STATE["spools"].append(sp)
|
||||
return JSONResponse({"ok": True, "id": sp["id"]}, 201)
|
||||
|
||||
@app.post("/api/spools/assign")
|
||||
async def assign(request: Request):
|
||||
f = form(await request.body())
|
||||
with LOCK:
|
||||
STATE["requests"].append("POST assign " + json.dumps(f))
|
||||
slot = int(f["slot"])
|
||||
for s in STATE["slots"]:
|
||||
if f["spool"] and s["spool"] == f["spool"]:
|
||||
s["spool"] = ""
|
||||
STATE["slots"][slot]["spool"] = f["spool"]
|
||||
STATE["slots"][slot]["question"] = ""
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------- Spoolman ----------
|
||||
|
||||
def make_spoolman() -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
def get(sid: int):
|
||||
if sid not in STATE["spoolman"]:
|
||||
raise HTTPException(404, "not found")
|
||||
return STATE["spoolman"][sid]
|
||||
|
||||
@app.get("/api/v1/spool")
|
||||
def spools(allow_archived: bool = False):
|
||||
return [s for s in STATE["spoolman"].values() if allow_archived or not s["archived"]]
|
||||
|
||||
@app.get("/api/v1/spool/{sid}")
|
||||
def spool(sid: int):
|
||||
return get(sid)
|
||||
|
||||
@app.patch("/api/v1/spool/{sid}")
|
||||
async def patch(sid: int, request: Request):
|
||||
body = await request.json()
|
||||
with LOCK:
|
||||
sp = get(sid)
|
||||
sp.update({k: v for k, v in body.items() if k in ("location",)})
|
||||
STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}")
|
||||
return sp
|
||||
|
||||
@app.put("/api/v1/spool/{sid}/use")
|
||||
async def use(sid: int, request: Request):
|
||||
body = await request.json()
|
||||
with LOCK:
|
||||
sp = get(sid)
|
||||
sp["used_weight"] += body["use_weight"]
|
||||
sp["remaining_weight"] -= body["use_weight"]
|
||||
STATE["requests"].append(f"PUT spoolman {sid} use {body['use_weight']}")
|
||||
return sp
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------- control ----------
|
||||
|
||||
def make_control() -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/state")
|
||||
def state():
|
||||
return STATE
|
||||
|
||||
@app.post("/insert")
|
||||
def insert(slot: int, spool: int, color: str = "0x000000"):
|
||||
with LOCK:
|
||||
STATE["slots"][slot]["printer"] = {"status": 1}
|
||||
STATE["slots"][slot]["question"] = "inserted"
|
||||
STATE["slots"][slot]["spool"] = ""
|
||||
STATE["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/remove")
|
||||
def remove(slot: int):
|
||||
with LOCK:
|
||||
STATE["slots"][slot]["printer"] = {"status": 0}
|
||||
STATE["record"][f"channels{slot}"] = channel("")
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/touch")
|
||||
def touch(slot: int):
|
||||
with LOCK:
|
||||
STATE["record"][f"channels{slot}"]["weight"] = "1000"
|
||||
STATE["slots"][slot]["spool"] = ""
|
||||
STATE["slots"][slot]["question"] = "changed"
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/job")
|
||||
def job(active: int):
|
||||
with LOCK:
|
||||
STATE["job"]["active"] = bool(active)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/consume")
|
||||
def consume(slot: int, grams: float):
|
||||
with LOCK:
|
||||
sid = STATE["slots"][slot]["spool"]
|
||||
sp = next(s for s in STATE["spools"] if s["id"] == sid)
|
||||
sp["remaining"] -= grams
|
||||
return {"ok": True, "remaining": sp["remaining"]}
|
||||
|
||||
@app.post("/authorize")
|
||||
async def authorize(request: Request):
|
||||
AUTHORIZED.add((await request.body()).decode().split()[1])
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------- SSH ----------
|
||||
|
||||
AUTHORIZED: set[str] = set()
|
||||
|
||||
|
||||
class SSHServer(paramiko.ServerInterface):
|
||||
def check_channel_request(self, kind, chanid):
|
||||
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
|
||||
|
||||
def get_allowed_auths(self, username):
|
||||
return "publickey"
|
||||
|
||||
def check_auth_publickey(self, username, key):
|
||||
return paramiko.AUTH_SUCCESSFUL if username == "root" and key.get_base64() in AUTHORIZED else paramiko.AUTH_FAILED
|
||||
|
||||
def check_channel_exec_request(self, channel_, command):
|
||||
def reply(): # forced command, whatever was sent
|
||||
with LOCK:
|
||||
data = json.dumps(STATE["record"]).encode()
|
||||
channel_.sendall(data)
|
||||
channel_.send_exit_status(0)
|
||||
channel_.close()
|
||||
threading.Timer(0.05, reply).start() # after the exec request is acknowledged
|
||||
return True
|
||||
|
||||
|
||||
def ssh_server(port: int):
|
||||
host_key = paramiko.Ed25519Key.generate() if hasattr(paramiko.Ed25519Key, "generate") else None
|
||||
if host_key is None:
|
||||
from io import StringIO
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
pem = Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()).decode()
|
||||
host_key = paramiko.Ed25519Key.from_private_key(StringIO(pem))
|
||||
sock = socket.socket()
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", port))
|
||||
sock.listen(10)
|
||||
print(f"mock SSH on {port}, host key {host_key.fingerprint}", flush=True)
|
||||
while True:
|
||||
conn, _ = sock.accept()
|
||||
t = paramiko.Transport(conn)
|
||||
t.add_server_key(host_key)
|
||||
try:
|
||||
t.start_server(server=SSHServer())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host-header", default="127.0.0.1:8081")
|
||||
args = ap.parse_args()
|
||||
seed_spoolman()
|
||||
threading.Thread(target=ssh_server, args=(2222,), daemon=True).start()
|
||||
servers = [
|
||||
uvicorn.Server(uvicorn.Config(make_cc2(args.host_header), host="0.0.0.0", port=8081, log_level="warning")),
|
||||
uvicorn.Server(uvicorn.Config(make_spoolman(), host="0.0.0.0", port=7912, log_level="warning")),
|
||||
uvicorn.Server(uvicorn.Config(make_control(), host="0.0.0.0", port=8099, log_level="warning")),
|
||||
]
|
||||
threads = [threading.Thread(target=s.run, daemon=True) for s in servers[1:]]
|
||||
for t in threads:
|
||||
t.start()
|
||||
servers[0].run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user