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 – 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