diff --git a/.gitignore b/.gitignore index c8dfb6e..d2f0a7d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ data/ !data/orca_filaments.json .runner-token + +# Dev-only Dateien — nicht ins öffentliche Repo +CLAUDE.md +release.sh diff --git a/README.dev.md b/README.dev.md new file mode 100644 index 0000000..452ae6d --- /dev/null +++ b/README.dev.md @@ -0,0 +1,147 @@ +

KX-Bridge Logo

+ +# KX-Bridge – Dev Branch + +> **Achtung:** Dies ist der Entwicklungs-Branch. Builds hier sind experimentell und nicht für den produktiven Einsatz geeignet. +> Für stabile Releases → [KX-Bridge-Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) + +--- + +## Versionsschema + +Dev-Builds verwenden das Format: + +``` +-dev+ +``` + +**Beispiel:** `0.9.1-dev+04a6a20` + +- `0.9.1` – Basis der aktuellen stabilen Version +- `-dev` – kennzeichnet den Entwicklungs-Branch +- `+04a6a20` – 7-stelliger Git-Commit-Hash, eindeutig je Build + +--- + +## Dev-Binaries testen + +Dev-Releases sind auf Gitea als Pre-Releases verfügbar: +[Dev-Releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) + +### Docker (empfohlen) + +```bash +git clone -b dev +cd kobrax +docker compose up -d +``` + +### Linux-Binary + +```bash +# Dev-Release herunterladen (kx-bridge-linux.zip) +unzip kx-bridge-linux.zip +chmod +x kx-bridge +./kx-bridge +``` + +`config/config.ini` und `data/` (SQLite + GCode-Store) werden **neben dem Binary** +angelegt. Beim Erststart ohne Drucker zeigt die UI auf `http://localhost:7125` den +Drucker-Tab mit "+ Drucker hinzufügen" — dort nur die IP eingeben, der Rest wird +automatisch importiert. + +### Windows-EXE + +``` +# Dev-Release herunterladen (kx-bridge-windows.zip) +# kx-bridge.exe starten — config/ und data/ liegen daneben +``` + +--- + +## Update-Kanal + +Dev-Versionen prüfen automatisch auf neue **Dev-Releases** — nicht auf stabile Releases. +Im Settings-Modal → „Auf Updates prüfen" zeigt den neuesten Dev-Build an. + +--- + +## Aktive Entwicklung (Stand 2026-05-10) + +Stand `dev`-Branch über v0.9.7 hinaus: + +| Feature | Status | +|---------|--------| +| MMU-Emulation (`/printer/objects/query?mmu`) für OrcaSlicer Filament-Sync | ✅ | +| GCode Store (SQLite + Thumbnails) | ✅ | +| Browser-Tab mit Suche/Filter/Sortierung | ✅ | +| Filament-Dialog: Per-Kanal-Remapping (GCode-Kanal → AMS-Slot) | ✅ | +| MQTT Print-Payload `ams_settings.ams_box_mapping` (nested) | ✅ | +| Print-History in SQLite | ✅ | +| Multi-Printer Support (Drucker-Tab + Header-Dropdown) | ✅ | +| **Multi-Printer in einer Bridge-Instanz** (ein Prozess, N Listener) | ✅ | +| Drucker-Emulator (`_archive/tools/kx_printer_emulator.py`) | ✅ | +| i18n DE/EN für alle neuen UI-Elemente | ✅ | + +--- + +## Multi-Printer-Setup + +Eine Bridge-Instanz kann jetzt mehrere Drucker gleichzeitig verwalten — ein Prozess, +N MQTT-Verbindungen, N HTTP-Listener, geteilte SQLite + GCode-Verzeichnis. + +### Konfiguration + +In `config/config.ini` pro Drucker eine `[printer_N]`-Sektion anlegen: + +```ini +[printer_1] +name = Kobra X +printer_ip = +mqtt_port = 9883 +username = +password = +mode_id = 20030 +device_id = +http_port = 7125 + +[printer_2] +name = Drucker 2 +printer_ip = +mqtt_port = 9883 +username = +password = +mode_id = 20030 +device_id = +http_port = 7126 +``` + +Credentials per `extract_credentials` oder `fetch_credentials` ermitteln (siehe Haupt-README). + +`http_port` ist optional — Default ist `7125 + (N-1)`. Wenn keine `[printer_N]`-Sektionen +existieren, läuft die Bridge im klassischen Einzel-Modus mit `[connection]` und einem Listener. + +### Docker + +`docker-compose.yml` exposed jetzt einen Port-Range `7125-7130`: + +```yaml +ports: + - "7125-7130:7125-7130" +``` + +```bash +docker compose up -d +# Drucker 1: http://localhost:7125 +# Drucker 2: http://localhost:7126 +``` + +OrcaSlicer / Mainsail richten den Klipper-Endpunkt pro Drucker auf den jeweiligen Port — +keine Slicer-Anpassungen nötig. + +--- + +## Stabile Version + +Für den produktiven Einsatz bitte die stabile Version verwenden: +[→ Zum stabilen Release](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) diff --git a/VERSION b/VERSION index 703d986..c126aa6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.27-rc1 +0.9.27-nightly9 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..efa71fc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,68 @@ +""" +Shared fixtures für KX-Bridge Tests. +Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig). +""" +import sys, types, argparse, pytest, pytest_asyncio +from unittest.mock import MagicMock +from aiohttp.test_utils import TestClient, TestServer + +# ── Pfad ────────────────────────────────────────────────────────────────────── +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge")) + +# ── env_loader mocken (keine .env nötig) ────────────────────────────────────── +env_mod = types.ModuleType("env_loader") +env_mod.PRINTER_IP = "" +env_mod.MQTT_PORT = 9883 +env_mod.USERNAME = "" +env_mod.PASSWORD = "" +env_mod.MODE_ID = "20030" +env_mod.DEVICE_ID = "" +sys.modules["env_loader"] = env_mod + +# ── Bridge + App importieren ─────────────────────────────────────────────────── +from kobrax_moonraker_bridge import KobraXBridge, build_app # noqa: E402 + + +def make_mock_client(): + """Minimaler Mock-MQTT-Client — keine Verbindung, keine Threads.""" + c = MagicMock() + c.callbacks = {} + c.connected = False + return c + + +def make_args(**overrides): + args = argparse.Namespace( + printer_ip = "", + mqtt_port = 9883, + username = "", + password = "", + mode_id = "20030", + device_id = "", + host = "127.0.0.1", + port = 7125, + ) + for k, v in overrides.items(): + setattr(args, k, v) + return args + + +@pytest_asyncio.fixture +async def client(): + """TestClient mit frischer Bridge-Instanz, ohne MQTT-Verbindung.""" + mock_client = make_mock_client() + bridge = KobraXBridge(mock_client, args=make_args()) + app = build_app(bridge) + async with TestClient(TestServer(app)) as c: + yield c, bridge + + +@pytest_asyncio.fixture +async def client_configured(): + """TestClient mit bereits konfigurierten Zugangsdaten.""" + mock_client = make_mock_client() + args = make_args(printer_ip="192.168.1.100", device_id="abc123deadbeef") + bridge = KobraXBridge(mock_client, args=args) + app = build_app(bridge) + async with TestClient(TestServer(app)) as c: + yield c, bridge diff --git a/tests/requirements-test.txt b/tests/requirements-test.txt new file mode 100644 index 0000000..74d3b66 --- /dev/null +++ b/tests/requirements-test.txt @@ -0,0 +1,3 @@ +pytest +pytest-asyncio +aiohttp diff --git a/tests/test_api_state.py b/tests/test_api_state.py new file mode 100644 index 0000000..f5cc7d4 --- /dev/null +++ b/tests/test_api_state.py @@ -0,0 +1,113 @@ +""" +Tests für /api/state — Drucker-Zustandsabfrage. +""" +import pytest + + +@pytest.mark.asyncio +async def test_state_returns_200(client): + c, _ = client + resp = await c.get("/api/state") + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_state_schema(client): + """Alle erwarteten Felder müssen vorhanden und typsicher sein.""" + c, _ = client + resp = await c.get("/api/state") + data = await resp.json() + + assert isinstance(data["print_state"], str) + assert isinstance(data["kobra_state"], str) + assert isinstance(data["nozzle_temp"], float) + assert isinstance(data["nozzle_target"], float) + assert isinstance(data["bed_temp"], float) + assert isinstance(data["bed_target"], float) + assert isinstance(data["progress"], float) + assert isinstance(data["print_duration"], int) + assert isinstance(data["remain_time"], int) + assert isinstance(data["curr_layer"], int) + assert isinstance(data["total_layers"], int) + assert isinstance(data["filename"], str) + assert isinstance(data["fan_speed"], int) + assert isinstance(data["light_on"], bool) + assert isinstance(data["ams_slots"], list) + + +@pytest.mark.asyncio +async def test_state_initial_values(client): + """Im Offline-Start müssen Temperaturen 0 und Zustand 'standby' sein.""" + c, _ = client + data = await (await c.get("/api/state")).json() + + assert data["print_state"] == "standby" + assert data["nozzle_temp"] == 0.0 + assert data["bed_temp"] == 0.0 + assert data["progress"] == 0.0 + assert data["filename"] == "" + + +@pytest.mark.asyncio +async def test_state_updates_after_mqtt_print_report(client): + """Simuliert ein eingehendes print/report MQTT-Paket und prüft State-Update.""" + c, bridge = client + + # Simuliere MQTT-Nachricht wie vom echten Drucker + bridge._on_print({ + "state": "printing", + "data": { + "filename": "test.gcode", + "progress": 42, + "print_time": 10, # Minuten → 600s + "remain_time": 5, # Minuten → 300s + "curr_layer": 20, + "total_layers": 100, + } + }) + + data = await (await c.get("/api/state")).json() + + assert data["print_state"] == "printing" + assert data["filename"] == "test.gcode" + assert data["progress"] == pytest.approx(0.42) + assert data["print_duration"] == 600 + assert data["remain_time"] == 300 + assert data["curr_layer"] == 20 + assert data["total_layers"] == 100 + + +@pytest.mark.asyncio +async def test_state_updates_after_mqtt_temp_report(client): + """Simuliert ein tempature/report Paket.""" + c, bridge = client + + bridge._on_temp({ + "data": { + "curr_nozzle_temp": 215.3, + "target_nozzle_temp": 220.0, + "curr_hotbed_temp": 59.8, + "target_hotbed_temp": 60.0, + } + }) + + data = await (await c.get("/api/state")).json() + assert data["nozzle_temp"] == pytest.approx(215.3) + assert data["nozzle_target"] == pytest.approx(220.0) + assert data["bed_temp"] == pytest.approx(59.8) + assert data["bed_target"] == pytest.approx(60.0) + + +@pytest.mark.asyncio +async def test_state_resets_on_cancel(client): + """Nach 'stoped' müssen Progress und Filename zurückgesetzt werden.""" + c, bridge = client + + # Erst Druck simulieren + bridge._on_print({"state": "printing", "data": {"filename": "x.gcode", "progress": 50}}) + # Dann Abbruch + bridge._on_print({"state": "stoped", "data": {}}) + + data = await (await c.get("/api/state")).json() + assert data["progress"] == 0.0 + assert data["filename"] == "" diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100644 index 0000000..9aba38e --- /dev/null +++ b/tests/test_install.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# test_install.sh – Smoke-Test: Release-Repo klonen, start.sh ausführen, HTTP prüfen. +# +# Simuliert den Weg eines anonymen Nutzers: +# 1. Release-Repo klonen +# 2. start.sh ausführen (baut Docker-Image, startet Container) +# 3. HTTP-Endpunkte prüfen +# 4. Aufräumen +# +# Voraussetzung: Docker installiert, Port 7125 frei +# +# Verwendung: +# bash tests/test_install.sh + +set -euo pipefail + +GITEA_URL="https://gitea.it-drui.de/viewit/KX-Bridge-Release" +WORK_DIR=$(mktemp -d /tmp/kx-bridge-test-XXXXXX) +PASS=0; FAIL=0 + +ok() { echo " ✓ $*"; PASS=$((PASS+1)); } +fail() { echo " ✗ $*"; FAIL=$((FAIL+1)); } + +cleanup() { + echo "" + echo "[cleanup] Stoppe Container und lösche Testverzeichnis ..." + cd "$WORK_DIR/KX-Bridge-Release" 2>/dev/null && docker-compose down 2>/dev/null || true + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +echo "=== KX-Bridge Installations-Smoke-Test ===" +echo "" + +# ── Schritt 1: Repo klonen ──────────────────────────────────────────────────── +echo "[1/5] Klone Release-Repo ..." +git clone --depth=1 "$GITEA_URL" "$WORK_DIR/KX-Bridge-Release" > /dev/null 2>&1 \ + && ok "Repo geklont" \ + || { fail "git clone fehlgeschlagen"; exit 1; } + +cd "$WORK_DIR/KX-Bridge-Release" + +# ── Schritt 2: Erwartete Dateien vorhanden ──────────────────────────────────── +echo "[2/5] Prüfe Dateien im Repo ..." +for f in start.sh docker-compose.yml Dockerfile kobrax_moonraker_bridge.py \ + anycubic_slicer.crt anycubic_slicer.key .env.example; do + [[ -f "$f" ]] && ok "$f vorhanden" || fail "$f FEHLT" +done + +# Dockerfile darf keine 05_scripts/-Pfade enthalten +if grep -q "05_scripts/" Dockerfile; then + fail "Dockerfile enthält '05_scripts/' – falsches Dockerfile im Release-Repo!" +else + ok "Dockerfile Pfade korrekt (kein 05_scripts/-Präfix)" +fi + +# ── Schritt 3: start.sh ausführen ──────────────────────────────────────────── +echo "[3/5] Führe start.sh aus ..." +chmod +x start.sh +./start.sh > /tmp/kx-bridge-start.log 2>&1 \ + && ok "start.sh erfolgreich" \ + || { fail "start.sh fehlgeschlagen (siehe /tmp/kx-bridge-start.log)"; cat /tmp/kx-bridge-start.log; exit 1; } + +# Kurz warten bis Bridge hochgefahren +sleep 3 + +# ── Schritt 4: HTTP-Endpunkte prüfen ───────────────────────────────────────── +echo "[4/5] Prüfe HTTP-Endpunkte ..." +BASE="http://localhost:7125" + +check_endpoint() { + local path="$1" + local desc="$2" + local http_code + http_code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$path") + [[ "$http_code" == "200" ]] \ + && ok "$desc ($path → $http_code)" \ + || fail "$desc ($path → $http_code)" +} + +check_endpoint "/" "Web-UI (index.html)" +check_endpoint "/api/state" "GET /api/state" +check_endpoint "/api/settings" "GET /api/settings" +check_endpoint "/server/info" "GET /server/info (Moonraker)" +check_endpoint "/printer/info" "GET /printer/info (Moonraker)" +check_endpoint "/printer/objects/list" "GET /printer/objects/list" +check_endpoint "/api/version" "GET /api/version (OctoPrint compat)" + +# Beim ersten Start: printer_ip muss leer sein → Settings-Modal würde sich öffnen +SETTINGS=$(curl -s "$BASE/api/settings") +PRINTER_IP=$(echo "$SETTINGS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('printer_ip',''))" 2>/dev/null || echo "ERROR") +[[ -z "$PRINTER_IP" ]] \ + && ok "Erstkonfiguration erkannt: printer_ip leer → Settings-Modal öffnet sich" \ + || fail "printer_ip sollte beim Erststart leer sein, ist: '$PRINTER_IP'" + +# ── Schritt 5: Container läuft stabil ──────────────────────────────────────── +echo "[5/5] Prüfe Container-Stabilität ..." +sleep 2 +RUNNING=$(docker-compose ps --services --filter "status=running" 2>/dev/null || true) +[[ -n "$RUNNING" ]] \ + && ok "Container läuft stabil" \ + || fail "Container ist nicht mehr aktiv" + +# ── Ergebnis ────────────────────────────────────────────────────────────────── +echo "" +echo "══════════════════════════════════════" +echo " Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen" +echo "══════════════════════════════════════" +[[ $FAIL -eq 0 ]] && exit 0 || exit 1 diff --git a/tests/test_moonraker.py b/tests/test_moonraker.py new file mode 100644 index 0000000..4dab83a --- /dev/null +++ b/tests/test_moonraker.py @@ -0,0 +1,77 @@ +""" +Tests für Moonraker-kompatible Endpunkte die OrcaSlicer aufruft. +""" +import pytest + + +@pytest.mark.asyncio +async def test_server_info(client): + c, _ = client + resp = await c.get("/server/info") + assert resp.status == 200 + data = await resp.json() + assert data["result"]["klippy_state"] in ("ready", "standby", "error") + + +@pytest.mark.asyncio +async def test_printer_info(client): + c, _ = client + resp = await c.get("/printer/info") + assert resp.status == 200 + data = await resp.json() + assert "hostname" in data["result"] + + +@pytest.mark.asyncio +async def test_objects_list(client): + c, _ = client + resp = await c.get("/printer/objects/list") + assert resp.status == 200 + data = await resp.json() + objects = data["result"]["objects"] + # OrcaSlicer erwartet mindestens diese Objekte + for obj in ("print_stats", "heater_bed", "extruder", "display_status"): + assert obj in objects + + +@pytest.mark.asyncio +async def test_objects_query_print_stats(client): + c, _ = client + resp = await c.get("/printer/objects/query?print_stats") + assert resp.status == 200 + data = await resp.json() + ps = data["result"]["status"]["print_stats"] + assert "state" in ps + assert "filename" in ps + assert "print_duration" in ps + + +@pytest.mark.asyncio +async def test_objects_query_temperatures(client): + c, _ = client + resp = await c.get("/printer/objects/query?extruder&heater_bed") + assert resp.status == 200 + data = await resp.json() + status = data["result"]["status"] + assert "temperature" in status["extruder"] + assert "temperature" in status["heater_bed"] + + +@pytest.mark.asyncio +async def test_octoprint_version(client): + """OrcaSlicer probt /api/version um Drucker-Typ zu erkennen.""" + c, _ = client + resp = await c.get("/api/version") + assert resp.status == 200 + data = await resp.json() + assert "server" in data + assert "api" in data + + +@pytest.mark.asyncio +async def test_files_list(client): + c, _ = client + resp = await c.get("/server/files/list") + assert resp.status == 200 + data = await resp.json() + assert isinstance(data["result"], list) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..ef0b149 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,82 @@ +""" +Tests für /api/settings — Lesen und Schreiben der Verbindungseinstellungen. +""" +import pytest +import tempfile +import pathlib + + +@pytest.mark.asyncio +async def test_settings_get_returns_200(client): + c, _ = client + resp = await c.get("/api/settings") + assert resp.status == 200 + + +@pytest.mark.asyncio +async def test_settings_get_schema(client): + c, _ = client + data = await (await c.get("/api/settings")).json() + for key in ("printer_ip", "mqtt_port", "username", "password", "device_id", "mode_id"): + assert key in data + + +@pytest.mark.asyncio +async def test_settings_get_empty_when_unconfigured(client): + """Frische Bridge ohne Zugangsdaten → printer_ip und device_id leer.""" + c, _ = client + data = await (await c.get("/api/settings")).json() + assert data["printer_ip"] == "" + assert data["device_id"] == "" + + +@pytest.mark.asyncio +async def test_settings_get_returns_configured_values(client_configured): + """Bridge mit Zugangsdaten → Werte korrekt zurückgegeben.""" + c, _ = client_configured + data = await (await c.get("/api/settings")).json() + assert data["printer_ip"] == "192.168.1.100" + assert data["device_id"] == "abc123deadbeef" + + +@pytest.mark.asyncio +async def test_settings_post_writes_env(client): + """POST /api/settings schreibt Werte in .env-Datei.""" + c, bridge = client + + with tempfile.TemporaryDirectory() as tmpdir: + env_path = pathlib.Path(tmpdir) / ".env" + env_path.write_text("") + bridge._find_env_path = lambda: env_path + + resp = await c.post("/api/settings", json={ + "printer_ip": "10.0.0.5", + "mqtt_port": 9883, + "username": "userABCD", + "password": "secret123", + "device_id": "deadbeef01234567", + "mode_id": "20030", + }) + assert resp.status == 200 + + content = env_path.read_text() + assert "PRINTER_IP=10.0.0.5" in content + assert "MQTT_USERNAME=userABCD" in content + assert "DEVICE_ID=deadbeef01234567" in content + + +@pytest.mark.asyncio +async def test_settings_post_preserves_existing_keys(client): + """POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN).""" + c, bridge = client + + with tempfile.TemporaryDirectory() as tmpdir: + env_path = pathlib.Path(tmpdir) / ".env" + env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n") + bridge._find_env_path = lambda: env_path + + await c.post("/api/settings", json={"printer_ip": "10.0.0.99"}) + + content = env_path.read_text() + assert "GITEA_TOKEN=mytoken" in content + assert "PRINTER_IP=10.0.0.99" in content