diff --git a/.claude/agents/changelog.md b/.claude/agents/changelog.md new file mode 100644 index 0000000..d009f2f --- /dev/null +++ b/.claude/agents/changelog.md @@ -0,0 +1,31 @@ +--- +name: KX-Bridge Changelog +description: Generiert einen CHANGELOG.md Eintrag aus Git-Commits seit dem letzten Tag. +tools: + - run_command + - read_file + - write_file +--- + +Du generierst CHANGELOG.md Einträge für KX-Bridge. + +Vorgehen: +1. Führe aus: `git log $(git describe --tags --abbrev=0)..HEAD --oneline` +2. Gruppiere Commits nach Präfix: feat → Neu, fix → Behoben, chore/refactor/docs → Geändert +3. Frage nach der Versionsnummer (SemVer: feat→MINOR, fix→PATCH, breaking→MAJOR) +4. Schreibe den Abschnitt im Format: + +``` +## [VERSION] - DATUM + +### Neu +- ... + +### Behoben +- ... + +### Geändert +- ... +``` + +5. Füge den Abschnitt am Anfang der bestehenden CHANGELOG.md ein, ohne vorhandene Einträge zu ändern. diff --git a/.claude/agents/docker-check.md b/.claude/agents/docker-check.md new file mode 100644 index 0000000..e889aa0 --- /dev/null +++ b/.claude/agents/docker-check.md @@ -0,0 +1,32 @@ +--- +name: KX-Bridge Docker Check +description: Prüft Dockerfile, docker-compose und das gebaute Image auf häufige Probleme. +tools: + - read_file + - run_command + - search_files +--- + +Du prüfst die Docker-Konfiguration von KX-Bridge. + +**Dockerfile:** +- Base-Image aktuell? (`python:3.11-slim` oder neuer) +- `.dockerignore` vorhanden und vollständig? +- Keine Secrets oder Zertifikate im Image (`anycubic_slicer.crt/.key` darf NICHT eingebettet sein) +- Healthcheck vorhanden? +- Kein `COPY . .` ohne `.dockerignore` + +**docker-compose.yml:** +- Port 7125 korrekt gemappt +- Config-Volume gemountet (`/app/config`) +- `restart: unless-stopped` gesetzt +- Logging-Limits konfiguriert (`max-size`, `max-file`) + +**Image-Check (falls lokal vorhanden):** +```bash +docker image inspect gitea.it-drui.de/viewit/kx-bridge:nightly +``` +- Image-Größe sinnvoll (< 500MB)? +- Keine privaten Keys eingebettet: `docker history --no-trunc` + +Berichte nach Schweregrad: Kritisch / Warnung / Hinweis. diff --git a/.claude/agents/moonraker-debug.md b/.claude/agents/moonraker-debug.md new file mode 100644 index 0000000..5a9c400 --- /dev/null +++ b/.claude/agents/moonraker-debug.md @@ -0,0 +1,23 @@ +--- +name: KX-Bridge Moonraker Debug +description: Analysiert Moonraker/Klipper Logs und KX-Bridge Ausgaben auf Fehlerursachen. +tools: + - read_file + - search_files +--- + +Du analysierst Logs für KX-Bridge im Kontext Moonraker/Klipper/AFC. + +**Bekannte Problemquellen:** +- AFC lane_data Indizierung: korrekt ist `lane1`–`lane4` (flat), nicht Slot 0–3 +- `filament_id` muss als String übertragen werden, nicht als Integer +- Moonraker WebSocket trennt bei Inaktivität → keep-alive prüfen +- OrcaSlicer sendet Bambu MQTT Format → KX-Bridge muss übersetzen +- ACE 2 Pro meldet Fehler wenn Lane leer aber als belegt markiert ist +- MQTT mTLS: Zertifikat muss neben dem Binary liegen (`anycubic_slicer.crt/.key`) + +**Bei einem Log:** +1. Identifiziere den **ersten** Fehler (nicht den letzten Symptom) +2. Zeige den relevanten Log-Kontext (±10 Zeilen um den Fehler) +3. Nenne die wahrscheinliche Ursache +4. Schlage einen konkreten Fix vor (Datei + Funktion wenn möglich) diff --git a/.claude/agents/nightly-prep.md b/.claude/agents/nightly-prep.md new file mode 100644 index 0000000..9c3d656 --- /dev/null +++ b/.claude/agents/nightly-prep.md @@ -0,0 +1,25 @@ +--- +name: KX-Bridge Nightly Prep +description: Bereitet den PR von nightly nach main vor. Prüft ob alle Voraussetzungen für ein Stable Release erfüllt sind. +tools: + - run_command + - read_file +--- + +Du bereitest einen nightly → main Merge für KX-Bridge vor. + +Führe folgende Checks aus und berichte: + +1. `git log main..nightly --oneline` → alle Commits die noch nicht in main sind +2. `git diff main..nightly -- CHANGELOG.md` → ist CHANGELOG.md für alle Änderungen aktualisiert? +3. Prüfe ob `tests/` alle geänderten Module abdeckt +4. Prüfe ob Dockerfile ein aktuelles Base-Image verwendet +5. Schlage eine SemVer-Versionsnummer vor: + - `feat:` Commits → MINOR erhöhen + - `fix:` Commits → PATCH erhöhen + - Breaking Change im Commit-Body → MAJOR erhöhen + +Abschlussbericht: +- ✅ Bereit für Release +- ⚠️ Offen: [Liste] +- ❌ Blockiert durch: [Grund] diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md new file mode 100644 index 0000000..85117b7 --- /dev/null +++ b/.claude/agents/reviewer.md @@ -0,0 +1,24 @@ +--- +name: KX-Bridge Reviewer +description: Reviewt geänderte Dateien vor einem PR auf nightly. Prüft Logik, Fehlerbehandlung, Moonraker-Kompatibilität und Stil. +tools: + - read_file + - list_directory + - search_files +--- + +Du bist Code-Reviewer für KX-Bridge — eine Python-Bridge zwischen OrcaSlicer und Moonraker/Klipper für den Anycubic Kobra X. + +Beim Review prüfst du: +- Korrekte Fehlerbehandlung bei Moonraker HTTP/MQTT Calls (keine unbehandelten Exceptions) +- Keine hardcodierten IPs oder Ports (müssen aus config.ini kommen) +- Thread-Sicherheit bei parallelen Moonraker-Abfragen (asyncio korrekt verwendet) +- AFC lane_data Struktur: flache Indizierung lane1–lane4, kein Slot-Mapping 0–3 +- Kein `print()` statt `logging` (außer in CLI-Hilfsfunktionen) +- Typ-Annotationen vorhanden, Python 3.8+ kompatibel (kein `X | Y` Syntax) +- Tests für neue öffentliche Funktionen vorhanden + +Ausgabeformat: +1. **Kritische Fehler** — blockieren den Merge +2. **Warnungen** — sollten vor Merge behoben werden +3. **Hinweise** — optional, für zukünftige Verbesserungen diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md new file mode 100644 index 0000000..dc4a52d --- /dev/null +++ b/.claude/agents/test-writer.md @@ -0,0 +1,26 @@ +--- +name: KX-Bridge Test Writer +description: Leitet pytest-Tests aus geänderten oder neuen Python-Dateien ab. +tools: + - read_file + - write_file + - list_directory + - search_files +--- + +Du schreibst pytest-Tests für KX-Bridge. + +Kontext: +- Moonraker API läuft auf Port 7125 (HTTP + WebSocket) +- AFC lane_data: flache Indizierung lane1–lane4 +- Externe HTTP-Calls zu Moonraker werden mit `unittest.mock` gemockt +- Python 3.8+ Kompatibilität (kein `X | Y` Union-Syntax) + +Für jede zu testende Funktion schreibst du: +1. Happy Path (Normalfall mit validen Eingaben) +2. Fehlerfall (Moonraker nicht erreichbar, Timeout, falsche Antwort) +3. Grenzwerte (leere lane_data, ungültige filament_id, None-Werte) + +Dateiname: `tests/test_.py` +Verwende pytest-Fixtures für Moonraker-Mock-Responses. +Keine echten Netzwerkaufrufe in Tests. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..014fbd2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "project": "KX-Bridge", + "language": "de", + "defaultAgent": "reviewer", + "context": { + "repoBase": "gitea.it-drui.de/viewit/KX-Bridge-Release", + "defaultBranch": "nightly", + "stableBranch": "main", + "registry": "gitea.it-drui.de/viewit/kx-bridge", + "moonrakerPort": 7125 + } +} diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cba63ab --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Report a bug in KX-Bridge +labels: bug +--- + +## Description + + +## Steps to Reproduce +1. +2. +3. + +## Expected Behavior + +## Actual Behavior + +## Environment +- KX-Bridge Version: +- OrcaSlicer Version: +- Moonraker/Klipper Version: +- Operating System: +- Installation: Docker / Binary + +## Logs +``` + +``` diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..7261fec --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature Request +about: Suggest a new feature or improvement +labels: enhancement +--- + +## Description + + +## Motivation + + +## Proposed Implementation + diff --git a/.gitea/make_release_json.py b/.gitea/make_release_json.py new file mode 100644 index 0000000..da974ab --- /dev/null +++ b/.gitea/make_release_json.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +import sys, json +tag, version, body_file = sys.argv[1], sys.argv[2], sys.argv[3] +body = open(body_file).read() +payload = json.dumps({ + "tag_name": tag, + "name": "KX-Bridge " + version + " Nightly", + "body": body, + "draft": False, + "prerelease": True +}) +open("/tmp/release_body.json", "w").write(payload) diff --git a/.gitea/pull_request_template.md b/.gitea/pull_request_template.md new file mode 100644 index 0000000..74818f8 --- /dev/null +++ b/.gitea/pull_request_template.md @@ -0,0 +1,21 @@ +## Description + + +## Related Issue +Closes # + +## Type +- [ ] Bug fix +- [ ] Feature +- [ ] Documentation +- [ ] Refactoring + +## Tested with +- OrcaSlicer Version: +- Printer: +- Moonraker/Klipper Version: + +## Checklist +- [ ] Tests added/updated +- [ ] CHANGELOG.md updated +- [ ] No debug code included diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml new file mode 100644 index 0000000..a3d95f5 --- /dev/null +++ b/.gitea/workflows/nightly.yml @@ -0,0 +1,193 @@ +name: Nightly Build + +on: + push: + branches: + - nightly + paths: + - '**.py' + - 'Dockerfile' + - 'requirements.txt' + - 'web/**' + - 'data/**' + - '.gitea/workflows/nightly.yml' + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + +jobs: + build: + runs-on: server-runner + steps: + - name: Checkout + run: | + if [ -d .git ]; then + git fetch --tags origin nightly + git reset --hard origin/nightly + git clean -fd + else + git clone --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git . + fi + + - name: Install Docker CLI + run: | + if ! command -v docker >/dev/null 2>&1; then + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + DARCH="x86_64" + BARCH="amd64" + else + DARCH="aarch64" + BARCH="arm64" + fi + wget -qO- "https://download.docker.com/linux/static/stable/${DARCH}/docker-27.5.1.tgz" \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker + chmod +x /usr/local/bin/docker + mkdir -p /usr/local/lib/docker/cli-plugins + wget -qO /usr/local/lib/docker/cli-plugins/docker-buildx \ + "https://github.com/docker/buildx/releases/download/v0.23.0/buildx-v0.23.0.linux-${BARCH}" + chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx + fi + docker version --format '{{.Client.Version}}' + + - name: Set up QEMU + run: | + docker run --rm --privileged tonistiigi/binfmt:latest --install all + + - name: Set up buildx + run: | + docker buildx inspect kxbuilder 2>/dev/null || \ + docker buildx create --name kxbuilder --use + docker buildx use kxbuilder + + - name: Login to Gitea registry + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | \ + docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Compute nightly version + run: | + # Letzten Stable-Tag ermitteln (v0.9.27 → minor=27) + LAST_STABLE=$(git tag --list 'v*' --sort=-version:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | { read -r line; echo "$line"; cat >/dev/null; } || true) + if [ -z "$LAST_STABLE" ]; then + echo "ERROR: kein Stable-Tag gefunden" >&2; exit 1 + fi + # Nächste Minor-Version: v0.9.27 → 0.9.28 + MAJOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f1) + MINOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f2) + PATCH=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f3) + NEXT_PATCH=$((PATCH + 1)) + BASE="${MAJOR}.${MINOR}.${NEXT_PATCH}" + # Laufende Nummer: Anzahl vorhandener nightly--nightlyX Tags + 1 + COUNT=$(git tag --list "nightly-${BASE}-nightly*" | wc -l | tr -d ' ') + N=$((COUNT + 1)) + VERSION="${BASE}-nightly${N}" + echo "VERSION=${VERSION}" > /tmp/nightly_version.env + echo "BASE=${BASE}" >> /tmp/nightly_version.env + echo "LAST_STABLE=${LAST_STABLE}" >> /tmp/nightly_version.env + echo "Computed nightly version: ${VERSION} (after ${LAST_STABLE})" + + - name: Build & push (amd64 + arm64) + run: | + . /tmp/nightly_version.env + # VERSION-Datei im Arbeitsverzeichnis für den Docker-Build setzen (kein Commit) + echo "$VERSION" > VERSION + docker buildx build \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ + --push \ + --provenance=false \ + --no-cache \ + -t "gitea.it-drui.de/viewit/kx-bridge:nightly" \ + -t "gitea.it-drui.de/viewit/kx-bridge:nightly-${VERSION}" \ + . + + - name: Create Gitea Nightly Release + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + . /tmp/nightly_version.env + TAG="nightly-${VERSION}" + + # Letzten Stable-Tag als Changelog-Basis (nur echte vX.Y.Z-Tags) + PREV_TAG=$(git tag --list 'v*' --sort=-version:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | { read -r line; echo "$line"; cat >/dev/null; } || true) + [ -z "$PREV_TAG" ] && PREV_TAG=$(git rev-list --max-parents=0 HEAD) + + # Changelog: NIGHTLY_CHANGELOG.md hat Vorrang (manuell gepflegt), + # sonst auto-generiert aus feat/fix-Commits seit letztem Stable-Tag + BODY_FILE=$(mktemp) + printf '## KX-Bridge %s — Nightly Build\n\n' "$VERSION" > "$BODY_FILE" + printf '[experimental] Untested features, for testers only.\n\n' >> "$BODY_FILE" + if [ -s NIGHTLY_CHANGELOG.md ]; then + cat NIGHTLY_CHANGELOG.md >> "$BODY_FILE" + else + printf '### Changes since `%s`\n\n' "$PREV_TAG" >> "$BODY_FILE" + git log "${PREV_TAG}..HEAD" --pretty=format:'%s' --no-merges \ + | grep -E '^(feat|fix)[:(]' \ + | grep -Ev '^(feat|fix)\((ci|release|build|workflow)\)' \ + | sed 's/^/- /' \ + >> "$BODY_FILE" || true + if ! grep -q '^- ' "$BODY_FILE"; then + printf '- No user-facing changes in this build\n' >> "$BODY_FILE" + fi + fi + printf '\n\n---\n\n### Update Docker image\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n' >> "$BODY_FILE" + printf 'Image tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`\n' >> "$BODY_FILE" + + # Tag setzen + git tag "$TAG" + git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG" + + # curl + jq installieren falls nötig + if ! command -v curl >/dev/null 2>&1; then + apk add --no-cache curl 2>/dev/null || \ + { wget -qO /usr/local/bin/curl \ + "https://github.com/moparisthebest/static-curl/releases/download/v8.6.0/curl-amd64" \ + && chmod +x /usr/local/bin/curl; } + fi + if ! command -v jq >/dev/null 2>&1; then + ARCH=$(uname -m) + JQ_ARCH="amd64"; [ "$ARCH" = "aarch64" ] && JQ_ARCH="arm64" + wget -qO /usr/local/bin/jq \ + "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-${JQ_ARCH}" + chmod +x /usr/local/bin/jq + fi + + # Altes Release löschen falls vorhanden + curl -s -X DELETE \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases/tags/${TAG}" \ + 2>/dev/null || true + + # Release erstellen — JSON sicher via jq bauen + jq -n \ + --arg tag "$TAG" \ + --arg name "KX-Bridge ${VERSION} Nightly" \ + --rawfile body "$BODY_FILE" \ + '{"tag_name":$tag,"name":$name,"body":$body,"draft":false,"prerelease":true}' \ + > /tmp/release_body.json + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/release_body.json \ + "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases" + rm -f "$BODY_FILE" /tmp/release_body.json + + - name: Reset NIGHTLY_CHANGELOG.md for the next build + env: + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + . /tmp/nightly_version.env + # The changelog just consumed above must not carry over into the + # next nightly - otherwise every build re-lists all prior entries + # since the last manual reset instead of just what's new. Not in + # nightly.yml's own push-trigger paths, so this commit does not + # re-trigger this workflow. + printf '## Changes in this build\n\n' > NIGHTLY_CHANGELOG.md + git config user.name "gitea-actions" + git config user.email "actions@gitea.it-drui.de" + git add NIGHTLY_CHANGELOG.md + git commit -m "chore: reset NIGHTLY_CHANGELOG.md after nightly-${VERSION} release" || exit 0 + git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git HEAD:nightly diff --git a/.gitea/workflows/pr-check.yml b/.gitea/workflows/pr-check.yml new file mode 100644 index 0000000..9a80a1a --- /dev/null +++ b/.gitea/workflows/pr-check.yml @@ -0,0 +1,34 @@ +name: PR Check + +on: + pull_request: + branches: + - nightly + +jobs: + lint-and-test: + runs-on: server-runner + steps: + - name: Checkout + run: | + if [ -d .git ]; then + git fetch origin + git reset --hard origin/nightly + git clean -fd + else + git clone --depth=1 --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git . + fi + + - name: Dependencies installieren + run: pip3 install -r requirements.txt + + - name: Lint + run: | + pip3 install flake8 + flake8 *.py --max-line-length=120 --extend-ignore=E501 + + - name: Tests + run: | + pip3 install pytest + pytest tests/ -v + if: ${{ hashFiles('tests/') != '' }} diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..91ff4ec --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,74 @@ +name: Stable Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: server-runner + steps: + - name: Checkout + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [ -d .git ]; then + git fetch --tags origin + git checkout "$TAG" + git clean -fd + else + git clone --depth=1 --branch "$TAG" https://gitea.it-drui.de/viewit/KX-Bridge-Release.git . + fi + + - name: Install Docker CLI + run: | + if ! command -v docker >/dev/null 2>&1; then + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + DARCH="x86_64" + BARCH="amd64" + else + DARCH="aarch64" + BARCH="arm64" + fi + wget -qO- "https://download.docker.com/linux/static/stable/${DARCH}/docker-27.5.1.tgz" \ + | tar xz --strip-components=1 -C /usr/local/bin docker/docker + chmod +x /usr/local/bin/docker + mkdir -p /usr/local/lib/docker/cli-plugins + wget -qO /usr/local/lib/docker/cli-plugins/docker-buildx \ + "https://github.com/docker/buildx/releases/download/v0.23.0/buildx-v0.23.0.linux-${BARCH}" + chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx + fi + docker version --format '{{.Client.Version}}' + + - name: Set up QEMU + run: | + docker run --rm --privileged tonistiigi/binfmt:latest --install all + + - name: Set up buildx + run: | + docker buildx inspect kxbuilder 2>/dev/null || \ + docker buildx create --name kxbuilder --use + docker buildx use kxbuilder + + - name: Login to Gitea registry + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" | \ + docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + # Strip fuehrendes 'v' fuer den Image-Tag (VERSION-Datei hat kein 'v'). + - name: Build & push (amd64 + arm64) + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + docker buildx build \ + --platform linux/amd64,linux/arm64,linux/arm/v7 \ + --push \ + --provenance=false \ + --no-cache \ + -t "gitea.it-drui.de/viewit/kx-bridge:latest" \ + -t "gitea.it-drui.de/viewit/kx-bridge:${VERSION}" \ + . + + # Hinweis: Das Gitea-Release (inkl. englischem Auto-Changelog + Binaries als + # Assets) erstellt release.sh synchron, da es die lokal via CodeBuilder + # gebauten Binaries direkt hochlaedt. Dieser Workflow baut nur das Docker-Image. diff --git a/.gitignore b/.gitignore index 167af95..07fb09d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,7 @@ __pycache__/ build/ dist/ *.spec -releases/*/kx-bridge -releases/*/extract_credentials -releases/*/extract_credentials.exe +releases/ !kx-bridge.spec @@ -17,3 +15,8 @@ config/*.ini data/ !data/orca_filaments.json +.runner-token + +# Dev-only Dateien — nicht ins öffentliche Repo +CLAUDE.md +release.sh diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index bf03f56..440e5b7 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -1,5 +1,138 @@ # Changelog +## [0.9.26] – 2026-06-21 + +### Neu +- **Italienische Sprachunterstützung** (PR #66, @Alex_M). Die Bridge-UI ist jetzt vollständig auf Italienisch verfügbar. + +### Behoben +- **Kamera startete immer beim Druckbeginn** (Issue #50). `camera_on_print` fehlte in der `/api/state`-Antwort — JavaScript las `undefined` und startete die Kamera unabhängig vom Setting. Jetzt korrekt im State enthalten. +- **Auto-Leveling-Setting wurde im Moonraker-Druckpfad ignoriert** (Issue #57). `handle_print_start` las den Wert nur aus den Bridge-Args, nicht aus dem Request-Body — Dialog-Checkbox und Per-Print-Override hatten keine Wirkung. Verhält sich jetzt identisch zum direkten Druckpfad. +- **Filament-Mapping: Freitext-Felder durch Dropdowns ersetzt** (Issue #57). Falsch getippte Vendor/Name-Kombination brach das Profil-Matching ohne Fehlermeldung; Felder sind jetzt Dropdowns (Vendor → Profil, vendor-gefiltert), sodass nur gültige Kombinationen gespeichert werden können. +- **Dashboard zeigte generischen Materialtyp statt Profilname** (Issue #57). AMS-Slot-Karten zeigen jetzt den gemappten Profilnamen (z.B. „eSUN PLA-Basic") statt nur „PLA". Fallback auf generischen Typ wenn kein Profil gemappt ist. +- **Ghost-Profil auf leerem Slot** (Issue #57). Verwaiste Mappings für leere Slots wurden weiterhin angezeigt; leere Slots zeigen jetzt korrekt „–". +- **Skip-Objects-Panel fehlte im Orca-Upload-Flow** (Issue #57). Panel erscheint jetzt in allen Druckflows; bei frischem Upload fragt die Bridge `fileDetails` beim Drucker nach und pollt die Objektliste bis zu 6 Sekunden nach. +- **Banner und Dialog erschienen gleichzeitig** (Issue #57). Settings-Save setzt jetzt den Dialog-Cancel-State zurück, sodass der Slot-Mapper nach Wechsel des Start-Print-Verhaltens zuverlässig öffnet. +- **„Leeren" lud idle-Datei beim nächsten Poll nach** (Issue #57). Leeren setzt jetzt den lokalen State sofort zurück (`file_ready`, `filename`, `thumbnail`) und löscht alle Dialog-Sperren — Vorschaubild und Aktions-Buttons verschwinden sofort und kommen nicht zurück. +- **Material-Matching für „PLA Silk", „Matte PLA" etc.** (PR #64, @p2l). Modifier+Basis-Muster in beliebiger Wortreihenfolge werden jetzt auf den Basis-Typ normalisiert; Dash-Varianten (PLA-CF) bleiben weiterhin korrekt inkompatibel mit ihrem Basis-Typ. + +## [0.9.25] – 2026-06-17 + +### Behoben +- **Zufällige Abstürze / Container-Restarts — Segfault in `libcrypto.so.3` + (Issue #53).** Der MQTT-über-TLS-Client teilte einen einzelnen SSL-Socket + zwischen dem Reader-Thread (`recv`) und den Sender-Threads (`sendall`), ohne sie + zu serialisieren. CPythons `ssl`-Modul erlaubt kein gleichzeitiges Lesen und + Schreiben auf demselben Socket — die Überlappung korrumpierte den internen + OpenSSL-Zustand und löste eine Heap-Corruption + Segfault aus, die auf manchen + Hosts timing-bedingt zuverlässig auftrat. Sämtliche Socket-Zugriffe (recv / + sendall / close / reconnect) werden nun unter einem einzigen Lock serialisiert; + der Reader prüft die Bereitschaft mit `select()` außerhalb des Locks, damit die + Sender nie ausgehungert werden. Reconnect und Disconnect tauschen den Socket + jetzt atomar. Dank an @BasK für den detaillierten Fault-Handler-Trace. +- **File-Browser akzeptierte Nicht-GCode-Uploads (Issue #59).** Drag & Drop umging + den `accept`-Filter des Dateidialogs, sodass z.B. ein JPG hochgeladen werden + konnte. Uploads werden jetzt client- und serverseitig validiert; nur `.gcode`, + `.gcode.3mf`, `.3mf` und `.bgcode` werden akzeptiert. Dank an @gangoke. + +## [0.9.24] – 2026-06-16 + +### Neu +- **Objekte überspringen in jedem Druck-Flow (Issue #57).** Der „Objekte + überspringen"-Bereich im Slot-Mapper erschien bisher nur beim Druck aus dem + Browser-Tab. Er ist jetzt in allen Flows verfügbar (inkl. Upload / Print-Leiste), + standardmäßig eingeklappt hinter einem `✂ Objekte überspringen (N)`-Header, damit + der Dialog kompakt bleibt — Klick klappt Vorschau + Checkliste auf. +- **Slot-Mapper zeigt konkreten Profilnamen (Issue #57).** Jeder Slot zeigt nun das + zugeordnete Filament-Profil (z.B. „PolyTerra PLA — Polymaker") in den Dropdown- + Optionen und als Hover-Tooltip am Slot-Marker, statt nur des generischen Typs. + Fällt auf den generischen Typ zurück, wenn kein Profil gemappt ist. + +## [0.9.23] – 2026-06-16 + +### Neu +- **Druckdialog nach Upload automatisch öffnen.** Eine neue Einstellung + `print_start_dialog` (Einstellungen → Drucker → „Druckstart-Verhalten") steuert, + was nach einem Upload bei leerlaufendem Drucker passiert: „Print-Dialog" öffnet + den Slot-Zuordnungs-Dialog automatisch, „Print-Leiste" behält das bisherige + Banner. Basiert auf PR #56 von @gangoke. +- **Auto-Leveling-Schalter pro Druck.** Der Druckdialog hat jetzt eine eigene + Auto-Leveling-Checkbox, die den globalen Standard für einen einzelnen Druck + überschreibt. + +### Behoben +- **Objekt-Skip wurde beim Druckstart still ignoriert (PR #56, @gangoke).** Der + Skip-Befehl wurde gesendet, *bevor* der Drucker im `printing`-Status war, und + daher verworfen. Der Skip wird nun in einer Retry-Schleife erneut angewendet, + sobald der Druck bestätigt läuft — mit einer Pending-Sperre, damit die UI den + Skip-Status nicht vorzeitig zurücksetzt. +- **Upload während eines laufenden Drucks überschrieb die Vorschau des laufenden + Auftrags.** Ein neuer Upload während des Drucks ersetzt nicht mehr Thumbnail / + file_ready des Auftrags auf dem Druckbett. + +## [0.9.22] – 2026-06-16 + +### Neu +- **Neu strukturiertes Einstellungs-Panel.** Das Einstellungs-Modal wurde durch + ein dauerhaftes Master-Detail-Panel mit fünf Kategorien ersetzt: Verbindung, + Drucker, Darstellung, Filament und System. Das Poll-Intervall ist nun live + einstellbar. +- **Vendor-Sichtbarkeitsfilter (Issue #41).** Eine neue Checkliste in den + Filament-Einstellungen beschränkt das Slot-Profil-Dropdown auf bestimmte + Hersteller. „Generic" und eigene importierte Profile sind immer sichtbar. +- **Idle-Datei-Aktionen in der Fortschritts-Karte (Issue #55).** Nach einem + Upload bei leerlaufendem Drucker erscheinen drei Schnellaktionen direkt in der + Fortschritts-Karte: ▶ Drucken, ⚙ Slots zuordnen und ✕ Leeren. + +### Behoben +- **Mobileraker-Kompatibilität (Issue #48).** Absturz in `ConfigExtruder.fromJson` + (leeres `configfile.config`), Hänger beim Refresh (Metadata-Endlosschleife) und + fehlende ETA/Restzeit behoben. + +## [0.9.21] – 2026-06-14 + +### Behoben +- **Kamera-Stream auf Android (Chrome / Firefox) nicht sichtbar.** Android-Browser + unterstützen `multipart/x-mixed-replace` (MJPEG) nicht. Die UI erkennt Android + jetzt automatisch und fällt auf Snapshot-Polling mit 5 fps zurück + (`/api/camera/snapshot` alle 200 ms) — keine Server-Änderung nötig. + +### Geändert +- Docker-Image auf **Debian 12 (Bookworm)** gepinnt (`python:3.11-slim-bookworm`), + um Kompatibilitätsprobleme mit glibc 2.41 zu vermeiden, die das aktuell von + `python:3.11-slim` gezogene Debian 13 Basis-Image mitbringt. +- MQTT- und HTTP-Verbindungen erzwingen jetzt **IPv4** (`AF_INET`), um + Verbindungsfehler auf Hosts zu verhindern, bei denen der Drucker nur über IPv4 + erreichbar ist, das OS aber IPv6 bevorzugt. +- Extruder-Stub in der Moonraker-`configfile`-Antwort enthält jetzt `sensor_type` + und `filament_diameter` — behebt einen Mobileraker-Absturz + (`Null is not a subtype of Object`, Issue #48). + +## [0.9.20] – 2026-06-08 + +### Neu +- **Französische Sprachunterstützung (PR #45 von @Nathacks)** +- **Z-Höhe in der Print-UI (PR #49 von @Nathacks).** Zeigt die aktuelle + Z-Position in mm unterhalb des Layer-Zählers. + +### Behoben +- **Kamera-Autostart ignorierte das "Kamera bei Druckstart einschalten"- + Setting nach einem Bridge-Restart (Issue #50).** Das Setting wurde in + der Prozessumgebung gecacht — nach dem Speichern in der UI überlebte + der alte Wert den Restart und der neue Wert aus `config.ini` wurde + nicht gelesen. +- **Kamera startete nach manuellem Stopp während eines Drucks automatisch + neu (Issue #50).** Ein neues `_camera_user_stopped`-Flag unterdrückt + den Autostart für die aktuelle Drucksitzung. Es wird beim Druckende + zurückgesetzt. +- **Falscher "Stream nicht verfügbar"-Fehler-Toast beim manuellen + Kamera-Stopp.** Der Bild-Fehler-Handler war noch registriert als + `img.src` geleert wurde. +- **JS-Fehler (`ReferenceError: br is not defined`) beim Licht-Toggle.** + Variable wurde aus dem falschen Scope referenziert. +- Webcam-URLs sind jetzt absolut, damit Mobileraker/Obico-Clients sie + erreichen können. + ## [0.9.19.1] – 2026-06-04 ### Behoben diff --git a/CHANGELOG.md b/CHANGELOG.md index f8247fe..75fb40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,222 @@ # Changelog +## [Unreleased] + +### Fixed +- **Slot kept showing/printing a stale filament type after a spool swap.** The + per-slot profile override (config.ini `[filament_profiles]`) stores only + vendor+name and was sticky: swapping the physical filament updated the AMS + colour and type live, but the saved profile persisted, so a slot that held + e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the + OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts. + The override is now applied only while its material *family* still matches the + loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so + within-family swaps never invalidate a valid profile). On a family change the + slot falls back to the generic default; the override is not deleted, so + reloading the original material reactivates it. +- **Filament profiles not isolated between printers in a multi-printer bridge** + (issue #74). The slot→profile mapping and `visible_vendors` were stored in a + single global `[filament_profiles]` section, so configuring one printer + overwrote the other and after a restart both loaded the same mapping. Each + printer now persists to its own `[filament_profiles_]` section, with a + read-fallback to the legacy global section (single-printer setups unchanged). +- **Printer dropdown showed the other printer's filament profiles** (issue #74). + The header dropdown and the printers-management "switch" link navigated within + the same port (`/printerN`), so viewing another printer pulled its profile + names cross-instance from the local origin. The links now point at each + printer's own `bridge_url`, so every printer is viewed same-origin on its own + port. + +## [0.9.26] – 2026-06-21 + +### New +- **Italian language support** (PR #66, @Alex_M). The bridge UI is now fully + available in Italian. + +### Fixed +- **Camera always started at print begin** (issue #50). `camera_on_print` was + missing from the `/api/state` response — JavaScript read `undefined` and started + the camera regardless of the setting. Now correctly exposed in state. +- **Auto-leveling setting ignored in Moonraker print path** (issue #57). + `handle_print_start` read the value only from bridge args, not from the request + body, so the dialog checkbox and the per-print override had no effect. Now + behaves identically to the direct print path. +- **Filament mapping free-text fields replaced by dropdowns** (issue #57). A + mistyped vendor/name broke profile matching silently; fields are now dropdowns + (vendor → profile, vendor-filtered) so only valid combinations can be saved. +- **Dashboard showed generic material type instead of profile name** (issue #57). + AMS slot cards now display the mapped profile name (e.g. "eSUN PLA-Basic") + instead of just "PLA". Falls back to the generic type when no profile is mapped. +- **Ghost profile shown on empty slot** (issue #57). Stale mappings for empty + slots were still rendered; empty slots now correctly show "–". +- **Skip-Objects panel missing in Orca upload flow** (issue #57). Panel now + appears in all print flows; on fresh upload the bridge requests `fileDetails` + from the printer and retries the object list for up to 6 s. +- **Banner and dialog appeared simultaneously** (issue #57). Settings save now + resets the dialog cancel state so the slot mapper reliably opens after toggling + Start Print Behavior. +- **"Clear" reloaded idle file on next poll** (issue #57). Clear now immediately + resets local state (`file_ready`, `filename`, `thumbnail`) and clears all dialog + locks — the preview and action buttons disappear instantly and do not return. +- **Material matching for "PLA Silk", "Matte PLA" etc.** (PR #64, @p2l). + Modifier+base patterns in any word order are now normalised to the base type; + dash-suffix variants (PLA-CF) remain correctly incompatible with their base. + +## [0.9.25] – 2026-06-17 + +### Fixed +- **Random crashes / container restarts — segfault in `libcrypto.so.3` (issue #53).** + The MQTT-over-TLS client shared a single SSL socket between the reader thread + (`recv`) and the sender threads (`sendall`) without serializing them. CPython's + `ssl` module does not allow concurrent read and write on the same socket — the + overlap corrupted OpenSSL's internal state, causing a heap corruption and a + segfault that manifested reliably on some hosts (timing-dependent). All socket + access (recv / sendall / close / reconnect) is now serialized under a single + lock; the reader probes readiness with `select()` outside the lock so senders + are never starved. Reconnect and disconnect now swap the socket atomically. + Thanks to @BasK for the detailed fault-handler trace that pinpointed this. +- **File browser accepted non-GCode uploads (issue #59).** Drag & drop bypassed + the file picker's `accept` filter, so e.g. a JPG could be uploaded. Uploads are + now validated both client- and server-side; only `.gcode`, `.gcode.3mf`, `.3mf` + and `.bgcode` are accepted. Thanks @gangoke. + +## [0.9.24] – 2026-06-16 + +### New +- **Skip Objects available in every print flow (issue #57).** The "Skip objects" + panel in the Slot Mapper used to appear only when printing from the Browser tab. + It now shows in all flows (upload / print bar included), collapsed by default + behind a `✂ Skip objects (N)` header to keep the dialog compact, expanding on + click with the object preview and checklist. +- **Slot Mapper shows the specific profile name (issue #57).** Each slot now + displays its mapped filament profile (e.g. "PolyTerra PLA — Polymaker") in the + dropdown options and as a hover tooltip on the slot marker, instead of just the + generic type. Falls back to the generic type when no profile is mapped. + +## [0.9.23] – 2026-06-16 + +### New +- **Auto-open print dialog after upload.** A new `print_start_dialog` setting + (Settings → Printer → "Start Print Behavior") controls what happens after a + file is uploaded while the printer is idle: `Print Dialog` opens the + slot-assignment dialog automatically, `Print Bar` keeps the previous banner + behaviour. Based on PR #56 by @gangoke. +- **Per-print auto-leveling toggle.** The print dialog now has its own + auto-leveling checkbox that overrides the global default for a single print. + +### Fixed +- **Object skip was silently ignored at print start (PR #56, @gangoke).** The + skip command was sent *before* the printer entered the `printing` state, so it + was dropped. The skip is now re-applied in a retry loop once the print is + confirmed running, with a pending-lock so the UI doesn't reset the skip state + prematurely. +- **Upload during an active print overwrote the running job's preview.** + Uploading a new file while printing no longer replaces the thumbnail / + file_ready of the job currently on the bed. + +## [0.9.22] – 2026-06-16 + +### New +- **Restructured Settings panel.** The settings modal has been replaced by a + persistent Master-Detail panel with five categories: Connection, Printer, + Appearance, Filament, and System. Poll interval is now adjustable live. +- **Vendor visibility filter (issue #41).** A new checklist in the Filament + settings lets you restrict the slot profile dropdown to specific manufacturers. + "Generic" and your own imported profiles are always visible. The list updates + automatically after a profile import. +- **Idle file actions in the progress card (issue #55).** After uploading a file + while the printer is idle, three quick-action buttons appear directly in the + progress card: ▶ Print, ⚙ Map Slots, and ✕ Clear — matching the file browser + workflow without navigating away. + +### Fixed +- **Mobileraker: app crashed with `Null is not a subtype of Object` in + `ConfigExtruder.fromJson` (issue #48).** `configfile.config` was returned as + an empty object `{}`. Mobileraker parses both `configfile.settings` and + `configfile.config` through the same strict Dart parser — both are now + populated with the same extruder/bed/stepper stub. +- **Mobileraker: app hung indefinitely on refresh (issue #48).** The WebSocket + `server.files.metadata` handler called a non-existent store method + (`get_file_by_filename`), always returning empty metadata. Mobileraker retried + this thousands of times per second. Both the HTTP and WS paths now share a + single `_build_file_metadata()` method. +- **Mobileraker: ETA / remaining time not shown (issue #48).** A side effect of + the metadata loop fix — once `currentFile` resolves, Mobileraker can calculate + ETA from `estimated_time`. +- **Mobileraker: `notify_status_update` triggered repeated `ConfigFile.parse` + (issue #48).** Static objects (`configfile`, `webhooks`, `heaters`, `history`) + were included in every live status push. They are now filtered out; only live + telemetry is broadcast. +- `motion_report` (`live_position`, `live_velocity`) added to printer objects + for Mobileraker motion display. +- Saving filament slot profiles no longer silently drops the `visible_vendors` + setting from `config.ini`. + +## [0.9.21] – 2026-06-14 + +### Fixed +- **Camera stream not visible on Android (Chrome / Firefox).** Android + browsers do not support `multipart/x-mixed-replace` (MJPEG). The UI + now detects Android and falls back to snapshot-polling at 5 fps + (`/api/camera/snapshot` every 200 ms) — no server-side change needed. + +### Changed +- Docker image now pinned to **Debian 12 (Bookworm)** (`python:3.11-slim-bookworm`) + to avoid glibc 2.41 compatibility issues introduced by the Debian 13 + base image that `python:3.11-slim` recently started pulling. +- MQTT and HTTP connections now **force IPv4** (`AF_INET`) to prevent + connection failures on hosts where the printer is only reachable via + IPv4 but the OS prefers IPv6. +- Extruder stub in the Moonraker `configfile` response now includes + `sensor_type` and `filament_diameter` — fixes a Mobileraker crash + (`Null is not a subtype of Object`, issue #48). + +## [0.9.20] – 2026-06-08 + +### New +- **French language support (PR #45 by @Nathacks)** +- **Z height display in the print UI (PR #49 by @Nathacks).** Shows + current Z position in mm below the layer counter. + +### Fixed +- **Camera auto-start ignored "Enable camera on print start" setting + after a bridge restart (issue #50).** The setting was cached in the + process environment — after saving it in the UI, the old value + survived the restart and the new value from `config.ini` was never + read. +- **Camera restarted automatically after manual stop during a print + (issue #50).** A new `_camera_user_stopped` flag suppresses + auto-restart for the current print session. It resets when the + print ends. +- **Spurious "stream unavailable" error toast when stopping the camera + manually.** The image error handler was still registered when + `img.src` was cleared. +- **JS error (`ReferenceError: br is not defined`) when toggling the + light.** Variable was referenced from the wrong scope. +- Webcam URLs are now absolute so that Mobileraker/Obico clients can + reach them. + ## [0.9.19.1] – 2026-06-04 ### Fixed -- Standalone-Binaries (Linux/Windows) zeigten `vunknown` als Version. - Die `VERSION`-Datei ist jetzt ins PyInstaller-Onefile eingebettet. -- Bei fehlenden TLS-Zertifikaten (`anycubic_slicer.crt`/`.key`) gab - es nur den rohen Fehler `[Errno 2] No such file or directory`. Die - Bridge meldet jetzt klar, wo die Dateien hingelegt werden müssen - und dass `anycubic-certs.zip` aus dem Gitea-Release stammt. +- Standalone binaries (Linux/Windows) reported `vunknown` as their + version. The `VERSION` file is now embedded into the PyInstaller + onefile bundle. +- When the TLS certificates (`anycubic_slicer.crt`/`.key`) were + missing, the bridge only logged the raw `[Errno 2] No such file + or directory`. It now states clearly where the files need to be + placed and that `anycubic-certs.zip` from the Gitea release is the + source. ### Changed -- Filament-Profil-Liste neu kuratiert: 209 statt 399 Einträge. - Profile die nur für drucker-spezifische Vendor-Bundles existieren - (z.B. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR, - Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) sind rausgeflogen - — OrcaSlicer hätte sie im Standard-Kobra-X-Setup beim Sync - ohnehin nicht gefunden, weil die jeweiligen Vendor-Bundles nur - bei aktivem Drucker-Vendor geladen werden. Für solche Filamente - bleibt der Custom-Profile-Import (Issue #41) der Weg. +- Filament profile list re-curated: 209 entries instead of 399. + Profiles that only exist inside printer-specific vendor bundles + (e.g. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR, + Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) were dropped — + OrcaSlicer wouldn't have found them in a default Kobra X setup + anyway, because the matching vendor bundle is only loaded when + the corresponding printer vendor is active. For those filaments + the custom profile import (issue #41) remains the way. ## [0.9.19] – 2026-06-02 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f7c3c76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to KX-Bridge + +Thanks for taking the time to contribute! Here's everything you need to know. + +--- + +## How to report a bug or request a feature + +Use the issue tracker: + +- **Bug:** [New Bug Report](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues/new?template=bug_report.md) +- **Feature:** [New Feature Request](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues/new?template=feature_request.md) + +Please fill in the template — especially the **KX-Bridge version** and **logs**. +Issues without version info are hard to debug. + +--- + +## How to submit a Pull Request + +### 1. Fork the repository + +Click **Fork** at the top of this page. +You now have your own copy at `gitea.it-drui.de/your-username/KX-Bridge-Release`. + +### 2. Clone your fork + +```bash +git clone https://gitea.it-drui.de/your-username/KX-Bridge-Release.git +cd KX-Bridge-Release +``` + +### 3. Create a branch + +Always branch off `nightly`: + +```bash +git checkout nightly +git checkout -b feature/my-feature # or fix/my-fix +``` + +### 4. Make your changes + +- Test your changes locally with Docker: + ```bash + docker build -t kx-bridge:dev . + docker run -p 7125:7125 -v ./config:/app/config kx-bridge:dev + ``` +- No debug `print()` statements — use `logging` +- Keep commits focused; one thing per commit + +### 5. Push and open a PR + +```bash +git push origin feature/my-feature +``` + +Gitea will show a banner — click **"Create Pull Request"**. +The PR template will be pre-filled. Set the target branch to **`nightly`**. + +--- + +## Branch model + +``` +master ← stable releases only (merged by maintainer) +nightly ← integration branch — PRs go here +feature/* ← your feature branch (in your fork) +fix/* ← your bugfix branch (in your fork) +``` + +Your PR always targets `nightly`. The maintainer periodically merges `nightly → master` for a new stable release. + +--- + +## Commit style + +Use conventional commit prefixes: + +| Prefix | When | +|---|---| +| `feat:` | new feature | +| `fix:` | bug fix | +| `docs:` | documentation only | +| `chore:` | maintenance, dependencies | +| `refactor:` | code change without new feature or fix | + +Example: `fix: prevent crash when printer is offline during startup` + +--- + +## Language + +- **Code and comments:** English +- **Issue comments:** match the language of the issue (if someone writes in German, reply in German) +- **Commit messages:** English + +--- + +## Questions? + +Open a [Discussion](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues) or leave a comment on the relevant issue. diff --git a/Dockerfile b/Dockerfile index 18ac4f6..ceec4e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ -FROM python:3.11-slim +FROM python:3.11-slim-bookworm WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg gcc python3-dev && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt && \ + apt-get purge -y gcc python3-dev && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* COPY kobrax_moonraker_bridge.py . COPY web/ ./web/ diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md new file mode 100644 index 0000000..f1eff73 --- /dev/null +++ b/NIGHTLY_CHANGELOG.md @@ -0,0 +1,2 @@ +## Changes in this build + diff --git a/README.de.md b/README.de.md index f8f3bf2..e32ff0c 100644 --- a/README.de.md +++ b/README.de.md @@ -21,14 +21,17 @@ Feedback willkommen.   [![Releases](https://img.shields.io/badge/Download-Releases-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Downloads](https://img.shields.io/badge/Downloads-800%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) +[![Downloads](https://img.shields.io/badge/Downloads-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM) Gefällt dir KX-Bridge? Ein Kaffee auf Ko-fi hält das Projekt am Leben. ☕ + +> 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen. + --- ## ✨ Was kann KX-Bridge? @@ -38,14 +41,18 @@ Feedback willkommen. | 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit | | 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream | | 🎨 | **AMS / Multicolor** — Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück | +| 🏷️ | **Custom-RFID-Tag-Matching** — mit Drittanbieter-Tools (z.B. der „ACE RFID"-App) beschriebene Spulen werden automatisch nach Marke + Material gegen deine importierten OrcaSlicer-Profile gematcht, statt auf ein generisches Profil zurückzufallen | | 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user//filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf | +| 🧵 | **Spoolman-Integration** — Spulen einzelnen AMS-Slots zuweisen, Filament-Verbrauch wird automatisch beim Drucken erfasst und synchronisiert | +| 🔗 | **Multi-ACE-Unterstützung** — mehrere aneinandergekettete ACE-Einheiten, auch bei Druckern ohne Toolhead-Buffer | | 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico | -| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) | -| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter | +| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget); erholt sich automatisch nach einem Drucker-Reboot mit rotiertem Stream-Token, kein manueller Reset nötig | +| 🗂️ | **GCode-Browser** — zwei Tabs: hochgeladene Dateien (mit Thumbnail, Druckhistorie, Suche & Filter, Mehrfachauswahl + Sammel-Löschen) und Dateien direkt auf dem Drucker-Speicher (mit echten Thumbnails, Mehrfachauswahl + Löschen) | | 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown | | ➕ | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert | +| 🖱️ | **Frei anpassbares Dashboard** — Kacheln per Drag & Drop verschieben und in der Größe anpassen, als Preset speichern | | 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart | -| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / 中文, Browser-Sprache automatisch erkannt | +| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / FR / IT / 中文, Browser-Sprache automatisch erkannt | | 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren | | 🧠 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket); für korrekten Vendor-Match pro Slot den [OrcaSlicer-KX-Build](#-empfohlener-slicer) nutzen | @@ -65,46 +72,24 @@ LAN-Modus am Kobra X aktivieren: docker compose up -d ``` +> Zusätzlich Spoolman und einen kompletten selbst gehosteten Obico-Setup +> (Spaghetti-Erkennung, Live-Stream) neben der Bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml) +> bündelt KX-Bridge + Spoolman + Obico (Web/ML/Tasks/Redis) + moonraker-obico +> in einem Netzwerk — Setup-Schritte in den Kommentaren am Dateianfang. + **Linux-Binary (kein Docker):** ```bash -chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64 +chmod +x kx-bridge && ./kx-bridge ``` **Windows-EXE (kein Docker):** ``` kx-bridge.exe ``` +> `config\` und `data\` werden neben der EXE angelegt — portabel. -> ⚠️ **TLS-Zertifikate für Standalone-Binary nötig** -> -> Die Bridge spricht per mTLS mit dem Drucker-MQTT und braucht zwei -> Zertifikat-Dateien **direkt neben dem Binary**: -> -> - `anycubic_slicer.crt` -> - `anycubic_slicer.key` -> -> Beide liegen im **`anycubic-certs.zip`** auf derselben Release-Seite. -> Lade die ZIP herunter und entpacke die beiden Dateien in dasselbe -> Verzeichnis wie `kx-bridge-linux-amd64` bzw. `kx-bridge.exe`. Ohne -> die Zertifikate siehst du `Verbindung fehlgeschlagen: TLS-Zertifikate -> fehlen …` (0.9.19.1+) oder `[Errno 2] No such file or directory` -> (ältere Builds). -> -> So muss es aussehen: -> ``` -> ~/kx-bridge/ -> ├── kx-bridge-linux-amd64 (oder kx-bridge.exe) -> ├── anycubic_slicer.crt ← aus anycubic-certs.zip -> ├── anycubic_slicer.key ← aus anycubic-certs.zip -> └── config/ (wird beim ersten Start angelegt) -> ``` -> -> Docker-User müssen das nicht machen — die Zertifikate sind im Image -> enthalten. - -> Bei Linux- und Windows-Binary liegen `config/` und `data/` (Einstellungen, -> SQLite, GCode-Store) jeweils neben dem Programm. Einfach den ganzen Ordner -> kopieren = umziehen. +> Bei Linux- und Windows-Binary liegen `config/` und `data/` (Einstellungen, SQLite, +> GCode-Store) jeweils neben dem Programm. Einfach den ganzen Ordner kopieren = umziehen. **Python direkt:** ```bash @@ -134,7 +119,7 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125` ## 📺 Video-Tutorial -[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM) --- @@ -211,6 +196,24 @@ docker compose up -d --build # lokal selber bauen (statt zu pullen) --- +## 🌙 Nightly-Builds + +Nightly-Builds enthalten die neuesten unveröffentlichten Features und werden automatisch bei jedem Entwicklungs-Push gebaut. +Sie können instabil sein — für Tests oder frühen Zugriff auf neue Funktionen geeignet. + +```bash +docker compose -f docker-compose.yml -f docker-compose.nightly.yml pull +docker compose -f docker-compose.yml -f docker-compose.nightly.yml up -d +``` + +Zurück zum stabilen Release: + +```bash +docker compose pull && docker compose up -d +``` + +--- + ## 🩹 Troubleshooting
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/README.es.md b/README.es.md index 0a4931c..21b202f 100644 --- a/README.es.md +++ b/README.es.md @@ -20,14 +20,19 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.   [![Releases](https://img.shields.io/badge/Descargar-Lanzamientos-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Downloads](https://img.shields.io/badge/Descargas-800%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) +[![Downloads](https://img.shields.io/badge/Descargas-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM) ¿Te gusta KX-Bridge? Un café en Ko-fi mantiene el proyecto vivo. ☕ +> [!CAUTION] +> **Trabajos de mantenimiento en curso** — Estamos reestructurando el repositorio (modelo de ramas, flujos CI, proceso de contribución). Es posible que notes cambios en los nombres de ramas, plantillas de PR y la forma en que se publican las versiones. Pedimos disculpas por las molestias. El manejo, el flujo de trabajo y la mantenibilidad a largo plazo mejorarán considerablemente. +> +> 👉 ¿Quieres contribuir? Por favor lee [CONTRIBUTING.md](CONTRIBUTING.md) primero. + --- ## ✨ Características @@ -37,14 +42,18 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback. | 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión | | 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara | | 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora | +| 🏷️ | **Coincidencia de etiquetas RFID personalizadas** — las bobinas etiquetadas con herramientas de terceros (p. ej. la app "ACE RFID") se emparejan automáticamente por marca + material con tus perfiles de OrcaSlicer importados, en lugar de caer en un perfil genérico | | 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user//filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios | +| 🧵 | **Integración con Spoolman** — asigna bobinas a las ranuras del AMS, el consumo de filamento se registra y sincroniza automáticamente al imprimir | +| 🔗 | **Soporte multi-ACE** — múltiples unidades ACE encadenadas, incluso en impresoras sin buffer en el cabezal | | 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico | -| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) | -| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros | +| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso); se recupera automáticamente tras un reinicio de la impresora que rota el token del stream, sin necesidad de reinicio manual | +| 🗂️ | **Explorador de GCode** — dos pestañas: archivos subidos (con vistas previas, historial de impresión, búsqueda y filtros, selección múltiple + borrado masivo) y archivos almacenados directamente en la memoria de la impresora (con vistas previas reales, selección múltiple + borrado) | | 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable | | ➕ | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente | +| 🖱️ | **Panel de control libre** — arrastra y redimensiona las tarjetas del panel a tu gusto, guárdalo como preset | | 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual | -| 🌐 | **Interfaz multilingüe** — DE / EN / ES / 中文, detecta automáticamente el idioma del navegador | +| 🌐 | **Interfaz multilingüe** — DE / EN / ES / FR / IT / 中文, detecta automáticamente el idioma del navegador | | 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador | | 🧠 | **OrcaSlicer** — protocolo Moonraker completo (HTTP + WebSocket); usa el [build OrcaSlicer-KX](#-slicer-recomendado) para emparejamiento correcto de vendor por ranura | @@ -64,45 +73,24 @@ Activa el modo LAN en la Kobra X: docker compose up -d ``` +> ¿Quieres Spoolman y un setup completo de Obico autoalojado (detección de +> espagueti, stream en vivo) junto al puente? [`docker-compose-KX.yml`](docker-compose-KX.yml) +> combina KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico +> en una sola red — consulta los comentarios al inicio del archivo para los pasos de configuración. + **Binario Linux (sin Docker):** ```bash -chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64 +chmod +x kx-bridge && ./kx-bridge ``` **EXE Windows (sin Docker):** ``` kx-bridge.exe ``` +> `config\` y `data\` se crean junto al EXE — instalación portátil. -> ⚠️ **Certificados TLS necesarios para el binario standalone** -> -> El bridge habla con el MQTT de la impresora vía mTLS y necesita dos -> ficheros de certificado **junto al binario**: -> -> - `anycubic_slicer.crt` -> - `anycubic_slicer.key` -> -> Ambos vienen en **`anycubic-certs.zip`** en la misma página de release. -> Descárgalo y extrae los dos ficheros en el mismo directorio que -> `kx-bridge-linux-amd64` o `kx-bridge.exe`. Sin ellos verás -> `Verbindung fehlgeschlagen: TLS-Zertifikate fehlen …` (0.9.19.1+) o -> `[Errno 2] No such file or directory` (versiones anteriores). -> -> Estructura correcta: -> ``` -> ~/kx-bridge/ -> ├── kx-bridge-linux-amd64 (o kx-bridge.exe) -> ├── anycubic_slicer.crt ← de anycubic-certs.zip -> ├── anycubic_slicer.key ← de anycubic-certs.zip -> └── config/ (se crea en el primer arranque) -> ``` -> -> Los usuarios de Docker no necesitan hacer esto — los certificados -> están incluidos en la imagen. - -> Con los binarios de Linux y Windows, `config/` y `data/` (configuración, -> SQLite, almacén de GCode) viven junto al programa. Copia toda la carpeta -> para mover la instalación. +> Con los binarios de Linux y Windows, `config/` y `data/` (configuración, SQLite, almacén de GCode) +> viven junto al programa. Copia toda la carpeta para mover la instalación. **Python directamente:** ```bash @@ -132,7 +120,7 @@ Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:71 ## 📺 Vídeo tutorial -[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM) --- @@ -208,6 +196,24 @@ docker compose up -d --build # recompilar localmente (en lugar de desc --- +## 🌙 Builds nocturnos (Nightly) + +Los builds nocturnos contienen las últimas funciones no publicadas y se generan automáticamente en cada push de desarrollo. +Pueden ser inestables — úsalos para pruebas o acceso anticipado a nuevas funciones. + +```bash +docker compose -f docker-compose.yml -f docker-compose.nightly.yml pull +docker compose -f docker-compose.yml -f docker-compose.nightly.yml up -d +``` + +Volver a la versión estable: + +```bash +docker compose pull && docker compose up -d +``` + +--- + ## 🩹 Solución de problemas
diff --git a/README.md b/README.md index f925b3e..8f56b8d 100644 --- a/README.md +++ b/README.md @@ -20,37 +20,46 @@ officially tested or supported. Feedback welcome.   [![Releases](https://img.shields.io/badge/Download-Releases-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Downloads](https://img.shields.io/badge/Downloads-800%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) +[![Downloads](https://img.shields.io/badge/Downloads-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)   -[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM) Like KX-Bridge? A coffee on Ko-fi keeps the project alive. ☕ +> [!CAUTION] +> **Ongoing maintenance work** — We are restructuring the repository (branch model, CI workflows, contribution process). You may notice changes to branch names, PR templates, and how releases are published. We apologise for any inconvenience. Handling, workflow, and long-term maintainability will be significantly improved as a result. +> +> 👉 Want to contribute? Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. + --- -## Features +## ✨ Features | | | |---|---| | 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed | | 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream | | 🎨 | **AMS / multicolor** — slots with per-slot **profile picker** (assign your own brand from OrcaSlicer profiles per slot); bridge writes material & colour back to the printer display | +| 🏷️ | **Custom RFID tag matching** — spools tagged with third-party tools (e.g. the "ACE RFID" app) auto-match vendor + material against your imported OrcaSlicer profiles instead of falling back to a generic default | | 📦 | **Import your own OrcaSlicer profiles** — drag a ZIP from `~/.config/OrcaSlicer/user//filament/` into the bridge; they show up in the slot dropdown under ★ Own profiles | +| 🧵 | **Spoolman integration** — assign spools to AMS slots, filament usage tracked and synced automatically as you print | +| 🔗 | **Multi-ACE support** — multiple daisy-chained ACE units, including printers without a toolhead buffer | | 📷 | **Obico integration (experimental)** — Time-Lapse and WebRTC live stream against a self-hosted [Obico server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico | -| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget | -| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter | +| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget; auto-recovers after a printer reboot rotates its stream token, no manual reset needed | +| 🗂️ | **GCode browser** — two tabs: files you've uploaded (with thumbnails, print history, search & filter, multi-select + bulk delete) and files stored directly on the printer's own storage (with real thumbnails, multi-select + delete) | | 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown | | ➕ | **Add a printer with one click** — just enter the IP, credentials are imported automatically | +| 🖱️ | **Free-form dashboard** — drag & resize the dashboard tiles into your own layout, save it as a preset | | 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart | -| 🌐 | **Multi-language UI** — DE / EN / ES / 中文, auto-detect browser locale | +| 🌐 | **Multi-language UI** — DE / EN / ES / FR / IT / 中文, auto-detect browser locale | | 🔄 | **Self-update** — install new versions directly in the browser | | 🧠 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket); pair with the [OrcaSlicer-KX build](#-recommended-slicer) for proper per-slot vendor matching | --- -## Quick Start +## 🚀 Quick Start ### 1. Prepare the printer @@ -64,45 +73,24 @@ Enable LAN mode on the Kobra X: docker compose up -d ``` +> Want Spoolman and a full self-hosted Obico setup (spaghetti detection, live stream) +> alongside the bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml) bundles +> KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico behind one +> network — see the file's header comments for setup steps. + **Linux binary (no Docker):** ```bash -chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64 +chmod +x kx-bridge && ./kx-bridge ``` **Windows EXE (no Docker):** ``` kx-bridge.exe ``` +> `config\` and `data\` are created next to the EXE — portable. -> ⚠️ **TLS certificates required for the standalone binary** -> -> The bridge talks to the printer's MQTT over mTLS and needs two -> certificate files **right next to the binary**: -> -> - `anycubic_slicer.crt` -> - `anycubic_slicer.key` -> -> Both ship inside **`anycubic-certs.zip`** on the same release page. -> Download it and extract the two files into the same directory as -> `kx-bridge-linux-amd64` / `kx-bridge.exe`. Without them you'll see -> `Verbindung fehlgeschlagen: TLS-Zertifikate fehlen …` (0.9.19.1+) -> or `[Errno 2] No such file or directory` (older builds). -> -> Working layout: -> ``` -> ~/kx-bridge/ -> ├── kx-bridge-linux-amd64 (or kx-bridge.exe) -> ├── anycubic_slicer.crt ← from anycubic-certs.zip -> ├── anycubic_slicer.key ← from anycubic-certs.zip -> └── config/ (auto-created on first run) -> ``` -> -> Docker users don't need to do this — the certs are baked into the -> image. - -> With the Linux and Windows binaries, `config/` and `data/` (settings, -> SQLite, GCode store) live next to the program. Copy the whole folder -> = move the installation. +> With the Linux and Windows binaries, `config/` and `data/` (settings, SQLite, GCode store) +> live next to the program. Copy the whole folder = move the installation. **Python directly:** ```bash @@ -130,13 +118,13 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125` --- -## Video Tutorial +## 📺 Video Tutorial -[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM) +[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM) --- -## Recommended Slicer +## 🎨 Recommended Slicer For proper AMS filament-sync we ship a **patched OrcaSlicer build**: @@ -155,7 +143,7 @@ For proper AMS filament-sync we ship a **patched OrcaSlicer build**: - Vendor match when `tray_info_idx` is set but its preset is incompatible - Two-pass lookup: first compatible presets, then all visible ones -**Why this matters:** without #13719 the AMS slots in OrcaSlicer all fall back to `Generic PLA` / `Generic PETG` even though the bridge already sends the concrete brand (`name + vendor_name + gate_filament_name`). With the KX build OrcaSlicer matches your actual user presets — including profiles you imported into the bridge via the [Import your own OrcaSlicer profiles](https://gitea.it-drui.de/viewit/KX-Bridge-Release/src/branch/master/docs/filament-preset-bridge-guide.md) flow. +**Why this matters:** without #13719 the AMS slots in OrcaSlicer all fall back to `Generic PLA` / `Generic PETG` even though the bridge already sends the concrete brand (`name + vendor_name + gate_filament_name`). With the KX build OrcaSlicer matches your actual user presets — including profiles you imported into the bridge via the [Import your own OrcaSlicer profiles](#-features) flow. Stock upstream OrcaSlicer still works for slicing and printing — you just lose the per-slot brand matching on AMS sync. Slot material + colour can still be pushed bridge → printer either way (that goes over MQTT, not via the slicer). @@ -163,7 +151,7 @@ OrcaSlicer-KX is a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer --- -## Community & Integrations +## 🏠 Community & Integrations - **[Home Assistant integration](https://github.com/gangoke/kobrax-lan-hass-component)** by [@gangoke](https://github.com/gangoke) — exposes sensors, print controls, @@ -180,7 +168,7 @@ OrcaSlicer-KX is a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer --- -## Getting credentials manually +## 🔧 Getting credentials manually Normally not needed — *"+ Add printer"* does this automatically. If you do need it: @@ -197,7 +185,7 @@ Alternatively (if the IP is unknown): open AnycubicSlicerNext, connect the print --- -## Useful commands +## ⚙️ Useful commands ```bash docker compose logs -f # show logs @@ -208,7 +196,25 @@ docker compose up -d --build # rebuild locally (instead of pulling) --- -## Troubleshooting +## 🌙 Nightly Builds + +Nightly builds contain the latest unreleased features and are built automatically on every development push. +They may be unstable — use them for testing or early access to new functionality. + +```bash +docker compose -f docker-compose.yml -f docker-compose.nightly.yml pull +docker compose -f docker-compose.yml -f docker-compose.nightly.yml up -d +``` + +To go back to the stable release: + +```bash +docker compose pull && docker compose up -d +``` + +--- + +## 🩹 Troubleshooting
"Wrong MQTT credentials" on start @@ -242,7 +248,7 @@ Migration runs automatically on first start after the upgrade — no action requ --- -## Security +## 🔒 Security - The bridge is reachable on the local network at `http://:7125` — **do not** expose it to the internet - `config/config.ini` contains printer credentials — do not share publicly @@ -250,7 +256,7 @@ Migration runs automatically on first start after the upgrade — no action requ --- -## License +## 📄 License [![License: GPL v3](https://img.shields.io/badge/License-GPL_v3-blue.svg)](LICENSE) diff --git a/VERSION b/VERSION index a696a42..017ccd0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.19.1 +0.9.29 diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..2ae11a6 --- /dev/null +++ b/agents.md @@ -0,0 +1,31 @@ +# KX-Bridge Claude Agents + +## Available Agents + +| Agent | File | When to use | +|---|---|---| +| Reviewer | `.claude/agents/reviewer.md` | Before every PR — checks logic, error handling, Moonraker compatibility | +| Changelog | `.claude/agents/changelog.md` | After merge to nightly — generates CHANGELOG.md entry from commits | +| Test Writer | `.claude/agents/test-writer.md` | When adding new functions — derives pytest tests | +| Nightly Prep | `.claude/agents/nightly-prep.md` | Before a release — checks readiness of nightly → main merge | +| Docker Check | `.claude/agents/docker-check.md` | Before image push — validates Dockerfile and compose config | +| Moonraker Debug | `.claude/agents/moonraker-debug.md` | On runtime errors — analyzes Moonraker/Klipper logs | + +## Usage + +In VS Code with Claude Code extension: +``` +@reviewer → code review of current changes +@changelog → generate CHANGELOG entry +@test-writer → write tests for changed files +@nightly-prep → check release readiness +@docker-check → validate Docker config +@moonraker-debug → analyze logs +``` + +## Context + +- Moonraker API: Port 7125 +- AFC lane_data: flat indexing lane1–lane4 +- Registry: `gitea.it-drui.de/viewit/kx-bridge` +- Default PR target: `nightly` diff --git a/config.ini.example b/config.ini.example index f0fa3b3..5b31436 100644 --- a/config.ini.example +++ b/config.ini.example @@ -41,6 +41,21 @@ web_upload_warning = 1 # Poll-Intervall in Sekunden poll_interval = 3 +# ─── Spoolman (optional) ─────────────────────────────────────────────────────── +# Verfolgt den Filamentverbrauch je AMS-Slot und bucht ihn automatisch vom +# passenden Spool ab (mm-basiert, wie Moonraker; Spoolman rechnet mm→Gramm). +# [spoolman] +# # Server-URL der Spoolman-Instanz (aus Sicht des Bridge-Containers erreichbar): +# server = http://192.168.x.x:7912 +# # 0 = nur am Druckende abbuchen, >0 = alle N Sekunden während des Drucks: +# sync_rate = 0 +# +# Die AMS-Slot → Spool-Zuordnung wird in der Weboberfläche gesetzt und je Drucker +# automatisch persistiert (nicht von Hand eintragen): +# Einzeldrucker : [spoolman] slot_spools = 0:42,1:17 +# Multi-Printer : [spoolman_1] slot_spools = 0:42,1:17 +# [spoolman_2] slot_spools = 0:5,1:6 + # ─── Multi-Printer (optional) ────────────────────────────────────────────────── # Mehrere Drucker können als [printer_1], [printer_2], … definiert werden. # Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port). diff --git a/config/config.ini.example b/config/config.ini.example index b954b90..bea9879 100644 --- a/config/config.ini.example +++ b/config/config.ini.example @@ -31,6 +31,88 @@ default_ams_slot = auto # Auto-Leveling vor jedem Druck (1 = an, 0 = aus) auto_leveling = 1 +# Kamera-Stream bei Druckstart automatisch einschalten (1 = an, 0 = aus) +camera_on_print = 0 + +# Warnung vor Druck von Web-Uploads (1 = an, 0 = aus) +web_upload_warning = 1 + +# Nach Upload: Filament/Color-Selector automatisch öffnen (1 = an, 0 = aus) +print_start_dialog = 1 + +# ─── Filament-Profile pro AMS-Slot (optional) ──────────────────────────────── +# Beim Slicer-Sync nimmt OrcaSlicer per Default immer "Generic PLA/PETG/...". +# Mit diesen Mappings sendet die Bridge die konkrete Orca-Filament-ID + +# Vendor mit (Anzeige im Slicer dann z.B. "PolyTerra PLA — Polymaker" statt +# nur "Generic PLA"). Mapping wird über die Web-UI gepflegt. +# Beispiel: +# [filament_profiles] +# slot_0_id = OGFL01 +# slot_0_vendor = Polymaker +# slot_1_id = OGFG23 +# slot_1_vendor = Polymaker + [bridge] # Poll-Intervall in Sekunden poll_interval = 3 + +# ─── Multi-Printer (optional) ────────────────────────────────────────────────── +# Mehrere Drucker können als [printer_1], [printer_2], … definiert werden. +# Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port). +# bridge_url zeigt auf die jeweilige Bridge-Instanz (für den /kx/printers-Endpunkt). +# Die [connection]-Sektion wird weiterhin als Fallback für diese Instanz verwendet. +# +# Beispiel: +# [printer_1] +# name = Kobra X Links +# bridge_url = http://192.168.178.95:7125 +# printer_ip = 192.168.178.95 +# mqtt_port = 9883 +# username = userXXXXXXXXXX +# password = XXXXXXXXXXXXXXX +# device_id = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# mode_id = 20030 +# +# [printer_2] +# name = Kobra X Rechts +# bridge_url = http://192.168.178.96:7125 +# printer_ip = 192.168.178.96 +# mqtt_port = 9883 +# username = userYYYYYYYYYY +# password = YYYYYYYYYYYYYYY +# device_id = yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +# mode_id = 20030 + +[ace_dry_presets] +# Vordefinierte Dry-Set Presets (Temp in °C, Dauer in Sekunden) +pla_temp = 45 +pla_duration_sec = 14400 +pla_plus_temp = 45 +pla_plus_duration_sec = 14400 +petg_temp = 50 +petg_duration_sec = 14400 +tpu_temp = 55 +tpu_duration_sec = 14400 +abs_asa_temp = 45 +abs_asa_duration_sec = 28800 +pa_pc_temp = 55 +pa_pc_duration_sec = 43200 + +# Custom Presets (Name + Temp + Dauer) +custom_1_name = Custom 1 +custom_1_temp = 45 +custom_1_duration_sec = 14400 +custom_2_name = Custom 2 +custom_2_temp = 45 +custom_2_duration_sec = 14400 +custom_3_name = Custom 3 +custom_3_temp = 45 +custom_3_duration_sec = 14400 + +[spoolman] +# URL der Spoolman-Instanz (leer lassen um Spoolman zu deaktivieren) +# server = http://192.168.x.x:7912 + +# Wie oft (Sekunden) der Filamentverbrauch während des Drucks gemeldet wird +# (0 = nur beim Druckende) +# sync_rate = 0 diff --git a/config_loader.py b/config_loader.py index 807f911..459942e 100644 --- a/config_loader.py +++ b/config_loader.py @@ -1,18 +1,20 @@ """ -config_loader.py – lädt Verbindungsparameter aus config/config.ini (primär) -oder .env (Fallback / Migration). -Umgebungsvariablen haben immer Vorrang. +config_loader.py - loads connection parameters from config/config.ini (primary) +or .env (fallback / migration). +Environment variables always take precedence. """ import os import sys import pathlib import configparser +from typing import Optional _BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent CONFIG_SECTION_CONNECTION = "connection" CONFIG_SECTION_PRINT = "print" CONFIG_SECTION_BRIDGE = "bridge" +CONFIG_SECTION_SPOOLMAN = "spoolman" def _find_config_file() -> pathlib.Path | None: @@ -32,7 +34,7 @@ def _find_env_file() -> pathlib.Path | None: def _load_env_file(path: pathlib.Path): - """Lädt .env-Datei als Fallback – setzt nur Keys die noch nicht in os.environ sind.""" + """Loads the .env file as a fallback - only sets keys not yet in os.environ.""" with open(path, encoding="utf-8") as f: for line in f: line = line.strip() @@ -45,25 +47,39 @@ def _load_env_file(path: pathlib.Path): os.environ[key] = val +# Single source of truth for env-var <-> config.ini mapping. _restart_bridge() +# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ +# before restarting, so a value removed here or in the UI settings save can +# never survive as a stale env var read by the new process. Add new settings +# here ONLY - no second list to keep in sync. +CONFIG_ENV_MAPPING = { + "PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"), + "MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"), + "MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"), + "MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"), + "MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"), + "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), + "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), + "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), + "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"), + "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), + "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), + "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), + "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), + "BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"), + "POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"), + "VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"), + "SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"), + "SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"), +} + + def _load_config_file(path: pathlib.Path): - """Lädt config.ini und setzt Keys in os.environ (nur wenn nicht bereits gesetzt).""" + """Loads config.ini and sets keys in os.environ (only if not already set).""" cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - mapping = { - "PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"), - "MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"), - "MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"), - "MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"), - "MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"), - "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), - "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), - "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), - "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), - "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), - "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), - } - for env_key, (section, option) in mapping.items(): + for env_key, (section, option) in CONFIG_ENV_MAPPING.items(): if env_key not in os.environ: try: val = cfg.get(section, option) @@ -73,6 +89,18 @@ def _load_config_file(path: pathlib.Path): pass + # Backward compatibility: old key FILE_READY_DIALOG → PRINT_START_DIALOG + if "PRINT_START_DIALOG" not in os.environ: + try: + legacy = cfg.get(CONFIG_SECTION_PRINT, "file_ready_dialog") + if legacy: + os.environ["PRINT_START_DIALOG"] = legacy + except (configparser.NoSectionError, configparser.NoOptionError): + pass + if "PRINT_START_DIALOG" not in os.environ and "FILE_READY_DIALOG" in os.environ: + os.environ["PRINT_START_DIALOG"] = os.environ["FILE_READY_DIALOG"] + + def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): """Einmalige Migration: .env → config.ini anlegen.""" env_vals: dict[str, str] = {} @@ -96,8 +124,9 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): } cfg[CONFIG_SECTION_PRINT] = { "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), - "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), - "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), + "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), + "vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"), + "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), "web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), } cfg[CONFIG_SECTION_BRIDGE] = { @@ -105,12 +134,12 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): } with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n") - f.write("# Automatisch migriert aus .env\n\n") + f.write("# Automatically migrated from .env\n\n") cfg.write(f) def find_config_path() -> pathlib.Path: - """Gibt den Pfad zur config.ini zurück (auch wenn sie noch nicht existiert).""" + """Returns the path to config.ini (even if it does not exist yet).""" for base in (_BASE, _BASE.parent): config_dir = base / "config" if config_dir.is_dir(): @@ -126,7 +155,7 @@ _env_path = _find_env_file() if _config_path: _load_config_file(_config_path) elif _env_path: - # Kein config.ini vorhanden → aus .env migrieren + # No config.ini present -> migrate from .env _target = find_config_path() migrate_env_to_config(_env_path, _target) _load_config_file(_target) @@ -134,13 +163,13 @@ elif _env_path: def list_printers() -> list[dict]: - """Liest alle [printer_N]-Sektionen aus config.ini. + """Reads all [printer_N] sections from config.ini. - Jede Sektion kann folgende Keys haben: + Each section may contain the following keys: name, printer_ip, mqtt_port, username, password, mode_id, device_id, bridge_url, default_ams_slot, auto_leveling - Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind + Returns an empty list when no [printer_N] sections exist (Single-Printer-Betrieb via [connection]). """ path = _find_config_file() @@ -166,25 +195,43 @@ def list_printers() -> list[dict]: return printers -def list_filament_profiles() -> dict[int, dict]: - """Liest die [filament_profiles]-Sektion aus config.ini. +def _filament_section(printer_id: Optional[str] = None) -> str: + """Section name holding a printer's filament-profile mapping. - Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird - aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt - (als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit - derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig): + Multi-printer (one bridge, N printers): each printer keeps its own + ``[filament_profiles_]`` section so the mappings cannot overwrite each + other. ``printer_id is None`` (single-printer / legacy callers) maps to the + original global ``[filament_profiles]`` section — full backward compatibility. + """ + pid = str(printer_id).strip() if printer_id is not None else "" + if pid and pid != "0": + return f"filament_profiles_{pid}" + return "filament_profiles" + + +def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]: + """Reads the [filament_profiles] section from config.ini. + + With ``printer_id`` set, reads the per-printer ``[filament_profiles_]`` + section and falls back to the legacy global ``[filament_profiles]`` while + that printer has no own section yet. + + Format per AMS slot - the primary selector is (vendor, name); the `id` is + looked up from orca_filaments.json on save and carried along + (as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing + the same filament_id like 'OGFL99', i.e. the ID is not unique): [filament_profiles] slot_0_vendor = Polymaker slot_0_name = PolyTerra PLA slot_0_id = OGFL01 - Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}} - zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping - (per filament_type) in der Bridge bleibt dann aktiv. + Returns a dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}. + Empty/missing slots are NOT included - the default mapping + (per filament_type) in the bridge then stays active. - Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name` - fehlt dann und der Aufrufer kann optional aus der orca_filaments.json + Backwards compat: old configs with only (vendor, id) stay readable; `name` + is then missing and the caller can optionally resolve it from orca_filaments.json rekonstruieren. """ path = _find_config_file() @@ -192,11 +239,14 @@ def list_filament_profiles() -> dict[int, dict]: return {} cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if not cfg.has_section("filament_profiles"): + section = _filament_section(printer_id) + if not cfg.has_section(section): + section = "filament_profiles" # fallback: legacy global section + if not cfg.has_section(section): return {} result: dict[int, dict] = {} - for key, value in cfg.items("filament_profiles"): - # Erwartet: slot__id oder slot__vendor oder slot__name + for key, value in cfg.items(section): + # Expects: slot__id or slot__vendor or slot__name if not key.startswith("slot_"): continue parts = key.split("_", 2) @@ -215,30 +265,173 @@ def list_filament_profiles() -> dict[int, dict]: return result -def save_filament_profiles(profiles: dict[int, dict]) -> bool: - """Schreibt die übergebenen Slot-Profile in die [filament_profiles]- - Sektion der config.ini. Existierende Einträge werden komplett ersetzt. +def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool: + """Writes the given slot profiles into the [filament_profiles] + section of config.ini. Existing entries are completely replaced. profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}} - Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint). + At least vendor+name must be set; id is optional (hint). + + With ``printer_id`` set, writes the per-printer ``[filament_profiles_]`` + section only — other printers and the legacy global section are untouched. """ path = _find_config_file() if not path: return False cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") - if cfg.has_section("filament_profiles"): - cfg.remove_section("filament_profiles") - if profiles: - cfg["filament_profiles"] = {} + section = _filament_section(printer_id) + # visible_vendors (Issue #41) is not a slot mapping - preserve it when + # replacing the section, otherwise the vendor filter is lost on slot save. + # First save of a per-printer section inherits the legacy global filter. + preserved_vendors = None + if cfg.has_option(section, "visible_vendors"): + preserved_vendors = cfg.get(section, "visible_vendors") + elif cfg.has_option("filament_profiles", "visible_vendors"): + preserved_vendors = cfg.get("filament_profiles", "visible_vendors") + if cfg.has_section(section): + cfg.remove_section(section) + if profiles or preserved_vendors: + cfg[section] = {} + if preserved_vendors: + cfg[section]["visible_vendors"] = preserved_vendors for slot_idx in sorted(profiles.keys()): entry = profiles[slot_idx] or {} if entry.get("vendor"): - cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"] + cfg[section][f"slot_{slot_idx}_vendor"] = entry["vendor"] if entry.get("name"): - cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"] + cfg[section][f"slot_{slot_idx}_name"] = entry["name"] if entry.get("id"): - cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"] + cfg[section][f"slot_{slot_idx}_id"] = entry["id"] + with open(path, "w", encoding="utf-8") as f: + cfg.write(f) + return True + + +def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]: + """Reads [filament_profiles] visible_vendors (comma-separated) from config.ini. + + Vendor visibility filter for the slot profile dropdown (Issue #41 option A). + Empty list = no restriction (backwards compatible: all vendors). + + With ``printer_id`` set, reads the per-printer section and falls back to the + legacy global ``[filament_profiles]`` filter. + """ + path = _find_config_file() + if not path: + return [] + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _filament_section(printer_id) + if not cfg.has_option(section, "visible_vendors"): + section = "filament_profiles" # fallback: legacy global section + if not cfg.has_option(section, "visible_vendors"): + return [] + raw = cfg.get(section, "visible_vendors") + return [v.strip() for v in raw.split(",") if v.strip()] + + +def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool: + """Writes visible_vendors into [filament_profiles] without touching the + (slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder. + + With ``printer_id`` set, writes the per-printer section. When that section is + created here for the first time, the slot mappings are seeded from the legacy + global section so they are not orphaned by the read-fallback in + ``list_filament_profiles``.""" + path = _find_config_file() + if not path: + return False + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _filament_section(printer_id) + if not cfg.has_section(section): + cfg.add_section(section) + if section != "filament_profiles" and cfg.has_section("filament_profiles"): + for key, value in cfg.items("filament_profiles"): + if key.startswith("slot_"): + cfg[section][key] = value + clean = [v.strip() for v in (vendors or []) if v and v.strip()] + if clean: + cfg[section]["visible_vendors"] = ", ".join(clean) + elif cfg.has_option(section, "visible_vendors"): + cfg.remove_option(section, "visible_vendors") + with open(path, "w", encoding="utf-8") as f: + cfg.write(f) + return True + + +def _spoolman_map_section(printer_id: Optional[str] = None) -> str: + """Section name holding a printer's AMS-slot → Spoolman-spool map. + + Multi-printer (one bridge, N printers): each printer keeps its map in its + own ``[spoolman_]`` section so two AMS units cannot overwrite each + other's mapping. ``printer_id is None`` (single-printer / legacy callers) + uses the original ``[spoolman] slot_spools`` key — full backward + compatibility. The global ``[spoolman]`` section keeps ``server`` / + ``sync_rate`` regardless. + """ + pid = str(printer_id).strip() if printer_id is not None else "" + if pid and pid != "0": + return f"{CONFIG_SECTION_SPOOLMAN}_{pid}" + return CONFIG_SECTION_SPOOLMAN + + +def _parse_slot_spools(raw: str) -> dict[int, int]: + """Parse ``"0:42,1:17"`` → ``{0: 42, 1: 17}`` (positive spool ids only).""" + result: dict[int, int] = {} + for pair in (raw or "").split(","): + pair = pair.strip() + if ":" not in pair: + continue + k, _, v = pair.partition(":") + k, v = k.strip(), v.strip() + if k.isdigit() and v.lstrip("-").isdigit() and int(v) > 0: + result[int(k)] = int(v) + return result + + +def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]: + """Read the AMS-slot → Spoolman-spool-id map from config.ini. + + With ``printer_id`` set, reads the per-printer ``[spoolman_] + slot_spools`` key and falls back to the legacy global ``[spoolman] + slot_spools`` while that printer has no own section yet. Returns + ``{slot_index: spool_id}`` (only positive ids). + """ + path = _find_config_file() + if not path: + return {} + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _spoolman_map_section(printer_id) + if cfg.has_option(section, "slot_spools"): + return _parse_slot_spools(cfg.get(section, "slot_spools", fallback="")) + if cfg.has_option(CONFIG_SECTION_SPOOLMAN, "slot_spools"): # legacy global fallback + return _parse_slot_spools(cfg.get(CONFIG_SECTION_SPOOLMAN, "slot_spools", fallback="")) + return {} + + +def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None) -> bool: + """Persist the AMS-slot → Spoolman-spool-id map to config.ini. + + With ``printer_id`` set, writes only the per-printer ``[spoolman_]`` + section so other printers and the global ``[spoolman]`` server config stay + untouched. An empty map clears the key. + """ + path = _find_config_file() + if not path: + return False + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + section = _spoolman_map_section(printer_id) + clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0} + if clean: + if not cfg.has_section(section): + cfg.add_section(section) + cfg[section]["slot_spools"] = ",".join(f"{k}:{v}" for k, v in sorted(clean.items())) + elif cfg.has_option(section, "slot_spools"): + cfg.remove_option(section, "slot_spools") with open(path, "w", encoding="utf-8") as f: cfg.write(f) return True @@ -248,7 +441,7 @@ def get(key: str, default: str = "") -> str: return os.environ.get(key, default) -# Häufig verwendete Shortcuts +# Frequently used shortcuts PRINTER_IP = get("PRINTER_IP", "") MQTT_PORT = int(get("MQTT_PORT", "9883")) USERNAME = get("MQTT_USERNAME", "") @@ -256,6 +449,13 @@ PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") -AUTO_LEVELING = int(get("AUTO_LEVELING","1")) -CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0")) +AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) +VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) +PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) +SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "") +SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0")) +BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "") +POLL_INTERVAL = int(get("POLL_INTERVAL", "3")) +VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0")) diff --git a/docker-compose-KX.yml b/docker-compose-KX.yml new file mode 100644 index 0000000..6423220 --- /dev/null +++ b/docker-compose-KX.yml @@ -0,0 +1,210 @@ +# KobraX Full Stack — KX-Bridge + Obico Self-Hosted + Spoolman +# +# For Portainer: Stack → Add Stack → Upload → select this file +# +# Prerequisite: push the Obico images to the Gitea registry once: +# docker tag obico-server-web:latest gitea.it-drui.de/viewit/obico-web:latest +# docker tag obico-server-ml_api:latest gitea.it-drui.de/viewit/obico-ml:latest +# docker tag obico-server-tasks:latest gitea.it-drui.de/viewit/obico-tasks:latest +# docker push gitea.it-drui.de/viewit/obico-web:latest +# docker push gitea.it-drui.de/viewit/obico-ml:latest +# docker push gitea.it-drui.de/viewit/obico-tasks:latest +# +# Persistent data: /mnt/dockerdata/KobraXStack// +# +# Ports: +# 7125 — KX-Bridge (Moonraker API) +# 3334 — Obico (Web UI) +# 7912 — Spoolman (Web UI) +# +# Obico admin account after first start: +# docker exec obico-web python manage.py createsuperuser + +x-obico-base: &obico-base + restart: unless-stopped + volumes: + - /mnt/dockerdata/KobraXStack/obico/data:/data + - /mnt/dockerdata/KobraXStack/obico/frontend:/frontend + depends_on: + - obico-redis + environment: + DEBUG: "False" + REDIS_URL: "redis://obico-redis:6379" + DATABASE_URL: "sqlite:////data/db.sqlite3" + INTERNAL_MEDIA_HOST: "http://obico-web:3334" + ML_API_HOST: "http://obico-ml:3333" + ACCOUNT_ALLOW_SIGN_UP: "False" + SITE_USES_HTTPS: "False" + SITE_IS_PUBLIC: "False" + DJANGO_SECRET_KEY: "change-me-to-a-random-secret-key-before-use" + WEBPACK_LOADER_ENABLED: "False" + networks: + - kobrax-stack + +services: + + # ── KX-Bridge ─────────────────────────────────────────────── + kx-bridge: + image: gitea.it-drui.de/viewit/kx-bridge:latest + container_name: kx-bridge + restart: unless-stopped + ports: + - "7125:7125" + volumes: + - /mnt/dockerdata/KobraXStack/kx-bridge/config:/app/config + - /mnt/dockerdata/KobraXStack/kx-bridge/data:/app/data + networks: + - kobrax-stack + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # ── Spoolman ──────────────────────────────────────────────── + spoolman: + image: ghcr.io/donkie/spoolman:latest + container_name: spoolman + restart: unless-stopped + ports: + - "7912:8000" + volumes: + - /mnt/dockerdata/KobraXStack/spoolman:/home/app/.local/share/spoolman + networks: + - kobrax-stack + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # ── Obico Redis ───────────────────────────────────────────── + obico-redis: + image: redis:7.2-alpine + container_name: obico-redis + restart: unless-stopped + volumes: + - /mnt/dockerdata/KobraXStack/obico/redis:/data + networks: + - kobrax-stack + healthcheck: + test: ["CMD", "redis-cli", "ping"] + start_period: 10s + interval: 15s + timeout: 5s + retries: 10 + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" + + # ── Obico ML API ──────────────────────────────────────────── + obico-ml: + image: gitea.it-drui.de/viewit/obico-ml:latest + container_name: obico-ml + restart: unless-stopped + command: bash -c "gunicorn --bind 0.0.0.0:3333 --workers 1 wsgi" + working_dir: /app + environment: + DEBUG: "False" + FLASK_APP: "server.py" + networks: + - kobrax-stack + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3333/hc/ || exit 1"] + start_period: 30s + interval: 30s + timeout: 10s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # ── Obico Web ─────────────────────────────────────────────── + obico-web: + <<: *obico-base + image: gitea.it-drui.de/viewit/obico-web:latest + container_name: obico-web + ports: + - "3334:3334" + depends_on: + - obico-ml + - obico-redis + command: > + sh -c 'python manage.py migrate && + python manage.py shell -c "from django.contrib.sites.models import Site; s=Site.objects.first(); s.domain=\"192.168.178.204:3334\"; s.name=\"Obico\"; s.save()" && + python manage.py collectstatic --noinput && + daphne -b 0.0.0.0 -p 3334 config.routing:application' + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3334/hc/ || exit 1"] + start_period: 60s + interval: 90s + timeout: 20s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # ── Obico Tasks (Celery) ──────────────────────────────────── + obico-tasks: + <<: *obico-base + image: gitea.it-drui.de/viewit/obico-tasks:latest + container_name: obico-tasks + command: sh -c "celery -A config worker --beat -l info -c 2 -Q realtime,celery" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # ── moonraker-obico plugin ────────────────────────────────── + # Connects KX-Bridge to the Obico server (spaghetti detection, remote UI) + # Prerequisite: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg + # must exist and contain a valid auth_token. + # + # Getting a token (after the first obico-web start): + # docker exec obico-web python manage.py shell -c " + # from app.models import OneTimeVerificationCode, User + # from django.utils import timezone; from datetime import timedelta; import random + # u = User.objects.first() + # c = OneTimeVerificationCode.objects.create(user=u, code='%06d' % random.randint(100000,999999), expired_at=timezone.now()+timedelta(hours=2)) + # print('CODE:', c.code)" + # curl -X POST 'http://localhost:3334/api/v1/octo/verify/?code=' + # → enter printer.auth_token from the response into the cfg + moonraker-obico: + image: gitea.it-drui.de/viewit/moonraker-obico:latest + container_name: moonraker-obico + restart: unless-stopped + network_mode: host + volumes: + - /mnt/dockerdata/KobraXStack/moonraker-obico:/opt/printer_data/config + - /mnt/dockerdata/KobraXStack/moonraker-obico/logs:/opt/printer_data/logs + command: ["-c", "/opt/printer_data/config/moonraker-obico.cfg"] + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + kobrax-stack: + driver: bridge + +# Directories must exist on the host: +# mkdir -p /mnt/dockerdata/KobraXStack/kx-bridge/config \ +# /mnt/dockerdata/KobraXStack/kx-bridge/data \ +# /mnt/dockerdata/KobraXStack/spoolman \ +# /mnt/dockerdata/KobraXStack/obico/data \ +# /mnt/dockerdata/KobraXStack/obico/frontend \ +# /mnt/dockerdata/KobraXStack/obico/redis \ +# /mnt/dockerdata/KobraXStack/moonraker-obico/logs +# Spoolman requires UID/GID 1000: +# sudo chown -R 1000:1000 /mnt/dockerdata/KobraXStack/spoolman +# +# Create the moonraker-obico config (enter auth_token after Obico setup): +# cp /path/to/moonraker-obico.cfg.example /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg diff --git a/docker-compose.nightly.yml b/docker-compose.nightly.yml new file mode 100644 index 0000000..af0c245 --- /dev/null +++ b/docker-compose.nightly.yml @@ -0,0 +1,3 @@ +services: + kx-bridge: + image: gitea.it-drui.de/viewit/kx-bridge:nightly diff --git a/docker-compose.portainer-nightly.yml b/docker-compose.portainer-nightly.yml new file mode 100644 index 0000000..3e043f1 --- /dev/null +++ b/docker-compose.portainer-nightly.yml @@ -0,0 +1,25 @@ +# KX-Bridge Nightly — Portainer Stack +# +# Paste this into Portainer → Stacks → Add stack → Web editor +# +# Uses the nightly build — may be unstable, for testing new features early. +# For production use, see docker-compose.portainer.yml (:latest). + +services: + kx-bridge: + image: gitea.it-drui.de/viewit/kx-bridge:nightly + volumes: + - /mnt/dockerdata/kx-nightly/config:/app/config + - /mnt/dockerdata/kx-nightly/data:/app/data + ports: + # Port 7125 = first printer. Add 7126, 7127, … for each additional printer. + - "7125:7125" + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +# Verzeichnisse müssen auf dem Host existieren: +# mkdir -p /mnt/dockerdata/kx-nightly/config /mnt/dockerdata/kx-nightly/data diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml new file mode 100644 index 0000000..43a8db1 --- /dev/null +++ b/docker-compose.portainer.yml @@ -0,0 +1,29 @@ +# KX-Bridge — Portainer Stack +# +# Paste this into Portainer → Stacks → Add stack → Web editor +# +# No configuration needed upfront — just deploy, open http://HOST-IP:7125 +# and add your printer via the UI (IP only, credentials are fetched automatically). +# +# All data (config, GCode store, database) is stored in named Docker volumes +# managed by Portainer. + +services: + kx-bridge: + image: gitea.it-drui.de/viewit/kx-bridge:latest + volumes: + - kx-bridge-config:/app/config + - kx-bridge-data:/app/data + ports: + # Port 7125 = first printer. Add 7126, 7127, … for each additional printer. + - "7125:7125" + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + kx-bridge-config: + kx-bridge-data: diff --git a/docker-compose.yml b/docker-compose.yml index 21fa90c..773bebf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - ./.env:/app/.env:ro ports: - "7125-7130:7125-7130" + # environment: + # - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige) restart: unless-stopped logging: driver: json-file diff --git a/env_loader.py b/env_loader.py index 342df7b..97f37af 100644 --- a/env_loader.py +++ b/env_loader.py @@ -1,6 +1,6 @@ """ -env_loader.py – lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsverzeichnis). -Umgebungsvariablen haben Vorrang vor .env-Werten. +env_loader.py - loads connection parameters from .env (repo root or working directory). +Environment variables take precedence over .env values. """ import os import sys @@ -39,7 +39,7 @@ def get(key: str, default: str = "") -> str: return os.environ.get(key, default) -# Häufig verwendete Shortcuts +# Frequently used shortcuts PRINTER_IP = get("PRINTER_IP", "") MQTT_PORT = int(get("MQTT_PORT", "9883")) USERNAME = get("MQTT_USERNAME", "") @@ -47,4 +47,9 @@ PASSWORD = get("MQTT_PASSWORD", "") MODE_ID = get("MODE_ID", "") DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") -AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) +AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) +VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) +WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) +PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) +BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "") diff --git a/kobrax_client.py b/kobrax_client.py index fa50ce3..7d03980 100644 --- a/kobrax_client.py +++ b/kobrax_client.py @@ -1,10 +1,10 @@ """ kobrax_client.py – Anycubic Kobra X LAN-MQTT-Client -Protokoll vollständig rekonstruiert via Sniffer 2026-04-17 (953 Nachrichten). +Protocol fully reconstructed via sniffer 2026-04-17 (953 messages). Voraussetzungen: - - /tmp/anycubic_slicer.crt und .key (aus cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0) + - /tmp/anycubic_slicer.crt and .key (from cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0) - Drucker im LAN-Modus erreichbar auf Port 9883 Verwendung: @@ -27,6 +27,7 @@ import hashlib import json import logging import os +import select import socket import ssl import sys @@ -120,6 +121,10 @@ class KobraXClient: self._buf = b"" self._pid = 1 self._lock = threading.Lock() + # Generation marker: incremented on every socket swap/close so the + # reader thread notices when _reconnect/_do_connect swapped the socket + # underneath it (Issue #53). Protects against recv on a stale fd. + self._sock_gen = 0 self._running = False # Pending requests by msgid (for response ACK) @@ -157,9 +162,9 @@ class KobraXClient: if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE): raise FileNotFoundError( f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key " - f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). " - f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke " - f"die Dateien dorthin." + f"must sit next to the kx-bridge binary ({_SCRIPT_DIR}/). " + f"Download anycubic-certs.zip from the Gitea release and extract " + f"the files there." ) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.check_hostname = False @@ -167,20 +172,31 @@ class KobraXClient: ctx.set_ciphers("DEFAULT:@SECLEVEL=0") ctx.load_cert_chain(CERT_FILE, KEY_FILE) - raw = socket.create_connection((self.host, self.port), timeout=5) - self._sock = ctx.wrap_socket(raw) - log.info("TLS connected cipher=%s", self._sock.cipher()[0]) + # Build the socket as a local variable - the handshake (connect + CONNACK) + # runs WITHOUT holding the lock so a slow connect does not freeze + # senders. Only the finished socket is swapped in under the lock (#53). + _ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM) + raw = socket.create_connection(_ai[0][4], timeout=5) + new_sock = ctx.wrap_socket(raw) + log.info("TLS connected cipher=%s", new_sock.cipher()[0]) - self._sock.sendall(_build_connect(self.client_id, self.username, self.password)) - self._sock.settimeout(3) - r = self._sock.recv(64) + new_sock.sendall(_build_connect(self.client_id, self.username, self.password)) + new_sock.settimeout(3) + r = new_sock.recv(64) if len(r) < 4 or r[0] != 0x20 or r[3] != 0: + try: + new_sock.close() + except Exception: + pass raise RuntimeError(f"CONNACK failed: {r.hex()}") log.info("CONNACK rc=0") - self._sock.settimeout(0.2) - self._buf = b"" - self._subscribe(self._sub_topic()) + new_sock.settimeout(0.2) + with self._lock: + self._sock = new_sock + self._sock_gen += 1 + self._buf = b"" + self._subscribe(self._sub_topic()) # takes the lock itself - do not nest log.debug("MQTT connected to %s:%s", self.host, self.port) def connect(self): @@ -190,10 +206,10 @@ class KobraXClient: time.sleep(0.3) def _ensure_reader(self): - """Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer - früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler - gestorben ist, würden empfangene Replies sonst nie ankommen — publish() - würde dann zwar senden, aber auf Antworten ewig warten.""" + """Ensures the reader thread is alive. If the reader died after a + previous disconnect/reconnect sequence or an unhandled error, + received replies would never arrive - publish() + would still send but wait for replies forever.""" if not self._running: return # gewollter disconnect t = getattr(self, "_reader_thread", None) @@ -206,46 +222,57 @@ class KobraXClient: def disconnect(self): self._running = False - try: - self._sock.close() - except Exception: - pass + with self._lock: + try: + if self._sock is not None: + self._sock.close() + except Exception: + pass + self._sock = None + self._sock_gen += 1 def _reconnect(self): - """Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder - antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die - ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach - nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht + """Persistent reconnect: keeps retrying forever until the printer is + responds or disconnect() was called. Backoff caps at 60 s. The + first 5 attempts log as WARNING (acute connection issue), afterwards + only DEBUG to avoid log spam during long printer outages (e.g. switched ausgeschaltet) zu vermeiden.""" - log.warning("Verbindung verloren – reconnect…") - try: - self._sock.close() - except Exception: - pass + log.warning("Connection lost - reconnecting...") + # Close + invalidation under the lock so no sender is mid-sendall + # auf den gerade geschlossenen Socket trifft (Issue #53). + with self._lock: + try: + if self._sock is not None: + self._sock.close() + except Exception: + pass + self._sock = None + self._sock_gen += 1 delays = [2, 4, 8, 15, 30, 60] attempt = 0 while self._running: delay = delays[min(attempt, len(delays) - 1)] try: self._do_connect() - log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1) + log.info("Reconnect successful (after %d attempts)", attempt + 1) return True except Exception as e: attempt += 1 lvl = log.warning if attempt <= 5 else log.debug lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay) - # Geteiltes Sleep damit disconnect() den Loop schneller bricht. + # Split sleep so disconnect() breaks the loop faster. slept = 0.0 while slept < delay and self._running: time.sleep(min(0.5, delay - slept)) slept += 0.5 - return False # nur wenn disconnect() gerufen wurde + return False # only when disconnect() was called def _subscribe(self, topic: str): with self._lock: pid = self._pid self._pid += 1 - self._sock.sendall(_build_subscribe(topic, pid)) + if self._sock is not None: + self._sock.sendall(_build_subscribe(topic, pid)) log.info("SUB %s", topic) # -- Read loop ----------------------------------------------------------- @@ -255,26 +282,61 @@ class KobraXClient: _empty_count = 0 while self._running: if time.time() - last_ping > 30: + ping_ok = False with self._lock: try: - self._sock.sendall(_build_pingreq()) + if self._sock is not None: + self._sock.sendall(_build_pingreq()) + ping_ok = True except Exception: - if self._running and not self._reconnect(): - break - last_ping = time.time() - continue + ping_ok = False + # Call _reconnect() OUTSIDE the lock - it takes the lock + # itself, and threading.Lock is not reentrant (deadlock otherwise). + if not ping_ok: + if self._running and not self._reconnect(): + break last_ping = time.time() + # Grab the current socket + generation under the lock so a + # parallel _reconnect/_do_connect swap does not leave us polling + # a stale fd (Issue #53). + with self._lock: + sock = self._sock + gen = self._sock_gen + if sock is None: + time.sleep(0.05) + continue + + # Idle wait WITHOUT the lock - select only probes readiness, so + # the reader never blocks the shared lock while idle. try: - data = self._sock.recv(65536) + ready, _, _ = select.select([sock], [], [], 0.2) + except (OSError, ValueError): + # fd closed/invalid (reconnect or disconnect mid-select) + if not self._running: + break + time.sleep(0.05) + continue + if not ready: + continue # idle, no lock held + + # Data pending: briefly take the lock for the single recv, serialized + # against all sendall callers. recv does not block long (select said + # ready, socket timeout is 0.2s). + try: + with self._lock: + # The socket could have been swapped between select and here. + if self._sock_gen != gen or self._sock is not sock: + continue + data = sock.recv(65536) if not data: - # Windows SSL kann kurzzeitig b"" liefern ohne echten EOF + # Windows SSL can briefly return b"" without a real EOF _empty_count += 1 if _empty_count >= 5: raise ConnectionResetError("EOF") continue _empty_count = 0 self._buf += data - self._drain() + self._drain() # outside the lock - dispatch/event.set() stays prompt except ssl.SSLWantReadError: continue except socket.timeout: @@ -383,8 +445,8 @@ class KobraXClient: # -- Publish + request/response ------------------------------------------ def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None: - # Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben — - # sonst würden Replies nie ankommen und event.wait() läuft ins Timeout. + # If the reader thread is dead for historical reasons, revive it - + # otherwise replies would never arrive and event.wait() would time out. self._ensure_reader() msgid = str(uuid.uuid4()) payload = json.dumps({ @@ -409,7 +471,7 @@ class KobraXClient: report_registered = True topic = self._pub_topic(msg_type) - # Status-Poll-TX (query/getInfo) ist reines Rauschen (alle paar Sekunden) → + # Status poll TX (query/getInfo) is pure noise (every few seconds) -> # auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar. _tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO log.log(_tx_level, "TX %-25s action=%-12s data=%s", @@ -469,8 +531,8 @@ class KobraXClient: self._sock.sendall(_build_publish(topic, payload)) except Exception as e: log.error("web send error: %s, reconnecting…", e) - # Reconnect triggern (analog zu publish()); ohne Retry weil - # fire-and-forget — der nächste Aufruf wird auf den frischen Socket + # Trigger a reconnect (like publish()); no retry because it is + # fire-and-forget - the next call will hit the fresh socket # treffen. try: self._reconnect() @@ -517,13 +579,13 @@ class KobraXClient: # -- Part-Skip ("Exclude Object") --------------------------------------- def query_skip_objects(self) -> dict | None: - """Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste.""" + """Asks the printer for the current object/skip list.""" return self.publish("skip", "query_obj") def skip_objects(self, names: list[str]) -> dict | None: - """Überspringt die genannten Objekte – auch mid-print möglich. + """Skips the named objects - also possible mid-print. - Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen + Names correspond to the EXCLUDE_OBJECT_DEFINE NAME=... entries im GCode-Header bzw. file_details.objects_skip_parts. """ return self.publish("skip", "start", {"objects_skip_parts": list(names)}) @@ -591,13 +653,14 @@ class KobraXClient: f"Connection: close\r\n\r\n" ).encode() - # Connect-Timeout kurz (LAN). Während sendall() darf der Socket so - # lange brauchen wie nötig — bei großen Dateien (>100 MB) und - # langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und - # würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach - # generös (Drucker verarbeitet die Datei bevor er antwortet). - sock = socket.create_connection((self.host, 18910), timeout=10) - sock.settimeout(None) # blocking während Send + # Short connect timeout (LAN). During sendall() the socket may take + # as long as needed - with large files (>100 MB) and slower WiFi + # at the printer, pushing otherwise takes >30 s and would falsely + # trip the connect timeout. The read timeout afterwards is generous + # (the printer processes the file before replying). + _ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM) + sock = socket.create_connection(_ai[0][4], timeout=10) + sock.settimeout(None) # blocking during send sock.sendall(headers + body) sock.settimeout(180) response = b"" @@ -654,7 +717,7 @@ if __name__ == "__main__": parser.add_argument("--mode-id", default=env_loader.MODE_ID) parser.add_argument("--device-id", default=env_loader.DEVICE_ID) parser.add_argument("--monitor", action="store_true", - help="Dauerhaft mithören und alle Reports ausgeben") + help="Listen continuously and print all reports") args = parser.parse_args() client = KobraXClient( @@ -678,7 +741,7 @@ if __name__ == "__main__": client.callbacks["*"] = on_msg client.connect() - print("[kobrax] Monitor-Modus aktiv (Ctrl-C zum Beenden)") + print("[kobrax] Monitor mode active (Ctrl-C to stop)") try: while True: time.sleep(1) @@ -692,7 +755,7 @@ if __name__ == "__main__": info = client.query_info() if info: d = info.get("data", {}) - print(f" Drucker: {d.get('printerName')} FW {d.get('version')}") + print(f" Printer: {d.get('printerName')} FW {d.get('version')}") print(f" Status: {d.get('state')}") t = d.get("temp", {}) print(f" Nozzle: {t.get('curr_nozzle_temp')}°C → {t.get('target_nozzle_temp')}°C") @@ -701,6 +764,6 @@ if __name__ == "__main__": print(f" Upload: {urls.get('fileUploadurl')}") print(f" Kamera: {urls.get('rtspUrl')}") else: - print(" Keine Antwort") + print(" No response") client.disconnect() diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 0a3b163..db32088 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1,7 +1,7 @@ """ -kobrax_moonraker_bridge.py – Moonraker-kompatibler HTTP/WebSocket-Bridge für Anycubic Kobra X +kobrax_moonraker_bridge.py - Moonraker-compatible HTTP/WebSocket bridge for the Anycubic Kobra X -Emuliert die Moonraker/Klipper-API damit OrcaSlicer den Kobra X direkt ansteuern kann. +Emulates the Moonraker/Klipper API so OrcaSlicer can control the Kobra X directly. Verwendung: python kobrax_moonraker_bridge.py --printer-ip 192.168.178.94 @@ -34,6 +34,7 @@ except ImportError: import env_loader import asyncio import hashlib +import copy import json import logging import os @@ -47,10 +48,10 @@ import threading import html from urllib.parse import quote -# Bei PyInstaller-Binary liegt alles neben sys.executable, sonst neben __file__ +# For PyInstaller binaries everything sits next to sys.executable, otherwise next to __file__ _BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _BASE) -# Read-Only Web-Assets (Themes) werden im Onefile-Binary via --add-data unter +# Read-only web assets (themes) are embedded in the onefile binary via --add-data under # sys._MEIPASS entpackt; im Script-/Docker-Modus liegen sie neben dieser Datei. _WEB_BASE = getattr(sys, "_MEIPASS", _BASE) from kobrax_client import KobraXClient @@ -72,7 +73,7 @@ try: from aiohttp import web import aiohttp except ImportError: - print("Fehler: aiohttp nicht installiert. Bitte: pip install aiohttp") + print("Error: aiohttp is not installed. Run: pip install aiohttp") sys.exit(1) try: @@ -96,13 +97,13 @@ def _kx_decrypt_info(encrypted_b64: str, key: str, iv: str) -> dict: async def _kx_fetch_credentials(ip: str, port: int = 18910) -> dict: - """Holt + entschlüsselt Drucker-Credentials via HTTP /info + /ctrl. + """Fetches + decrypts printer credentials via HTTP /info + /ctrl. - Wirft eine Exception bei Netzwerk-/Decrypt-Fehlern. Algorithmus aus + Raises an exception on network/decrypt errors. Algorithm from tools/fetch_credentials.py (AES-256-CBC, Key=token[16:32], IV=ctrl-token). """ if not _HAS_CRYPTO: - raise RuntimeError("pycryptodome nicht installiert") + raise RuntimeError("pycryptodome is not installed") import random, string nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6)) timeout = aiohttp.ClientTimeout(total=10) @@ -133,29 +134,42 @@ logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)-5s %(name)s: %(message)s", datefmt="%H:%M:%S") log = logging.getLogger("bridge") +# aiohttp logs one INFO line per HTTP request (access log) — with 2s frontend +# polling that drowns out the bridge's own logs by default. Toggleable at +# runtime via the verbose_http_log setting (see handle_api_settings_post). +logging.getLogger("aiohttp.access").setLevel(logging.WARNING) -# Web-UI: Unterverzeichnis unter web/themes//index.html + +def _set_verbose_http_log(enabled: bool): + logging.getLogger("aiohttp.access").setLevel(logging.INFO if enabled else logging.WARNING) + +# Web UI: subdirectory under web/themes//index.html _UI_THEME_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") -# Erlaubte statische Theme-Dateien unter /kx/ui/ +# Allowed static theme files under /kx/ui/ _KX_UI_ASSETS: dict[str, str] = { "style.css": "text/css", "app.js": "application/javascript", } +# Files from lib/ are served based on their extension (no whitelist entry needed) +_KX_UI_LIB_TYPES: dict[str, str] = { + ".js": "application/javascript", + ".css": "text/css", +} _KX_UI_TRANSLATION_RE = re.compile(r"^translations/([a-z]{2}(?:-[a-z]{2})?)\.json$") -# Ring-Buffer für Browser-Log-Stream (letzte 200 Einträge) +# Ring buffer for the browser log stream (last 200 entries) import collections as _collections _log_buffer: "_collections.deque[dict]" = _collections.deque(maxlen=500) _log_sse_queues: "list[asyncio.Queue]" = [] class _BrowserLogHandler(logging.Handler): - """Sendet Log-Records in den Ring-Buffer und alle offenen SSE-Queues.""" + """Sends log records to the ring buffer and all open SSE queues.""" _fmt = logging.Formatter(datefmt="%H:%M:%S") def emit(self, record: logging.LogRecord): msg = record.getMessage() - # Exceptions mit Traceback in den Browser durchreichen (sonst sieht der - # Nutzer nur "Fehler: X" ohne Kontext). + # Pass exceptions with traceback through to the browser (otherwise the + # user only sees "Error: X" without context). if record.exc_info: try: msg += "\n" + self._fmt.formatException(record.exc_info) @@ -202,12 +216,12 @@ KLIPPER_VERSION = "v0.12.0-1" def _parse_gcode_estimated_time(data: bytes) -> int: - """Liest geschätzte Druckzeit aus GCode (OrcaSlicer + PrusaSlicer). - Gibt Sekunden zurück, 0 wenn nicht gefunden. - PrusaSlicer schreibt die Zeit ins Header (erste 16KB), - OrcaSlicer schreibt sie ans Ende der Datei (letzte 16KB).""" + """Reads the estimated print time from GCode (OrcaSlicer + PrusaSlicer). + Returns seconds, 0 when not found. + PrusaSlicer writes the time into the header (first 16KB), + OrcaSlicer writes it at the end of the file (last 16KB).""" import re - # Anfang + Ende der Datei durchsuchen (OrcaSlicer schreibt Zeit am Ende) + # Search the beginning + end of the file (OrcaSlicer writes the time at the end) search_text = (data[:16384] + data[-65536:]).decode("utf-8", errors="ignore") # OrcaSlicer: ; total estimated time: 9m 20s # PrusaSlicer: ; estimated printing time (normal mode) = 1h 9m 20s @@ -222,19 +236,19 @@ def _parse_gcode_estimated_time(data: bytes) -> int: elif unit == "m": secs += int(val) * 60 elif unit == "s": secs += int(val) if secs: - log.info(f"Slicer-Schätzzeit: {secs}s ({m.group(1).strip()})") + log.info(f"Slicer estimate: {secs}s ({m.group(1).strip()})") return secs def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]: - """Liest (layer_height, initial_layer_height) aus dem OrcaSlicer-/PrusaSlicer- - GCode-Header. Beide sind als Konfigblock am Ende des GCode hinterlegt. + """Reads (layer_height, initial_layer_height) from the OrcaSlicer/PrusaSlicer + GCode header. Both are stored as a config block at the end of the GCode. Beispiel-Zeilen: ; layer_height = 0.2 ; initial_layer_print_height = 0.2 - Liefert (0.0, 0.0) wenn nicht gefunden — Aufrufer entscheidet was er macht + Returns (0.0, 0.0) when not found - the caller decides what to do (typisch: keinen Z-Wert anzeigen).""" import re head = data[:16384].decode("utf-8", errors="ignore") @@ -256,7 +270,7 @@ def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]: def _extract_thumbnail(data: bytes) -> str: - """Extrahiert Base64-PNG-Thumbnail aus GCode (OrcaSlicer-Format).""" + """Extracts the base64 PNG thumbnail from GCode (OrcaSlicer format).""" try: marker = b"; thumbnail begin" end_marker = b"; thumbnail end" @@ -278,12 +292,12 @@ def _extract_thumbnail(data: bytes) -> str: def _extract_filament_info(data: bytes) -> list[dict]: - """Liest Filament-Farben/Materialien inkl. Tool-Reihenfolge aus Orca/Prusa-GCode. + """Reads filament colors/materials incl. tool order from Orca/Prusa GCode. Gibt Liste von {slot_index, color_hex, material} in Tool-/Paint-Reihenfolge - (T0, T1, ...) zurück. - Sucht sowohl am Anfang als auch am Ende der Datei, da Orca große - Thumbnail-Blöcke einfügen kann und Metadaten dann im Tail stehen. + (T0, T1, ...). + Searches both the start and the end of the file since Orca can insert + large thumbnail blocks, pushing the metadata into the tail. """ try: head = data[:131072] @@ -408,14 +422,14 @@ class GCodeStore: abort_reason TEXT ); """) - # Migration: Spalte gcode_filaments nachrüsten falls DB älter + # Migration: add gcode_filaments column for older databases try: self._conn.execute("ALTER TABLE gcode_files ADD COLUMN gcode_filaments TEXT") self._conn.commit() except Exception: pass - # Migration: Spalten objects_skip_parts + svg_image (Part-Skip-Feature, v0.9.10) - # Plus layer_height / first_layer_height (Obico Z-Höhe, v0.9.18) + # Migration: columns objects_skip_parts + svg_image (part-skip feature, v0.9.10) + # Plus layer_height / first_layer_height (Obico Z height, v0.9.18) for col, typ in ( ("objects_skip_parts", "TEXT"), ("svg_image", "TEXT"), @@ -427,7 +441,7 @@ class GCodeStore: self._conn.commit() except Exception: pass - # Migration: Flag für Web-Uploads (Warnhinweis vor Druck) + # Migration: flag for web uploads (warning before print) try: self._conn.execute("ALTER TABLE gcode_files ADD COLUMN web_unverified INTEGER NOT NULL DEFAULT 0") self._conn.commit() @@ -440,7 +454,7 @@ class GCodeStore: web_unverified: bool = False, layer_height: float = 0.0, first_layer_height: float = 0.0) -> str: - """Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück.""" + """Saves a GCode file to disk and DB. Returns the path.""" safe_name = os.path.basename(filename) path = os.path.join(self._gcode_dir, safe_name) with open(path, "wb") as f: @@ -480,7 +494,7 @@ class GCodeStore: return dict(row) if row else None def update_file_objects(self, filename: str, objects: list, svg: str = "") -> None: - """Speichert Objekt-Liste + optionales SVG zu einer Datei (matcht via filename).""" + """Saves the object list + optional SVG for a file (matched via filename).""" if not filename: return with self._lock: @@ -492,7 +506,7 @@ class GCodeStore: self._conn.commit() def update_file_filaments(self, file_id: str, gcode_filaments: list | None) -> None: - """Aktualisiert geparste GCode-Filamente für einen bestehenden DB-Eintrag.""" + """Updates parsed GCode filaments for an existing DB entry.""" with self._lock: self._conn.execute( "UPDATE gcode_files SET gcode_filaments=? WHERE id=?", @@ -574,17 +588,25 @@ class GCodeStore: class CameraCache: """Zentraler Kamera-Demuxer. - Hält EINEN ffmpeg-Prozess offen, der den FLV-Stream vom Drucker liest - und parallel zwei Outputs erzeugt: - - MJPEG @ 2fps → letzter Frame im RAM für /api/camera/snapshot - - MPEG-TS (-c:v copy) → Fanout an alle /api/camera/h264-Subscriber + Keeps ONE ffmpeg process per output type open that reads the FLV stream + from the printer and produces: + - MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot + - MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers + - MJPEG @ 15fps/640px -> fanout to all /api/camera/stream subscribers + (the live-view used by the dashboard AND by every Moonraker-compatible + client, since server.webcams.list advertises this same stream_url) Damit: - * Nur EINE FLV-Verbindung zum Drucker (löst Single-Client-Limit / 429) - * Snapshot ist instant (Speicher-Read, kein ffmpeg-Spawn pro Request) - * Mehrere parallele H.264-Konsumenten möglich (Plugin + Web-UI + …) + * Only ONE FLV connection to the printer per output type (solves the + single-client limit / 429) - previously /api/camera/stream opened a + brand-new, uncached ffmpeg + printer connection per HTTP client, which + competed with the cached jpeg/h264 connections for the printer's very + limited number of concurrent camera clients and caused intermittent + "stream unavailable" failures. + * Snapshots are instant (memory read, no ffmpeg spawn per request) + * Multiple parallel H.264/MJPEG consumers possible (plugin + web UI + ...) - Lazy-Start beim ersten Konsumenten, Auto-Restart bei ffmpeg-Crash. + Lazy start on the first consumer, auto-restart on ffmpeg crash. """ JPEG_SOI = b"\xff\xd8" @@ -596,31 +618,95 @@ class CameraCache: self.latest_jpeg: bytes = b"" self.latest_jpeg_ts: float = 0.0 self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set() + self.mjpeg_subscribers: "set[asyncio.Queue[bytes]]" = set() self._proc_jpeg: "asyncio.subprocess.Process | None" = None self._proc_h264: "asyncio.subprocess.Process | None" = None + self._proc_mjpeg: "asyncio.subprocess.Process | None" = None self._task_jpeg: "asyncio.Task | None" = None self._task_h264: "asyncio.Task | None" = None + self._task_mjpeg: "asyncio.Task | None" = None self._lock = asyncio.Lock() + self._fail_count_jpeg: int = 0 + self._fail_count_h264: int = 0 + self._fail_count_mjpeg: int = 0 def set_url(self, url: str): + # A changed URL means the printer rotated its stream token (typically + # after a reboot). Running ffmpeg processes still hold the stale URL + # and will never pick it up on their own - they only re-read self._url + # at the top of their outer loop, which they never reach while blocked + # in a stdout read on the old, now-silent connection. Tear them down; + # the next ensure_running() respawns them against the new URL. + changed = bool(url and self._url and url != self._url) self._url = url + if changed: + self.reset() + + def reset(self): + """Reset backoff counters and forcefully tear down any running + ffmpeg loops - including cancelling their background tasks. + + Only killing the ffmpeg subprocess is not enough: the owning task + might currently be sitting in `await asyncio.sleep(delay)` from a + previous exponential backoff (up to 300s) after an earlier failure. + Resetting the fail-count doesn't wake it up early, so a user + clicking "reset" could see nothing happen for minutes. Cancelling + the task guarantees an immediate, clean restart on the next + ensure_running() call. + """ + self._fail_count_jpeg = 0 + self._fail_count_h264 = 0 + self._fail_count_mjpeg = 0 + for task in (self._task_jpeg, self._task_h264, self._task_mjpeg): + if task is not None and not task.done(): + task.cancel() + for proc in (self._proc_jpeg, self._proc_h264, self._proc_mjpeg): + if proc is not None: + try: + proc.kill() + except Exception: + pass + self._task_jpeg = self._task_h264 = self._task_mjpeg = None + self._proc_jpeg = self._proc_h264 = self._proc_mjpeg = None async def ensure_running(self): - if self._proc_jpeg is None or self._proc_jpeg.returncode is not None: + # NOTE: we check the *task* state, not self._proc_* - the process + # handle is only assigned later, inside the task body, once ffmpeg + # has actually been spawned. Checking self._proc_* here left a race + # window: two callers arriving before the newly-created task got a + # chance to run would both see "no process yet" and each spawn a + # duplicate ffmpeg + duplicate printer connection, silently + # orphaning the older one (whichever task's coroutine runs last + # overwrites the shared self._proc_* reference, so nobody keeps a + # handle to kill the earlier orphaned process). Task creation is + # synchronous, so checking self._task_* here is race-free. + if self._task_jpeg is None or self._task_jpeg.done(): self._task_jpeg = asyncio.create_task(self._run_jpeg_loop()) - if self._proc_h264 is None or self._proc_h264.returncode is not None: + if self._task_h264 is None or self._task_h264.done(): self._task_h264 = asyncio.create_task(self._run_h264_loop()) + if self._task_mjpeg is None or self._task_mjpeg.done(): + self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop()) def _input_args(self, url: str) -> list[str]: - args = ["-fflags", "nobuffer", "-flags", "low_delay"] + args = ["-fflags", "nobuffer", "-flags", "low_delay", + # Bail out if the source goes silent. A printer reboot or + # network loss leaves the TCP connection ESTABLISHED with no + # data and no FIN, so a passive stdout read blocks forever + # without this (Issue #99). Value is microseconds. + "-timeout", "10000000"] if url.lower().startswith("rtsp://"): args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] else: - args += ["-probesize", "500000", "-analyzeduration", "500000"] + # The printer's FLV source occasionally emits non-monotonic container + # timestamps (PTS jumps of days) while the video data itself stays + # valid. Without this flag ffmpeg's realtime pacing breaks on such a + # jump and the stream stalls after ~15-30 min (Issue #90). + args += ["-use_wallclock_as_timestamps", "1", + "-probesize", "500000", "-analyzeduration", "500000"] return args async def _run_jpeg_loop(self): - """Hält einen ffmpeg-Prozess am Leben der MJPEG@2fps in den Cache schreibt.""" + """Keeps an ffmpeg process alive that writes MJPEG@2fps into the cache.""" while True: url = self._url if not url: @@ -628,20 +714,21 @@ class CameraCache: continue try: self._proc_jpeg = await asyncio.create_subprocess_exec( - _find_ffmpeg(), "-loglevel", "quiet", + _find_ffmpeg(), "-loglevel", "warning", *self._input_args(url), "-i", url, "-vf", "fps=2", "-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3", "-flush_packets", "1", "pipe:1", stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, ) except Exception as e: - log.warning(f"CameraCache: ffmpeg-jpeg start fehlgeschlagen: {e}") + log.warning(f"CameraCache: ffmpeg-jpeg start failed: {e}") await asyncio.sleep(3.0) continue buf = b"" + rc = None try: while True: chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK) @@ -664,8 +751,8 @@ class CameraCache: except Exception as e: log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}") finally: - # Kill + Wait — sonst bleibt der Child-Prozess als Zombie und - # asyncio meldet "Unknown child pid …" beim nächsten reaper-Tick. + # Kill + wait - otherwise the child process lingers as a zombie and + # asyncio reports "Unknown child pid ..." on the next reaper tick. if self._proc_jpeg is not None: try: self._proc_jpeg.kill() @@ -675,11 +762,26 @@ class CameraCache: await self._proc_jpeg.wait() except Exception: pass + rc = self._proc_jpeg.returncode + if rc: + try: + err = await self._proc_jpeg.stderr.read(500) + if err: + log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}") + except Exception: + pass self._proc_jpeg = None - await asyncio.sleep(2.0) # restart delay + if rc: + self._fail_count_jpeg += 1 + delay = min(2.0 * (2 ** self._fail_count_jpeg), 300.0) + log.warning(f"CameraCache: ffmpeg-jpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_jpeg})") + await asyncio.sleep(delay) + else: + self._fail_count_jpeg = 0 + await asyncio.sleep(2.0) async def _run_h264_loop(self): - """Hält einen ffmpeg-Prozess am Leben der MPEG-TS an alle Subscriber fanoutet.""" + """Keeps an ffmpeg process alive that fans out MPEG-TS to all subscribers.""" while True: url = self._url if not url: @@ -687,25 +789,26 @@ class CameraCache: continue try: self._proc_h264 = await asyncio.create_subprocess_exec( - _find_ffmpeg(), "-loglevel", "quiet", + _find_ffmpeg(), "-loglevel", "warning", *self._input_args(url), "-i", url, "-c:v", "copy", "-an", "-f", "mpegts", "pipe:1", stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, ) except Exception as e: - log.warning(f"CameraCache: ffmpeg-h264 start fehlgeschlagen: {e}") + log.warning(f"CameraCache: ffmpeg-h264 start failed: {e}") await asyncio.sleep(3.0) continue + rc = None try: while True: chunk = await self._proc_h264.stdout.read(self.TS_CHUNK) if not chunk: break - # Fanout: nicht-blockierend pro Subscriber, langsame Clients - # bekommen ihren ältesten Chunk verworfen (Queue voll → drop). + # Fanout: non-blocking per subscriber; slow clients + # get their oldest chunk dropped (queue full -> drop). for q in list(self.h264_subscribers): if q.full(): try: @@ -728,8 +831,149 @@ class CameraCache: await self._proc_h264.wait() except Exception: pass + rc = self._proc_h264.returncode + if rc: + try: + err = await self._proc_h264.stderr.read(500) + if err: + log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}") + except Exception: + pass self._proc_h264 = None - await asyncio.sleep(2.0) + if rc: + self._fail_count_h264 += 1 + delay = min(2.0 * (2 ** self._fail_count_h264), 300.0) + log.warning(f"CameraCache: ffmpeg-h264 exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_h264})") + await asyncio.sleep(delay) + else: + self._fail_count_h264 = 0 + await asyncio.sleep(2.0) + + async def _run_mjpeg_loop(self): + """Keeps an ffmpeg process alive that fans out MJPEG@15fps/640px + (complete JPEG frames) to all /api/camera/stream subscribers.""" + while True: + url = self._url + if not url: + await asyncio.sleep(2.0) + continue + try: + proc = await asyncio.create_subprocess_exec( + _find_ffmpeg(), "-loglevel", "warning", + *self._input_args(url), "-i", url, + "-vf", "fps=15,scale=640:-1", + "-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3", + "-flush_packets", "1", "pipe:1", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + self._proc_mjpeg = proc + except Exception as e: + log.warning(f"CameraCache: ffmpeg-mjpeg start failed: {e}") + await asyncio.sleep(3.0) + continue + + buf = b"" + rc = None + try: + while True: + chunk = await proc.stdout.read(self.TS_CHUNK) + if not chunk: + break + buf += chunk + # extract complete JPEG frames and fan them out whole + # (so every subscriber gets clean multipart boundaries, + # not arbitrary byte chunks like the h264/mpegts fanout) + while True: + start = buf.find(self.JPEG_SOI) + if start == -1: + buf = b"" + break + end = buf.find(self.JPEG_EOI, start + 2) + if end == -1: + buf = buf[start:] + break + frame = buf[start:end + 2] + buf = buf[end + 2:] + for q in list(self.mjpeg_subscribers): + if q.full(): + try: + q.get_nowait() + except Exception: + pass + try: + q.put_nowait(frame) + except Exception: + pass + except Exception as e: + log.debug(f"CameraCache: mjpeg-loop unterbrochen: {e}") + finally: + # NOTE: cleanup operates on the local `proc` reference, not + # on self._proc_mjpeg. If this task got cancelled (e.g. by + # reset()) a new task may already have started and assigned + # its own process to self._proc_mjpeg by the time we reach + # here - killing that shared attribute instead of our own + # local proc would kill the WRONG (newer) process. + try: + proc.kill() + except Exception: + pass + try: + await proc.wait() + except Exception: + pass + rc = proc.returncode + if rc: + try: + err = await proc.stderr.read(500) + if err: + log.warning(f"CameraCache: ffmpeg-mjpeg stderr: {err.decode(errors='replace').strip()}") + except Exception: + pass + if self._proc_mjpeg is proc: + self._proc_mjpeg = None + if rc: + self._fail_count_mjpeg += 1 + delay = min(2.0 * (2 ** self._fail_count_mjpeg), 300.0) + log.warning(f"CameraCache: ffmpeg-mjpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_mjpeg})") + await asyncio.sleep(delay) + else: + self._fail_count_mjpeg = 0 + await asyncio.sleep(2.0) + + +class SpoolmanClient: + """Thin synchronous HTTP client for Spoolman filament tracking. + + Designed to be called from daemon threads (poll loop, _on_print callbacks). + Uses requests (already in requirements) so no event-loop dependency. + """ + + def __init__(self, server_url: str, sync_rate: int = 0): + self.server_url = server_url.rstrip("/") + self.sync_rate = sync_rate + + def _req(self, method: str, path: str, **kwargs): + import requests + r = requests.request(method, f"{self.server_url}{path}", timeout=5, **kwargs) + r.raise_for_status() + return r.json() + + def health_check(self) -> bool: + try: + self._req("GET", "/api/v1/health") + return True + except Exception: + return False + + def list_spools(self) -> list: + return self._req("GET", "/api/v1/spool") + + def use_filament(self, spool_id: int, use_length_mm: float) -> None: + """Report consumed filament length in mm. Spoolman converts to weight + using the spool's filament profile density.""" + self._req("PUT", f"/api/v1/spool/{spool_id}/use", + json={"use_length": round(use_length_mm, 2)}) class KobraXBridge: @@ -739,19 +983,27 @@ class KobraXBridge: self._printer_id = printer_id self._all_bridges = all_bridges if all_bridges is not None else {} self.ws_clients: set[web.WebSocketResponse] = set() - # In-Memory KV-Store für Moonraker /server/database/item (moonraker-obico, - # mainsail-presets etc.). Nicht persistent — überlebt keinen Restart. + # In-memory KV store for Moonraker /server/database/item (moonraker-obico, + # mainsail presets etc.). Not persistent - does not survive a restart. self._moonraker_kv_store: dict[str, dict] = {} - # Slot→Orca-Filament-Profile-Mapping (aus config.ini [filament_profiles]). + # Slot -> Orca filament profile mapping (from config.ini [filament_profiles]). # Format: {slot_idx: {"id": "OGFL01", "vendor": "Polymaker"}}. - # Wird in _build_lane_data verwendet, damit OrcaSlicer die konkrete - # Marke ("PolyTerra PLA — Polymaker") statt nur "Generic PLA" anzeigt. + # Used in _build_lane_data so OrcaSlicer shows the concrete + # brand ("PolyTerra PLA - Polymaker") instead of just "Generic PLA". try: import config_loader as _cl - self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles() + self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles(self._printer_id) except Exception: self._filament_profiles = {} + # Vendor visibility filter for the slot profile dropdown (Issue #41 option A). + # Empty list = all vendors visible (backwards compatible). + try: + import config_loader as _cl + self._visible_vendors: list[str] = _cl.list_visible_vendors(self._printer_id) + except Exception: + self._visible_vendors = [] self._last_state: dict = {} + self._last_ams_set_request: dict | None = None self._state = { "nozzle_temp": 0.0, "nozzle_target": 0.0, @@ -766,10 +1018,10 @@ class KobraXBridge: "remain_time": 0, "curr_layer": 0, "total_layers": 0, - # Layer-Heights pro aktuell laufender Datei (aus dem GCode-Header - # geparst). Wird im Upload-Pfad + beim _fetch_from_store gesetzt. - # Obico nutzt currentZ aus gcode_position[2] — die Bridge rechnet - # currentZ aus curr_layer + diesen Werten in build_print_payload. + # Layer heights for the currently running file (parsed from the + # GCode header). Set in the upload path + in _fetch_from_store. + # Obico uses currentZ from gcode_position[2] - the bridge computes + # currentZ from curr_layer + these values in build_print_payload. "layer_height": 0.0, "first_layer_height": 0.0, "printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"), @@ -783,8 +1035,13 @@ class KobraXBridge: "print_speed_mode": 2, "connection_error": "", "file_ready": "", + "filament_mismatch": None, + "print_start_dialog": getattr(args, "print_start_dialog", 1), "filament_mode": "toolhead", + "supplies_usage": 0, "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None}, + "error_code": 0, + "pause_msg": "", } self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot @@ -794,22 +1051,58 @@ class KobraXBridge: self._head_tools_model: int = -1 self._filament_mode: str = "toolhead" self._last_uploaded_file: str = "" + # Pending waiters for a specific file/report `action` (e.g. "listLocal", + # "deleteBatch"). publish()'s own return value for these actions is just + # a generic immediate ACK skeleton (code=0, all fields empty) - the real + # answer arrives later via the file/report callback (_on_file), same + # as the existing fileDetails fire-and-forget pattern. Format: + # {action: {"event": threading.Event(), "result": dict|None}}. + self._file_action_waiters: dict[str, dict] = {} + # Thumbnail cache for files on the printer's own storage (filename -> + # base64 PNG string, "" if the file has no embedded thumbnail). + # In-memory only - not persisted, cleared on restart. + self._printer_thumbnail_cache: dict[str, str] = {} self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir self._current_job_id: str = "" self._camera_autostarted: bool = False + self._camera_user_stopped: bool = False # user manually stopped the camera during a print self.camera_cache: CameraCache = CameraCache() self._thumbnail_b64: str = "" self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config() - # Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10) + # Part skip: most recent skip list reported by the printer (v0.9.10) self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0} + # Pre-Print-Skip: pending until printer enters printing state + self._pending_preprint_skip: list[str] = [] + self._pending_preprint_skip_deadline: float = 0.0 - # Theme-Name prüfen (keine Sonderzeichen oder Umlaute) + # Spoolman filament tracking + _sm_url = (getattr(args, "spoolman_server", "") or "").strip() + self._spoolman: SpoolmanClient | None = ( + SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0)) + if _sm_url else None + ) + # Persistierte Spool-Zuordnung (AMS-Slot → Spoolman-Spool) je Drucker laden. + # Fix: this used to reference `config_loader`, but the module alias is + # `env_loader` (line 32) -> NameError swallowed by the bare `except`, + # so persistence never loaded. Now via the local import + per printer. + try: + import config_loader as _cl + self._spoolman_slot_spools: dict[int, int] = _cl.list_spool_map(self._printer_id) + except Exception as _e: + log.warning("Spoolman: failed to load slot map: %s", _e) + self._spoolman_slot_spools = {} # {ams_slot_idx: spoolman_spool_id} + self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print + self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman + self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick + self._spoolman_last_sync: float = 0.0 + + # Validate theme name (no special characters or umlauts) raw_theme = (getattr(args, "ui_theme", None) or "default").strip() if not _UI_THEME_NAME_RE.match(raw_theme): - log.warning("Ungültiger UI-Theme-Name %r – nutze default", raw_theme) + log.warning("Invalid UI theme name %r – using default", raw_theme) raw_theme = "default" self._ui_theme = raw_theme self._index_tpl_cache: str | None = None @@ -824,6 +1117,152 @@ class KobraXBridge: client.callbacks["light/report"] = self._on_light client.callbacks["skip/report"] = self._on_skip + # Reachability is rechecked periodically (not just once at boot) so the + # UI status dot reflects the printer's/Spoolman's actual current state + # instead of freezing on the boot-time result. + self._spoolman_reachable: bool = False + self._spoolman_last_health_check: float = 0.0 + if self._spoolman: + def _check(): + ok = self._spoolman.health_check() + self._spoolman_reachable = ok + self._spoolman_last_health_check = time.time() + log.info(f"Spoolman: {'OK' if ok else 'unreachable'} at {self._spoolman.server_url}") + threading.Thread(target=_check, daemon=True, name="spoolman-health").start() + + # ── Spoolman helpers ────────────────────────────────────────────────────── + + def _spoolman_filament_mm(self) -> float: + """Total filament_used_mm for the current print file from the GCode DB.""" + filename = self._state.get("filename", "") + if not filename: + return 0.0 + try: + gf = self._store.get_file_by_name(filename) + return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0 + except Exception: + return 0.0 + + def _spoolman_attribute_tick(self, activity_map: dict) -> None: + """Attribute the supplies_usage delta since last tick to the active slot. + + Skips attribution during loading/unloading transitions (tool changes + + purges) to avoid charging the wrong spool for purge material.""" + if not self._spoolman or not self._spoolman_slot_spools: + return + if self._state.get("print_state") != "printing": + return + current = self._state.get("supplies_usage", 0) + delta = current - self._spoolman_last_usage + self._spoolman_last_usage = current + if delta <= 0: + return + loaded = self._ams_loaded_slot + if loaded < 0: + return + if activity_map.get(loaded): + return + self._spoolman_slot_usage[loaded] = self._spoolman_slot_usage.get(loaded, 0.0) + delta + + def _spoolman_unreported(self) -> dict[int, float]: + """Return {slot_idx: mm} of usage not yet reported to Spoolman. + + Falls back to equal split of total supplies_usage when per-slot + attribution data is absent (e.g. single-extruder with no AMS).""" + total_used = self._state.get("supplies_usage", 0) + if self._spoolman_slot_usage: + return { + slot: self._spoolman_slot_usage.get(slot, 0.0) + - self._spoolman_slot_reported.get(slot, 0.0) + for slot in self._spoolman_slot_spools + } + n = len(self._spoolman_slot_spools) + already = sum(self._spoolman_slot_reported.values()) + per = (total_used - already) / n if n else 0.0 + return {slot: per for slot in self._spoolman_slot_spools} + + def _spoolman_report(self, unreported: dict[int, float], min_mm: float = 0.1) -> None: + """Fire-and-forget report of unreported mm to each mapped spool.""" + sm = self._spoolman + for slot_idx, mm in unreported.items(): + if mm < min_mm: + continue + spool_id = self._spoolman_slot_spools.get(slot_idx) + if not spool_id: + continue + self._spoolman_slot_reported[slot_idx] = ( + self._spoolman_slot_reported.get(slot_idx, 0.0) + mm + ) + def _send(sid=spool_id, length=mm): + try: + sm.use_filament(sid, length) + log.info(f"Spoolman: {length:.1f} mm → spool {sid}") + except Exception as e: + log.warning(f"Spoolman: report failed (spool {sid}): {e}") + threading.Thread(target=_send, daemon=True, name="spoolman-report").start() + + def _spoolman_notify_end(self): + """Report remaining filament on print end.""" + if not self._spoolman or not self._spoolman_slot_spools: + return + self._spoolman_report(self._spoolman_unreported()) + + def _spoolman_sync_midprint(self): + """Report incremental filament usage during a print (sync_rate interval).""" + if not self._spoolman or not self._spoolman_slot_spools: + return + self._spoolman_report(self._spoolman_unreported(), min_mm=10.0) + + # ── Spoolman API handlers ───────────────────────────────────────────────── + + async def handle_kx_spoolman_status(self, request): + """GET /kx/spoolman/status""" + return self._json_cors({ + "configured": bool(self._spoolman), + "reachable": self._spoolman_reachable if self._spoolman else False, + "server": self._spoolman.server_url if self._spoolman else "", + "sync_rate": self._spoolman.sync_rate if self._spoolman else 0, + "slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}, + }) + + async def handle_kx_spoolman_spools(self, request): + """GET /kx/spoolman/spools — proxied from Spoolman.""" + if not self._spoolman: + return self._json_cors({"error": "Spoolman not configured"}, status=503) + try: + spools = await asyncio.get_event_loop().run_in_executor( + None, self._spoolman.list_spools + ) + return self._json_cors({"spools": spools}) + except Exception as e: + log.warning(f"Spoolman: list_spools failed: {e}") + return self._json_cors({"error": str(e)}, status=502) + + async def handle_kx_spoolman_set_active(self, request): + """POST /kx/spoolman/active-spool + Body: {"slot_map": {"0": 42, "2": 17}} — AMS slot index → Spoolman spool ID.""" + try: + data = await request.json() + except Exception: + return self._json_cors({"error": "invalid JSON"}, status=400) + slot_map = data.get("slot_map") or data.get("slot_spools") or {} + self._spoolman_slot_spools = { + int(k): int(v) for k, v in slot_map.items() + if str(v).isdigit() and int(v) > 0 + } + # Persist per printer (own [spoolman_] section) so the + # assignment survives bridge restarts and two AMS units don't overwrite each other. + # (Previously: NameError on `config_loader` -> nothing was ever saved.) + try: + import config_loader as _cl + _cl.save_spool_map(self._spoolman_slot_spools, self._printer_id) + except Exception as _e: + log.warning("Spoolman: failed to save slot map: %s", _e) + self._spoolman_slot_usage = {} + self._spoolman_slot_reported = {} + self._spoolman_last_usage = 0.0 + return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}}) + def _default_ace_dry_presets(self) -> dict[str, dict]: return { "pla": {"temp": 45, "duration_sec": 4 * 3600}, @@ -910,19 +1349,33 @@ class KobraXBridge: if kobra_state: self._state["kobra_state"] = kobra_state - # Kamera bei Druckstart automatisch einschalten (Settings-Option). - # Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI). + # Automatically switch on the camera at print start (settings option). + # Centralized here so it covers all print start paths (OrcaSlicer + UI). # _camera_autostarted verhindert Mehrfach-Trigger pro Druck. if kobra_state == "printing": - if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False): + if (getattr(self._args, "camera_on_print", 0) + and not self._camera_autostarted + and not self._camera_user_stopped): self._camera_autostarted = True try: self.client.start_camera() - log.info("Kamera bei Druckstart automatisch eingeschaltet") + log.info("Camera switched on automatically at print start") except Exception as e: - log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") + log.warning(f"Camera auto-start failed: {e}") elif kobra_state in ("free", "finished", "stoped", "canceled"): self._camera_autostarted = False + self._camera_user_stopped = False # release for the next print + + if kobra_state in ("pause", "paused"): + pause_msg = payload.get("msg", "") + if pause_msg: + error_code = payload.get("code", 0) + self._state["error_code"] = error_code + self._state["pause_msg"] = pause_msg + log.warning(f"Printer paused: [{error_code}] {pause_msg}") + elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"): + self._state["error_code"] = 0 + self._state["pause_msg"] = "" # Job-History: Druckstart erkennen if kobra_state == "printing" and not self._current_job_id: @@ -934,24 +1387,30 @@ class KobraXBridge: gcode_file_id=gf["id"], printer_id=self._printer_id, ) - log.info(f"Job gestartet: {self._current_job_id} für {filename}") + log.info(f"Job started: {self._current_job_id} for {filename}") + self._spoolman_slot_usage = {} + self._spoolman_slot_reported = {} + self._spoolman_last_usage = 0.0 + self._spoolman_last_sync = 0.0 # Job-History: Druckende erkennen if kobra_state in ("finished",) and self._current_job_id: self._store.finish_job(self._current_job_id, status="completed") log.info(f"Job abgeschlossen: {self._current_job_id}") + self._spoolman_notify_end() self._current_job_id = "" elif kobra_state in ("stoped", "canceled") and self._current_job_id: self._store.finish_job(self._current_job_id, status="cancelled") log.info(f"Job abgebrochen: {self._current_job_id}") + self._spoolman_notify_end() self._current_job_id = "" - # Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der - # Drucker meldet "finished" nach erfolgreichem Druck — file_ready wurde - # bisher nur bei stoped/canceled geleert, dadurch kam das Banner zurück. - if kobra_state == "finished": - self._state["file_ready"] = "" - if kobra_state in ("stoped", "canceled"): + # Terminal states (successful finish AND stop/cancel) must leave the + # same clean end state - a "finished" print used to only clear + # file_ready (Issue #29), leaving progress/filename/duration/layer + # fields stuck at the last job's values until the *next* print + # happened to overwrite them (Issue #102). + if kobra_state in ("finished", "stoped", "canceled"): self._state["progress"] = 0.0 self._state["filename"] = "" self._state["file_ready"] = "" @@ -960,9 +1419,21 @@ class KobraXBridge: self._state["slicer_time"] = 0 self._state["layer_height"] = 0.0 self._state["first_layer_height"] = 0.0 + self._state["supplies_usage"] = 0 + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 self._thumbnail_b64 = "" - self._state["filename"] = d.get("filename", self._state["filename"]) - if "progress" in d: + else: + # Only adopt the payload's filename outside terminal states - the + # printer often still reports the just-finished job's filename in + # the same "finished"/"stoped"/"canceled" message that triggered + # the reset above, which would otherwise immediately undo it. + self._state["filename"] = d.get("filename", self._state["filename"]) + # Pre-print phases (leveling/preheating/checking) report their own + # "progress" - passing it through would make display_status.progress/ + # virtual_sdcard.progress jump non-monotonically once real printing + # starts and the value resets (Issue #102). + if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): self._state["progress"] = float(d["progress"]) / 100.0 if "print_time" in d: self._state["print_duration"] = int(d["print_time"]) * 60 @@ -974,6 +1445,8 @@ class KobraXBridge: self._state["total_layers"] = d["total_layers"] if "taskid" in d: self._state["taskid"] = str(d["taskid"]) + if "supplies_usage" in d: + self._state["supplies_usage"] = int(d["supplies_usage"]) settings = d.get("settings") or {} if "print_speed_mode" in settings: self._state["print_speed_mode"] = int(settings["print_speed_mode"]) @@ -981,39 +1454,48 @@ class KobraXBridge: def _on_info(self, payload: dict): d = payload.get("data") or {} - # MQTT-Name nur übernehmen wenn kein eigener Name gesetzt (env oder per-Drucker config) + # Only adopt the MQTT name if no custom name is set (env or per-printer config) if not env_loader.get("BRIDGE_PRINTER_NAME") and not getattr(self, "_name_locked", False): self._state["printer_name"] = d.get("printerName", self._state["printer_name"]) self._state["firmware_version"] = d.get("version", self._state["firmware_version"]) - # Der echte Druck-State steckt bei info/report im verschachtelten - # project.state ("printing"/"paused"/…). Das oberste data.state ist nur - # der Geräte-State ("busy"/"free") und würde "paused" verschlucken. + # The real print state lives in info/report inside the nested + # project.state ("printing"/"paused"/...). The top-level data.state is only + # the device state ("busy"/"free") and would swallow "paused". project = d.get("project") or {} proj_state = project.get("state", "") kobra_state = proj_state or d.get("state", "") if kobra_state: self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby") self._state["kobra_state"] = kobra_state - # Upload-Banner nach Druckende ausblenden (Issue #29) – der State kommt - # je nach Drucker auch über info/report (project.state), nicht nur print/report. + # Hide the upload banner after the print ends (Issue #29) - the state also + # arrives via info/report (project.state) depending on the printer, not only print/report. + # Layer fields must reset here too (Issue #102) - info/report is the + # only source for curr_layer/total_layers on some printers, and they + # otherwise stay stuck at the last job's values indefinitely. if kobra_state in ("finished", "stoped", "canceled"): self._state["file_ready"] = "" - # Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report). - # _camera_autostarted-Guard verhindert Doppel-Start mit _on_print. + self._state["curr_layer"] = 0 + self._state["total_layers"] = 0 + # Camera auto-start here as well (OrcaSlicer often reports the start via info/report). + # The _camera_autostarted guard prevents a double start with _on_print. if kobra_state == "printing": - if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False): + if (getattr(self._args, "camera_on_print", 0) + and not self._camera_autostarted + and not self._camera_user_stopped): self._camera_autostarted = True try: self.client.start_camera() - log.info("Kamera bei Druckstart automatisch eingeschaltet") + log.info("Camera switched on automatically at print start") except Exception as e: - log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") + log.warning(f"Camera auto-start failed: {e}") elif kobra_state in ("free", "finished", "stoped", "canceled"): self._camera_autostarted = False + self._camera_user_stopped = False # release for the next print if project: if "filename" in project: self._state["filename"] = project["filename"] - if "progress" in project: + # Same non-monotonic-progress guard as _on_print (Issue #102). + if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"): self._state["progress"] = float(project["progress"]) / 100.0 if "print_time" in project: self._state["print_duration"] = int(project["print_time"]) * 60 @@ -1046,47 +1528,144 @@ class KobraXBridge: def _on_skip(self, payload: dict): """skip/report-Callback (Part-Skip-Feature, v0.9.10). - Drucker meldet hier IMMER die Liste der bereits geskippten Objekte - zurück (objects_skip_parts), egal ob auf query_obj oder nach skip/start. - Die Gesamt-Objektliste kommt aus file/report. + The printer ALWAYS reports the list of already-skipped objects here + (objects_skip_parts), whether on query_obj or after skip/start. + The full object list comes from file/report. """ d = payload.get("data") or {} skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or [] - # Liste immer (auch leer) übernehmen – sonst bleibt sie auf alten Stand + # While a pre-print skip is still pending, ignore empty early reports + # so the UI doesn't snap back before the printer confirms the skip. + now = time.time() + if (not skipped and self._pending_preprint_skip + and now <= self._pending_preprint_skip_deadline): + return + + # During an active print, skip states are effectively monotonic. + # Some firmware reports come back empty/partial in between; + # those must not remove already-confirmed skip objects from the UI. + existing_skipped = [str(n) for n in (self._skip_state.get("skipped") or []) if n] + existing_set = set(existing_skipped) + incoming_skipped = [str(n) for n in (skipped or []) if n] + incoming_set = set(incoming_skipped) + active_print = self._state.get("print_state") in ("printing", "paused") + if active_print and existing_set: + if not incoming_set: + skipped = list(existing_skipped) + elif not incoming_set.issuperset(existing_set): + merged = list(existing_skipped) + for n in incoming_skipped: + if n not in existing_set: + merged.append(n) + skipped = merged + + # Release the pending lock once the printer confirms the requested objects + if self._pending_preprint_skip and set(skipped) >= set(self._pending_preprint_skip): + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 self._skip_state = { "skipped": list(skipped), "ts": int(time.time()), } if payload.get("state") == "done" or payload.get("code") == 200: - log.info(f"Skip-Antwort: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") + log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") + + def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None: + """Sends a file/* MQTT request (via send_fn, which must call + self.client.publish(..., timeout=0) fire-and-forget) and blocks the + calling thread until a matching file/report with this `action` + arrives via _on_file, or the timeout elapses. + + Needed because the printer's publish() return value for actions like + listLocal/deleteBatch is just a generic immediate ACK skeleton + (code=0, empty fields) - the real response is a separate, later + file/report message, same as the existing fileDetails pattern. + Must be called from a worker thread (e.g. via run_in_executor), not + the asyncio event loop, since it blocks on a threading.Event. + """ + event = threading.Event() + waiter = {"event": event, "result": None} + self._file_action_waiters[action] = waiter + try: + send_fn() + event.wait(timeout) + return waiter["result"] + finally: + if self._file_action_waiters.get(action) is waiter: + del self._file_action_waiters[action] def _on_file(self, payload: dict): + # Deliver to any pending listLocal/deleteBatch waiter first (see + # _wait_for_file_action) - these actions carry no file_details/ + # thumbnail payload of their own, so this doesn't interfere with the + # handling below. + action = payload.get("action") or "" + waiter = self._file_action_waiters.get(action) + if waiter is not None: + waiter["result"] = payload + waiter["event"].set() + d = payload.get("data") or {} details = d.get("file_details") or {} thumb = details.get("thumbnail") or details.get("png_image") or "" - if thumb: + file_name = d.get("filename") or details.get("filename") or self._last_uploaded_file + active_print = self._state.get("print_state") in ("printing", "paused") + current_print_file = self._state.get("filename") or "" + # Uploads during a running print must not overwrite the active + # progress preview. + if thumb and (not active_print or (file_name and file_name == current_print_file)): self._thumbnail_b64 = thumb - log.info(f"Vorschaubild empfangen: {len(thumb)} Zeichen base64") + log.info(f"Thumbnail received: {len(thumb)} base64 chars") # Part-Skip: Objekt-Liste + optionales SVG (v0.9.10) objs = details.get("objects_skip_parts") or [] svg = details.get("svg_image") or "" if objs: - filename = d.get("filename") or details.get("filename") or self._last_uploaded_file + filename = file_name if filename: try: self._store.update_file_objects(filename, objs, svg) - log.info(f"Skip-Objekte für {filename}: {len(objs)} ({'mit SVG' if svg else 'ohne SVG'})") + log.info(f"Skip objects for {filename}: {len(objs)} ({'with SVG' if svg else 'no SVG'})") except Exception as e: - log.warning(f"update_file_objects fehlgeschlagen: {e}") + log.warning(f"update_file_objects failed: {e}") self._push_status_update() + def _apply_preprint_skip_after_start(self, names: list[str], retries: int = 20, delay_s: float = 0.75): + """Sends the skip command only after the printer switched to the printing state. + + Before that, the command goes nowhere (no active print). + """ + wanted = [str(n) for n in (names or []) if isinstance(n, str) and n] + if not wanted: + return False + for i in range(max(1, int(retries))): + try: + if self._state.get("print_state") not in ("printing", "paused"): + time.sleep(max(0.1, float(delay_s))) + continue + resp = self.client.skip_objects(wanted) + if resp is not None: + log.info(f"Pre-Print skip applied ({len(wanted)} objects) on attempt {i+1}/{retries}") + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + return True + except Exception as e: + log.debug(f"Pre-Print skip attempt {i+1}/{retries} failed: {e}") + time.sleep(max(0.1, float(delay_s))) + log.warning(f"Pre-Print skip could not be confirmed after {retries} attempts") + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + return False + @staticmethod def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str: """Detect active filament topology mode. Modes: - toolhead: only toolhead slots - - ace_direct: ACE channels directly mapped (no toolhead box present) + - ace_direct: ACE channels directly mapped, no toolhead box present. + Covers one unit (Kobra X) as well as multiple daisy-chained units + (Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a + block of 4 global slots at box_id * 4. - ace_hub: toolhead + ACE via hub (slot 4 as hub path) """ toolhead = any(b.get("id") == -1 for b in boxes) @@ -1122,19 +1701,22 @@ class KobraXBridge: return global_slots, global_loaded if mode == "ace_direct": - # ace_direct exposes exactly 4 channels total. - # If firmware reports multiple ACE boxes, keep only the first one. - if ace_boxes: - ace = ace_boxes[0] - ace_id = ace["id"] - for local_idx, s in enumerate((ace.get("slots") or [])[:4]): - s = dict(s) - s["global_index"] = local_idx - s["box_id"] = ace_id - global_slots.append(s) - ace_loaded = ace.get("loaded_slot", -1) - if 0 <= ace_loaded < 4: - global_loaded = ace_loaded + # One or more ACE units, no toolhead buffer (Kobra X: 1 unit, + # Kobra S1: up to 2+ units, Issue #95). Global index = + # box_id * 4 + local slot, so the numbering matches + # _global_to_box_slot's //4-%4 fallback and stays stable + # regardless of report order. + for ace in ace_boxes: + ace_id = int(ace["id"]) + base = ace_id * 4 + for local_idx, s in enumerate((ace.get("slots") or [])[:4]): + s = dict(s) + s["global_index"] = base + local_idx + s["box_id"] = ace_id + global_slots.append(s) + ace_loaded = ace.get("loaded_slot", -1) + if 0 <= ace_loaded < 4: + global_loaded = base + ace_loaded return global_slots, global_loaded # ace_hub @@ -1232,7 +1814,7 @@ class KobraXBridge: return selected if warn_on_empty_default: - log.warning(f"Standard-Slot {slot_idx} ist leer – fallback auf Auto") + log.warning(f"Default slot {slot_idx} is empty - falling back to auto") return all_loaded @staticmethod @@ -1251,16 +1833,40 @@ class KobraXBridge: loaded = loaded_slots if loaded is None: loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default) - return [ - { - "paint_index": pidx, - "ams_index": self._slot_to_print_ams_index(gidx), - "paint_color": [255, 255, 255, 255], - "ams_color": self._slot_color_rgba(s), - "material_type": s.get("type", "PLA"), - } - for pidx, (gidx, s) in enumerate(loaded) - ] + if not loaded: + return [] + loaded_map = {gidx: s for gidx, s in loaded} + max_idx = max(loaded_map.keys()) + # The printer interprets ams_box_mapping as an ordered list (entry N = TN). + # Missing slots must be inserted as placeholders, otherwise everything shifts. + # A placeholder must NOT reference a physically empty tray: the printer + # rejects such an entry even for a tool the GCode never calls (printing + # Filament 4 with the slot below it empty fails; all-full works). Point + # gap placeholders at a definitely-loaded tray instead of the gap's own + # (empty) index. + fallback_gidx = max_idx # highest loaded slot -> loaded + printable + fallback_slot = loaded_map[fallback_gidx] + fallback_ams = self._slot_to_print_ams_index(fallback_gidx) + result = [] + for i in range(max_idx + 1): + if i in loaded_map: + s = loaded_map[i] + result.append({ + "paint_index": i, + "ams_index": self._slot_to_print_ams_index(i), + "paint_color": [255, 255, 255, 255], + "ams_color": self._slot_color_rgba(s), + "material_type": s.get("type", "PLA"), + }) + else: + result.append({ + "paint_index": i, + "ams_index": fallback_ams, + "paint_color": [255, 255, 255, 255], + "ams_color": self._slot_color_rgba(fallback_slot), + "material_type": fallback_slot.get("type", "PLA"), + }) + return result def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: """Build print mapping from UI filament assignments. @@ -1309,20 +1915,18 @@ class KobraXBridge: if box_id == -1: return local_slot if self._filament_mode == "ace_direct": - return local_slot + # Multi-ACE (Issue #95): each unit occupies its own block of 4. + # Identical to the old `return local_slot` for a single unit (id 0). + return box_id * 4 + local_slot return 3 + box_id * 4 + local_slot def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict: """Build {global_slot_index: loading|unloading} from feed_status data.""" + # Note: all boxes are considered — the old primary_ace_id filter (skip + # every ACE box except the first in ace_direct mode) is gone since the + # slot aggregation now handles multiple ACE units (Issue #95). activity: dict = {} - primary_ace_id = -1 - if self._filament_mode == "ace_direct": - ace_ids = sorted(int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0) - if ace_ids: - primary_ace_id = ace_ids[0] for box in boxes: - if self._filament_mode == "ace_direct" and primary_ace_id >= 0 and int(box.get("id", -1)) != primary_ace_id: - continue fs = box.get("feed_status") or {} current_status = int(fs.get("current_status", -1)) local_slot = int(fs.get("slot_index", -1)) @@ -1352,10 +1956,21 @@ class KobraXBridge: return activity def _on_multicolor_box(self, payload: dict): + if payload.get("state") == "failed": + req = getattr(self, "_last_ams_set_request", None) + log.warning( + f"multiColorBox setInfo rejected by printer: request={req} raw_response={payload.get('data')}" + ) + self._state["last_ams_set_error"] = True + return data = payload.get("data") or {} + if not isinstance(data, dict): + log.warning(f"multiColorBox/report: unexpected data shape: {data!r}") + return boxes = data.get("multi_color_box") or [] if not boxes: return + self._state["last_ams_set_error"] = False self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) self._state["filament_mode"] = self._filament_mode @@ -1373,8 +1988,8 @@ class KobraXBridge: for s in global_slots: s["activity"] = activity_map.get(s.get("global_index"), "") - # Tip-Forming: nach Einziehen (status=10) oder Ausziehen (status=11) - # schickt der originale Slicer automatisch type=3 (Extruder-Rückzug). + # Tip forming: after feed-in (status=10) or feed-out (status=11) + # the original slicer automatically sends type=3 (extruder retract). # Check ALL boxes so ACE-triggered events are handled correctly. for box in boxes: fs = box.get("feed_status") or {} @@ -1389,12 +2004,12 @@ class KobraXBridge: {"multi_color_box": [{"id": bi, "feed_status": {"slot_index": si, "type": 3}}]}, timeout=0 ) - log.info(f"Tip-Forming (type=3) nach status={cs} box={bi} slot={si}") + log.info(f"Tip forming (type=3) after status={cs} box={bi} slot={si}") threading.Thread(target=_tip_form, daemon=True).start() if global_slots: self._ams_slots = global_slots - log.info(f"AMS-Slots empfangen: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}") + log.info(f"AMS slots received: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}") self._push_status_update() def _update_ace_drying_state(self, data: dict, boxes: list): @@ -1494,39 +2109,165 @@ class KobraXBridge: self._push_status_update() # OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping) - # Default-Mapping pro Material-Typ wenn der User keinen Slot-Profil- - # Override gesetzt hat. Für den Kobra X bevorzugen wir Anycubic-eigene - # Filament-IDs aus den `@Anycubic Kobra X 0.4 nozzle`-Profilen — die - # sind druckerspezifisch is_compatible und werden von OrcaSlicer direkt - # gematched. Library-Fallbacks (OGF*) nur für Material-Typen ohne - # Kobra-X-spezifisches Anycubic-Profil — deren @System-Profile haben - # `compatible_printers: []` (= mit allen Druckern kompatibel). + # Default mapping per material type when the user has not set a slot + # profile override. For the Kobra X we prefer Anycubic's own + # filament IDs from the `@Anycubic Kobra X 0.4 nozzle` profiles - those + # are printer-specific is_compatible and are picked up by OrcaSlicer directly + # matched. Library fallbacks (OGF*) only for material types without + # Kobra X-specific Anycubic profile - their @system profiles have + # `compatible_printers: []` (= compatible with all printers). _TRAY_INFO_IDX = { # Anycubic-eigene Kobra-X-Profile - "PLA": "GFPLA", - "PLA+": "GFPLA+", - "PLA SILK": "GFPLA Silk", - "PETG": "GFPETG", - "ABS": "GFABS", - "ASA": "GFASA", - "TPU": "GFTPU 95A", - "PVA": "GFPVA", + "PLA": "GFPLA", + "PLA+": "GFPLA+", + "PLA SILK": "GFPLA Silk", + "PLA-SILK": "GFPLA Silk", + "PLASILK": "GFPLA Silk", + "SILK PLA": "GFPLA Silk", + "PLA MATTE": "GFPLA", + "PLA-MATTE": "GFPLA", + "PLA MARBLE": "GFPLA", + "PLA WOOD": "GFPLA", + "PETG": "GFPETG", + "PETG+": "GFPETG", + "ABS": "GFABS", + "ASA": "GFASA", + "TPU": "GFTPU 95A", + "TPE": "GFTPU 95A", + "PVA": "GFPVA", # Kein Anycubic-Kobra-X-Profil → Library-Fallback - "PLA-CF": "OGFL98", - "PETG-CF": "OGFG98", - "PA": "OGFN99", - "PA-CF": "OGFN98", - "PC": "OGFC99", - "HIPS": "OGFS98", + "PLA-CF": "OGFL98", + "PLA CF": "OGFL98", + "PETG-CF": "OGFG98", + "PETG CF": "OGFG98", + "PA": "OGFN99", + "PA-CF": "OGFN98", + "PA CF": "OGFN98", + "PC": "OGFC99", + "HIPS": "OGFS98", } - def _build_lane_data(self) -> dict: - """Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0. + # Normalizes material type strings to the canonical key for _TRAY_INFO_IDX + # and _default_filament_name. PLA variants without an exact match fall + # back to their base family (PLA+ -> PLA+, PLA Matte -> PLA, etc.). + @staticmethod + def _normalize_material(mat: str) -> str: + m = mat.upper().strip().replace("-", " ").replace("_", " ") + # Bekannte Varianten normalisieren + _ALIASES = { + "PLAPLUS": "PLA+", "PLA PLUS": "PLA+", + "SILK PLA": "PLA SILK", "PLASILK": "PLA SILK", + "PLA MATTE": "PLA MATTE", "PLA MARBLE": "PLA MARBLE", + "PLA WOOD": "PLA WOOD", + "TPE": "TPU", + "PETG PLUS": "PETG+", + "PA6": "PA", "PA12": "PA", "PA66": "PA", + } + if m in _ALIASES: + return _ALIASES[m] + return m - POSITIONSTREU: jeder physische Slot behält seine Position (tray id = - Slot-Position). Leere Slots werden als Platzhalter-Tray gemeldet, NICHT - weggefiltert/komprimiert — sonst rutschen die Farben auf falsche Positionen - (z.B. Slot 1=gelb, 2=leer, 3=rot → rot dürfte nicht auf Position 2 landen). + @staticmethod + def _material_family(mat: str) -> str: + """Reduce a material to its base polymer family. + + PLA / PLA+ / PLA SILK / PLA MATTE -> "PLA"; PETG / PETG+ -> "PETG"; etc. + Used by the stale-profile guard: only a change of *family* (e.g. PETG -> + PLA) invalidates a saved slot profile — a change within the family + (PLA -> PLA SILK) must not discard an otherwise valid profile. + """ + if not mat: + return "" + m = KobraXBridge._normalize_material(mat) + # Longer prefixes first so "PETG" is not swallowed by "PET". + for fam in ("PETG", "PLA", "ABS", "ASA", "TPU", "PVA", "HIPS", "PA", "PC", "PET"): + if m.startswith(fam): + return fam + return m + + def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]: + """Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g. + "GEEETECH PLA Bas", written via third-party RFID tools) into + (vendor, material_family). + + Anycubic's ACE RFID system concatenates vendor + material + a + truncated serial/variant into one `type` string for custom tags - + unlike a normal spool report where `type` is just "PLA"/"PETG"/etc. + Returns ("", "") when the first token isn't a known vendor (from the + merged system+user filament library), which leaves plain type + strings like "PLA" completely unaffected (Issue #101). + """ + tokens = raw_type.split() + if len(tokens) < 2: + return "", "" + first = tokens[0].strip().lower() + vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()} + vendor = vendors.get(first) + if not vendor: + return "", "" + family = self._material_family(" ".join(tokens[1:])) + if not family: + return "", "" + return vendor, family + + def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict: + """Find an imported/system filament profile by (vendor, material + family) - used to auto-resolve a combined ACE-RFID type string to + the user's already-imported OrcaSlicer profile (Issue #101), since + the exact profile `name` never appears verbatim in the truncated + RFID string.""" + matches = [ + p for p in self._load_orca_filaments() + if p.get("vendor", "").lower() == vendor.lower() + and self._material_family(p.get("type", "")) == family + ] + if not matches: + return {} + if len(matches) > 1: + log.debug( + f"_match_profile_by_vendor_family: {len(matches)} profiles match " + f"vendor={vendor!r} family={family!r}, using first: {matches[0].get('name')}" + ) + return matches[0] + + def _profile_material(self, profile: dict) -> str: + """Material type (e.g. "PETG") of a saved slot profile, resolved by + (vendor, name) from the Orca filament library. Returns "" when the + profile is not in the library — we do NOT guess in that case.""" + name = (profile or {}).get("name", "") + if not name: + return "" + vendor = profile.get("vendor", "") + for p in self._load_orca_filaments(): + if p.get("vendor") == vendor and p.get("name") == name: + return p.get("type", "") or "" + return "" + + def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict: + """Saved slot-profile override — but only while its material *family* + still matches the material currently loaded in the AMS. + + Non-destructive suppression (Option A): when the family no longer matches + (e.g. a PETG profile but PLA loaded) we return {} → the slot falls back to + the generic default. The override stays in config.ini and reactivates as + soon as the matching material is loaded again. When the profile's family + is unknown we do NOT suppress (fail-safe).""" + profile = self._filament_profiles.get(global_idx) or {} + if not profile.get("name"): + return {} + prof_fam = self._material_family(self._profile_material(profile)) + ams_fam = self._material_family(ams_material) + if prof_fam and ams_fam and prof_fam != ams_fam: + return {} + return profile + + def _build_lane_data(self) -> dict: + """Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0. + + POSITION-FAITHFUL: every physical slot keeps its position (tray id = + slot position). Empty slots are reported as placeholder trays, NOT + filtered out/compacted - otherwise colors shift to wrong positions + (e.g. slot 1=yellow, 2=empty, 3=red -> red must not land on position 2). """ slots = self._ams_slots total = len(slots) @@ -1558,26 +2299,44 @@ class KobraXBridge: color_hex = color_raw[:6].upper() + "FF" else: color_hex = "FFFFFFFF" - material = slot.get("type", "PLA").upper() - # User-Override aus config.ini [filament_profiles].slot_N_id - # bekommt Vorrang vor dem Default-Mapping nach material-Type. - # Vendor wird mitgesendet (tray_sub_brands + filament_vendor), - # damit ein gepatchter OrcaSlicer den Match nach Marke + Type + - # Farbe machen kann (analog SnapmakerPrinterAgent). - # Zwei-Schicht-Resolution für den Filament-Hint an OrcaSlicer: + material = self._normalize_material(slot.get("type", "PLA")) + # User override from config.ini [filament_profiles].slot_N_id + # takes precedence over the default mapping by material type. + # The vendor is sent along (tray_sub_brands + filament_vendor), + # so a patched OrcaSlicer can match by brand + type + + # color (analogous to SnapmakerPrinterAgent). + # Two-layer resolution for the filament hint sent to OrcaSlicer: # 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle - # 2. Generic-Fallback (_TRAY_INFO_IDX) pro Material-Typ — kein - # Vendor-Hint, OrcaSlicer trifft dann sein eigenes Generic-Preset - user_profile = self._filament_profiles.get(slot_index) or {} + # 2. Generic fallback (_TRAY_INFO_IDX) per material type - no + # vendor hint; OrcaSlicer then picks its own generic preset + # Stale-profile guard: only apply the override while its material + # family still matches the loaded filament (PETG profile + PLA + # loaded -> dropped). + user_profile = self._effective_slot_profile(slot_index, material) + if not user_profile.get("name"): + # Third layer: auto-resolve a combined ACE-RFID "VENDOR TYPE + # SERIAL" string (e.g. "GEEETECH PLA Bas", from third-party + # RFID tools) against the user's already-imported profile + # library, instead of falling through to the neutral Generic + # fallback (Issue #101). Not persisted to config.ini - this + # re-derives on every _build_lane_data() call, so a + # differently-tagged spool loaded later isn't stuck with a + # stale match. + vendor_guess, family_guess = self._parse_combined_rfid_type(slot.get("type", "")) + if vendor_guess: + auto_profile = self._match_profile_by_vendor_family(vendor_guess, family_guess) + if auto_profile.get("name"): + user_profile = auto_profile + material = family_guess if user_profile.get("name"): vendor = user_profile.get("vendor", "") fila_name = user_profile.get("name", "") tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99") else: # Default: Library-Generic-Profil (siehe _default_filament_name) — - # ist mit allen Druckern kompatibel und garantiert sichtbar. - # Der User wählt pro Slot bewusst eine konkrete Marke wenn er - # eine will; Default bleibt neutral. + # is compatible with all printers and guaranteed to be visible. + # The user deliberately picks a concrete brand per slot if they + # want one; the default stays neutral. fila_name = self._default_filament_name(material) vendor = "Generic" if fila_name.startswith("Generic ") else "" tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99") @@ -1590,14 +2349,14 @@ class KobraXBridge: "tray_sub_brands": vendor, # OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` + # `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name → - # filament_id_by_type). Wir senden beide Schreibweisen mit - # damit ältere Patch-Varianten + zukünftige Upstream-PRs beide - # bedient sind. + # filament_id_by_type). We send both spellings so that + # older patch variants + future upstream PRs are both + # covered. "name": fila_name, "vendor_name": vendor, - # Aliase für ältere Patch-Varianten (Variante 2, + # Aliases for older patch variants (variant 2, # MoonrakerPrinterAgent.cpp): filament_id direkt (exakt), - # sonst preset-Name per find_preset() auflösen. + # otherwise resolve the preset name via find_preset(). "filament_id": tray_info_idx, "filament_vendor": vendor, "filament_name": fila_name, @@ -1626,9 +2385,9 @@ class KobraXBridge: """OrcaSlicer-Default-Filename-Pattern: `___.gcode` z.B. `adapter_e27_plate(01)_PLA_0.2_41m1s.gcode` → 0.2. - Fallback wenn der GCode-Header nicht geparst wurde (z.B. Datei direkt am - Slicer gestartet, oder vor v0.9.18 hochgeladen). Liefert 0.0 wenn das - Pattern nicht greift.""" + Fallback when the GCode header was not parsed (e.g. file started directly + on the slicer, or uploaded before v0.9.18). Returns 0.0 when the + pattern does not match.""" import re if not fname: return 0.0 @@ -1641,18 +2400,18 @@ class KobraXBridge: return 0.0 def _estimate_current_z(self) -> float: - """Schätzt die aktuelle Z-Höhe aus curr_layer + Layer-Heights. + """Estimates the current Z height from curr_layer + layer heights. - Der Drucker liefert keine echte Z-Position via MQTT, aber Obico - (moonraker-obico/printer.py:267) liest currentZ aus `gcode_position[2]`. - Wir rechnen das mit der layer_height aus dem GCode-Header zurück: + The printer provides no real Z position via MQTT, but Obico + (moonraker-obico/printer.py:267) reads currentZ from `gcode_position[2]`. + We back-compute it with the layer_height from the GCode header: z = first_layer_height + (curr_layer - 1) * layer_height - Werte werden im Upload-Pfad gesetzt und nur bei Druckabbruch/-ende - zurückgesetzt (Slot-/Farbänderungen ändern nichts daran). Falls die - Werte fehlen (z.B. weil der Druck direkt am Slicer gestartet wurde - ohne Upload über die Bridge), wird einmalig aus dem GCode-Store - nachgeladen. Liefert 0.0 wenn nichts bekannt — Obico zeigt dann + Values are set in the upload path and only reset on print cancel/end + (slot/color changes do not affect them). If the values are + missing (e.g. because the print was started directly on the slicer + without an upload through the bridge), they are reloaded once from + the GCode store. Returns 0.0 when nothing is known - Obico then shows keinen Z-Wert.""" s = self._state layer_h = float(s.get("layer_height") or 0.0) @@ -1667,12 +2426,12 @@ class KobraXBridge: except Exception: pass if not layer_h and fname: - # Letzter Fallback: OrcaSlicer-Default-Filename enthält die Layer-Height + # Last fallback: the OrcaSlicer default filename contains the layer height layer_h = self._layer_height_from_filename(fname) if layer_h and not first_h: first_h = layer_h if layer_h: - # cache in state damit nicht jeder Build wieder den Store fragt + # cache in state so not every build queries the store again s["layer_height"] = layer_h s["first_layer_height"] = first_h if not layer_h: @@ -1687,13 +2446,22 @@ class KobraXBridge: # WebSocket push # ------------------------------------------------------------------------- + # Static objects that never change at runtime. They are delivered once + # via objects.query/subscribe, but NOT included in every + # notify_status_update - otherwise Mobileraker's + # ConfigFile.parse (expensive + strict) runs on every status tick and the app + # hangs/crashes on refresh (Issue #48). + _STATIC_STATUS_OBJECTS = ("configfile", "webhooks", "heaters", "history") + def _push_status_update(self): if not self.ws_clients: return + objs = self._build_printer_objects() + live = {k: v for k, v in objs.items() if k not in self._STATIC_STATUS_OBJECTS} msg = { "jsonrpc": "2.0", "method": "notify_status_update", - "params": [self._build_printer_objects(), time.time()], + "params": [live, time.time()], } text = json.dumps(msg) dead = set() @@ -1706,9 +2474,9 @@ class KobraXBridge: def _build_mmu_object(self) -> dict: # POSITIONSTREU: ein Gate je physischem Slot, in Reihenfolge. Leere Slots - # bekommen gate_status=0 (statt weggelassen zu werden) – sonst rutschen die + # get gate_status=0 (instead of being omitted) - otherwise the # Farben in OrcaSlicer auf falsche Gates (Slot 1=gelb, 2=leer, 3=rot → - # rot darf nicht auf Gate 1 landen). gate_status 0=leer, 1=verfügbar. + # red must not land on gate 1). gate_status 0=empty, 1=available. slots = sorted( ((int(s.get("global_index", i)), s) for i, s in enumerate(self._ams_slots)), key=lambda item: item[0], @@ -1721,28 +2489,34 @@ class KobraXBridge: num_gates = len(slots) gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], [] gate_filament_name = [] + gate_spool_id = [] for _global_index, slot in slots: occupied = slot.get("status") == 5 gate_status.append(1 if occupied else 0) - material = (slot.get("type") or "PLA").upper() if occupied else "" + material = self._normalize_material(slot.get("type") or "PLA") if occupied else "" gate_material.append(material) c = slot.get("color", [0, 0, 0]) if occupied else [0, 0, 0] - # Happy Hare erwartet gate_color als RRGGBB OHNE '#' (Klipper-Limitation). + # Happy Hare expects gate_color as RRGGBB WITHOUT '#' (Klipper limitation). # Leerer Gate: leerer String + RGB [0,0,0]. gate_color.append("{:02X}{:02X}{:02X}".format(*c[:3]) if occupied else "") gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)] if occupied else [0.0, 0.0, 0.0]) gate_temperature.append(_TEMP.get(material, 210) if occupied else 0) - # gate_filament_name aus User-Override oder Material-Default für den + # gate_filament_name from user override or material default for the # HH-Pfad in OrcaSlicer (fetch_hh_filament_info). Wenn Orca den - # HH-Pfad wählt (MMU-Erkennung), wertet PR #13719 dieses Feld als - # Preset-Namen aus → 'Anycubic PLA' matched das druckerspezifische - # Preset, leerer String führte vorher auf Generic PLA. + # HH path (MMU detection), PR #13719 evaluates this field as a + # preset name -> 'Anycubic PLA' matches the printer-specific + # preset; an empty string previously led to Generic PLA. if occupied: - user_profile = self._filament_profiles.get(_global_index) or {} + # Stale-profile guard (see _effective_slot_profile): only apply the + # override while its material family still matches the loaded filament. + user_profile = self._effective_slot_profile(_global_index, material) fila_name = user_profile.get("name") or self._default_filament_name(material) gate_filament_name.append(fila_name) else: gate_filament_name.append("") + # Spoolman spool ID per gate from the (printer-specific) slot map so + # Happy Hare/OrcaSlicer can show the bound spool (-1 = none). + gate_spool_id.append(self._spoolman_slot_spools.get(_global_index, -1) if occupied else -1) loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(slots)} active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1) @@ -1755,24 +2529,38 @@ class KobraXBridge: "gate_temperature": gate_temperature, "gate_color_rgb": gate_color_rgb, "gate_filament_name": gate_filament_name, - "gate_spool_id": [-1] * num_gates, + "gate_spool_id": gate_spool_id, "ttg_map": list(range(num_gates)), "tool": active_gate, "gate": active_gate, } def _default_filament_name(self, material: str) -> str: - """Default-Name für `gate_filament_name`/`name` in lane_data wenn kein - User-Override gesetzt ist. Bewusste Designentscheidung: **immer - Generic ** als Default — das Library-Profil ist `compatible_printers:[]` - (= mit jedem Drucker kompatibel) und damit garantiert sichtbar. + """Default name for `gate_filament_name`/`name` in lane_data when no + user override is set. Deliberate design decision: **always + Generic ** as the default - the library profile is `compatible_printers:[]` + (= compatible with every printer) and therefore guaranteed to be visible. - OrcaSlicer matcht dann das neutrale Generic-Preset und der User - kann pro Slot eine konkrete Marke setzen wenn er das will.""" + OrcaSlicer then matches the neutral generic preset and the user + can set a concrete brand per slot if they want to.""" if not material: return "" - mat = material.upper().strip() + mat = self._normalize_material(material) profs = self._load_orca_filaments() + # Varianten-Mapping: Drucker meldet z.B. "PLA SILK", OrcaSlicer speichert + # all variants under type=PLA with the variant name in the name field. + _VARIANT_NAME = { + "PLA SILK": "Generic PLA Silk", + "PLA MATTE": "Generic PLA Matte", + "PLA+": "Generic PLA", + "PLA-CF": "Generic PLA-CF", + "PETG-CF": "Generic PETG-CF", + } + if mat in _VARIANT_NAME: + target = _VARIANT_NAME[mat] + for p in profs: + if p.get("vendor") == "Generic" and p.get("name") == target: + return p["name"] def _match_type(p: dict) -> bool: pt = (p.get("type") or "").upper() return pt == mat or pt.startswith(mat + "-") or pt.startswith(mat + " ") @@ -1780,8 +2568,8 @@ class KobraXBridge: for p in profs: if p.get("vendor") == "Generic" and p.get("name", "").startswith("Generic ") and _match_type(p): return p.get("name", "") - # Falls die Library-Generic für diesen exotischen Material-Typ fehlt, - # liefern wir nichts — OrcaSlicer fällt auf filament_id_by_type zurück. + # If the library generic for this exotic material type is missing, + # we return nothing - OrcaSlicer falls back to filament_id_by_type. return "" def _build_printer_objects(self) -> dict: @@ -1817,7 +2605,7 @@ class KobraXBridge: "is_active": s["print_state"] == "printing", "file_path": s["filename"], # file_position approximiert: fraction × est_total_size. - # Genauer Wert kommt aus dem Drucker nicht, Obico nutzt es nur als Anzeige. + # The printer does not provide an exact value; Obico only uses it for display. "file_position": int(s["progress"] * 1_000_000) if s["progress"] else 0, }, "toolhead": { @@ -1827,7 +2615,7 @@ class KobraXBridge: "estimated_print_time": s["print_duration"], }, "mmu": self._build_mmu_object(), - # ── Moonraker-Kompatibilität für moonraker-obico ── + # -- Moonraker compatibility for moonraker-obico -- "heaters": { "available_heaters": ["extruder", "heater_bed"], "available_sensors": [], @@ -1838,9 +2626,9 @@ class KobraXBridge: "state_message": "Printer is ready", }, # speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5) - # Aktuelle Z-Höhe für Obico aus curr_layer + Layer-Heights schätzen - # (Drucker liefert keine echte Z-Position per MQTT). gcode_position[2] - # ist der Wert den moonraker-obico in printer.py als currentZ liest. + # Estimate the current Z height for Obico from curr_layer + layer heights + # (the printer provides no real Z position via MQTT). gcode_position[2] + # is the value moonraker-obico reads as currentZ in printer.py. "gcode_move": { "speed_factor": {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0), "extrude_factor": 1.0, @@ -1851,12 +2639,23 @@ class KobraXBridge: "homing_origin": [0, 0, 0, 0], "position": [0, 0, self._estimate_current_z(), 0], }, + # motion_report: Mobileraker reads the live velocity here + # (live_velocity). The Kobra X MQTT provides NO real mm/s, only + # a print_speed_mode (1-4). live_velocity therefore stays 0 - but the + # object must exist, otherwise Mobileraker displays nothing + # (motion_report used to be null). live_position mirrors the + # estimated Z height (like gcode_move). + "motion_report": { + "live_position": [0, 0, self._estimate_current_z(), 0], + "live_velocity": 0.0, + "live_extruder_velocity": 0.0, + }, "fan": { "speed": (int(s.get("fan_speed") or 0)) / 100.0, "rpm": None, }, - # history (object): Obico abonniert es als Objekt; das eigentliche - # /server/history/list-Endpoint liefert die echte Liste separat. + # history (object): Obico subscribes to it as an object; the actual + # /server/history/list endpoint delivers the real list separately. "history": { "job_totals": { "total_jobs": 0, @@ -1868,14 +2667,14 @@ class KobraXBridge: }, "current_job": None, }, - # Pseudo-Klipper-Macros für moonraker-obico: - # - _OBICO_LAYER_CHANGE meldet die aktuelle Layer-Nr. Obico nutzt das - # für "first layer scan"-Trigger und layer-aligned Time-Lapse-Frames. - # Wir bedienen das aus dem MQTT-Stream (s["curr_layer"]). - # - TIMELAPSE_TAKE_FRAME signalisiert, dass die aktuelle Pause vom - # Time-Lapse stammt (sonst würde Obico die Pause als User-Pause - # interpretieren). Wir setzen is_paused=False, weil unsere Pausen - # nie Time-Lapse-Pausen sind. + # Pseudo Klipper macros for moonraker-obico: + # - _OBICO_LAYER_CHANGE reports the current layer number. Obico uses this + # for "first layer scan" triggers and layer-aligned time-lapse frames. + # We feed this from the MQTT stream (s["curr_layer"]). + # - TIMELAPSE_TAKE_FRAME signals that the current pause comes from the + # time-lapse (otherwise Obico would interpret the pause as a user + # pause). We set is_paused=False because our pauses are + # never time-lapse pauses. "gcode_macro _OBICO_LAYER_CHANGE": { "current_layer": int(s.get("curr_layer") or 0), "first_layer_scanning": False, @@ -1884,6 +2683,81 @@ class KobraXBridge: "gcode_macro TIMELAPSE_TAKE_FRAME": { "is_paused": False, }, + # configfile stub - Mobileraker and other clients crash without + # this object (Missing field: configFile). Values from the + # decrypted avata_main.conf (ACCFG1.0 - Kobra X firmware). + # Mobileraker (Issue #48) parses BOTH branches config + settings via + # denselben ConfigFile.parse → ConfigExtruder.fromJson; ein leeres + # config:{} crashed the non-nullable Dart parser. Therefore + # config identisch zu settings gespiegelt. + "configfile": self._klipper_configfile_stub(), + } + + def _klipper_configfile_stub(self) -> dict: + """Minimal Klipper configfile stub for Mobileraker/OctoApp (Issue #48). + + Mobileraker parses BOTH branches `config` and `settings` through the same + ConfigFile.parse → ConfigExtruder.fromJson. Ein leeres `config: {}` + crashed the non-nullable Dart parser, therefore `config` is + mirrored identically to `settings`. Values from the decrypted + avata_main.conf (ACCFG1.0 — Kobra X Firmware). + """ + settings = { + "printer": { + "kinematics": "cartesian", + "max_velocity": 450, + "max_accel": 10000, + "max_z_velocity": 12, + "max_z_accel": 100, + "square_corner_velocity": 20.0, + }, + "extruder": { + "nozzle_diameter": 0.4, + "filament_diameter": 1.75, + "sensor_type": "ATC Semitec 104GT-2", + "min_temp": 0, + "max_temp": 320, + "min_extrude_temp": 10, + # Mobileraker ConfigExtruder erwartet diese Felder non-nullable + # (max_extrude_only_distance, max_power) or present as a key + # (max_extrude_only_velocity/accel may be null). Missing = + # Crash in ConfigExtruder.fromJson (Issue #48). + "max_extrude_only_distance": 100.0, + "max_power": 1.0, + "max_extrude_only_velocity": None, + "max_extrude_only_accel": None, + }, + "heater_bed": { + # Mobileraker ConfigHeaterBed: heater_pin, sensor_type, control + # are non-nullable. Values are placeholders (the bridge does not know + # the real pins - Anycubic firmware, no Klipper printer.cfg). + "heater_pin": "PA0", + "sensor_type": "ATC Semitec 104GT-2", + "control": "pid", + "min_temp": 0, + "max_temp": 120, + }, + # Fill stepper_* with non-nullable required fields (step_pin, dir_pin, + # rotation_distance), otherwise ConfigStepper.fromJson crashes. + "stepper_x": {"step_pin": "PA1", "dir_pin": "PA2", "rotation_distance": 40, + "position_min": -18.5, "position_max": 280}, + "stepper_y": {"step_pin": "PA3", "dir_pin": "PA4", "rotation_distance": 40, + "position_min": -6.5, "position_max": 272.5}, + "stepper_z": {"step_pin": "PA5", "dir_pin": "PA6", "rotation_distance": 8, + "position_min": -4, "position_max": 262}, + "virtual_sdcard": {"path": "/data/gcodes"}, + "pause_resume": {}, + "display_status": {}, + } + # config + settings must contain the same fields - Mobileraker + # parses both. deepcopy so no client is affected by a shared reference + # versehentlich beide Zweige mutiert. + return { + "config": copy.deepcopy(settings), + "settings": settings, + "warnings": [], + "save_config_pending": False, + "save_config_pending_items": {}, } # ------------------------------------------------------------------------- @@ -1904,8 +2778,8 @@ class KobraXBridge: async def handle_kx_files(self, request): files = self._store.list_files() - # Legacy-Einträge ohne gespeicherte Filament-Metadaten nachziehen, - # damit Dialog links die GCode-Farben statt AMS-Slots zeigt. + # Backfill legacy entries without stored filament metadata + # so the dialog's left side shows GCode colors instead of AMS slots. for f in files: needs_refresh = not f.get("gcode_filaments") if not needs_refresh: @@ -1926,9 +2800,9 @@ class KobraXBridge: if parsed_filaments: f["gcode_filaments"] = json.dumps(parsed_filaments) self._store.update_file_filaments(f["id"], parsed_filaments) - except Exception: - pass - # Letzten Job-Status + Dauer pro Datei ergänzen + except Exception as e: + log.debug(f"Filament metadata backfill failed for {f.get('filename')}: {e}") + # Add last job status + duration per file jobs = self._store.list_jobs(limit=500) last_job: dict = {} for j in reversed(jobs): @@ -1947,6 +2821,93 @@ class KobraXBridge: return self._json_cors({"result": "ok"}) return self._json_cors({"error": "not found"}, status=404) + async def handle_kx_printer_files(self, request): + """GET /kx/printer-files - lists files on the printer's OWN internal + storage (file/listLocal MQTT action), as opposed to /kx/files which + lists what the bridge itself has stored. Needed because prints + started directly from Anycubic Slicer Next (bypassing the bridge) + leave files on the printer that were previously only visible/ + deletable from the printer's own display (Issue #102 context).""" + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "listLocal", + lambda: self.client.publish( + "file", "listLocal", + {"page_num": 1, "page_size": 200, "path": "/"}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + records = (result.get("data") or {}).get("records") or [] + files = [r for r in records if not r.get("is_dir")] + return self._json_cors({"result": files}) + + async def handle_kx_printer_file_delete(self, request): + """POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}. + Single endpoint for both single and multi-select delete - the + printer's file/deleteBatch MQTT action natively accepts a list.""" + try: + body = await request.json() + except Exception: + body = {} + filenames = body.get("filenames") or [] + if not filenames: + return self._json_cors({"error": "no filenames given"}, status=400) + files = [{"path": "/", "filename": fn} for fn in filenames if fn] + loop = asyncio.get_event_loop() + def _delete(): + return self._wait_for_file_action( + "deleteBatch", + lambda: self.client.publish( + "file", "deleteBatch", + {"root": "local", "files": files}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _delete) + if not result or result.get("state") != "success": + return self._json_cors({"error": "delete failed", "detail": result}, status=502) + return self._json_cors({"result": "ok"}) + + async def handle_kx_printer_file_thumbnail(self, request): + """GET /kx/printer-files/{filename}/thumbnail - fetches the embedded + GCode thumbnail for a file on the printer's own storage, via + file/fileDetails. The printer extracts and base64-encodes the + "; thumbnail begin"-block from the GCode header on demand and + returns it inline in data.file_details.thumbnail - no separate + download/presigned-URL step needed (verified live against a real + Kobra X). Cached in-memory per filename since a file's thumbnail + never changes while it exists on the printer, and re-querying on + every render/scroll would mean one MQTT roundtrip per visible card.""" + filename = request.match_info.get("filename", "") + if not filename: + return self._json_cors({"error": "no filename given"}, status=400) + cached = self._printer_thumbnail_cache.get(filename) + if cached is not None: + return self._json_cors({"result": {"thumbnail": cached}}) + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "fileDetails", + lambda: self.client.publish( + "file", "fileDetails", + {"root": "local", "filename": filename}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + thumb = ((result.get("data") or {}).get("file_details") or {}).get("thumbnail") or "" + self._printer_thumbnail_cache[filename] = thumb + return self._json_cors({"result": {"thumbnail": thumb}}) + async def handle_kx_file_download(self, request): file_id = request.match_info["file_id"] f = self._store.get_file(file_id) @@ -1956,8 +2917,8 @@ class KobraXBridge: if not path or not os.path.isfile(path): return self._json_cors({"error": "not found"}, status=404) filename = os.path.basename(f.get("filename") or path) - # RFC 5987: filename* mit URL-encoding für Sonderzeichen/UTF-8, - # plus ASCII-fallback (alle " und \ aus filename strippen für den + # RFC 5987: filename* with URL encoding for special chars/UTF-8, + # plus ASCII fallback (strip all " and \ from filename for the # quoted-string-Part). ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace('"', "").replace("\\", "") encoded = quote(filename, safe="") @@ -1974,15 +2935,17 @@ class KobraXBridge: slots = [] for i, s in enumerate(self._ams_slots): gidx = int(s.get("global_index", i)) - profile = self._filament_profiles.get(gidx) or {} + # Stale-profile guard: only show the override while its material + # family matches the loaded AMS material (else slot has no brand). + profile = self._effective_slot_profile(gidx, s.get("type", "")) slots.append({ "slot_index": gidx, "material": s.get("type", ""), "color_hex": "#{:02X}{:02X}{:02X}".format(*s.get("color", [0,0,0])[:3]), "status": "loaded" if s.get("status") == 5 else "empty", "nozzle_temp": 0, - # Aktueller User-Override aus config.ini [filament_profiles] - # — (vendor,name) ist eindeutig, id ist nur Hint. + # Current user override from config.ini [filament_profiles] + # - (vendor,name) is unique, id is only a hint. "filament_id": profile.get("id", ""), "filament_vendor": profile.get("vendor", ""), "filament_name": profile.get("name", ""), @@ -1990,12 +2953,12 @@ class KobraXBridge: return self._json_cors({"result": slots}) async def handle_kx_filament_profiles(self, request): - """Liefert die statische Liste der OrcaSlicer-Filament-Profile - (aus bridge/data/orca_filaments.json — vom Generator-Script + """Returns the static list of OrcaSlicer filament profiles + (from bridge/data/orca_filaments.json - produced by the generator script tools/gen_orca_filament_list.py erzeugt). Optional Filter via ?type=PLA / ?vendor=Polymaker. - Frontend nutzt das für die Slot-Profile-Dropdown. + The frontend uses this for the slot profile dropdown. """ type_filter = request.rel_url.query.get("type", "").upper().strip() vendor_filter = request.rel_url.query.get("vendor", "").strip() @@ -2007,8 +2970,8 @@ class KobraXBridge: return self._json_cors({"result": profiles}) async def handle_kx_filament_profiles_user_list(self, request): - """GET /kx/filament/profiles/user — nur die User-importierten Profile, - für den Settings-Tab (Verwaltung mit Lösch-Buttons).""" + """GET /kx/filament/profiles/user - only the user-imported profiles, + for the settings tab (management with delete buttons).""" path = self._orca_filaments_user_path() if not os.path.isfile(path): return self._json_cors({"result": []}) @@ -2020,21 +2983,21 @@ class KobraXBridge: return self._json_cors({"result": user_profiles}) async def handle_kx_filament_profiles_import(self, request): - """POST /kx/filament/profiles/user — multipart-Upload mit einer - ZIP-Datei oder mehreren `.json`-Files aus + """POST /kx/filament/profiles/user - multipart upload with one + ZIP file or multiple `.json` files from ~/.config/OrcaSlicer/user//filament/. - Bestehende User-Profile mit gleichem (vendor, name)-Key werden - überschrieben. Geparste Profile haben dasselbe Schema wie + Existing user profiles with the same (vendor, name) key are + overwritten. Parsed profiles use the same schema as orca_filaments.json (id, name, vendor, type, color).""" import io, zipfile from orca_filaments import parse_profile_bytes added: list[dict] = [] skipped: int = 0 - # System-Index für Inherits-Resolve: User-Profile referenzieren + # System index for inherits resolution: user profiles reference # System-Parents via "inherits" (z.B. "Generic PLA @System"). Damit - # können wir filament_id/vendor/type/color aus dem System-Parent - # ziehen wenn das User-Profil sie selbst nicht setzt. + # we can pull filament_id/vendor/type/color from the system parent + # when the user profile does not set them itself. sys_idx = [p for p in self._load_orca_filaments() if not p.get("is_user")] try: reader = await request.multipart() @@ -2073,7 +3036,7 @@ class KobraXBridge: if not added: return self._json_cors({"result": "ok", "added": 0, "skipped": skipped}) - # Merge mit existierender User-JSON (gleicher (vendor,name) → ersetzen) + # Merge with existing user JSON (same (vendor,name) -> replace) path = self._orca_filaments_user_path() existing: list[dict] = [] if os.path.isfile(path): @@ -2099,8 +3062,8 @@ class KobraXBridge: "total_user": len(merged)}) async def handle_kx_filament_profiles_user_delete(self, request): - """DELETE /kx/filament/profiles/user — löscht entweder einen einzelnen - Eintrag (?vendor=…&name=…) oder alle wenn keine Query angegeben.""" + """DELETE /kx/filament/profiles/user - deletes either a single + entry (?vendor=...&name=...) or all when no query is given.""" vendor = request.rel_url.query.get("vendor", "").strip() name = request.rel_url.query.get("name", "").strip() path = self._orca_filaments_user_path() @@ -2129,17 +3092,17 @@ class KobraXBridge: "total_user": len(existing)}) def _find_orca_filaments_json(self) -> str | None: - """Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/ + """Finds the static JSON file. Sits next to web/ under _WEB_BASE/data/ — in allen 3 Deployment-Modi: • Dev: bridge/data/orca_filaments.json - • Docker: /app/data/orca_filaments.json (statisch im Image, NICHT das - Volume-data/ das Runtime-State enthält — siehe Dockerfile) + * Docker: /app/data/orca_filaments.json (static in the image, NOT the + volume data/ holding runtime state - see Dockerfile) • Onefile: sys._MEIPASS/data/orca_filaments.json - Wenn das Volume-mounted /app/data/ den static-data überdeckt, liegt eine - Kopie auch unter _WEB_BASE/data/ (= /app/ im Docker = derselbe Pfad). - Bei Konflikt: zweite Suche unter ../bridge/data/ als Fallback für Dev-Setups.""" + When the volume-mounted /app/data/ shadows the static data, a copy + also sits under _WEB_BASE/data/ (= /app/ in Docker = the same path). + On conflict: second lookup under ../bridge/data/ as a fallback for dev setups.""" candidates = [ - # Docker: COPY bridge/data → /app/static/ (data/ ist Volume → überdeckt) + # Docker: COPY bridge/data -> /app/static/ (data/ is a volume -> shadowed) os.path.join(_WEB_BASE, "static", "orca_filaments.json"), os.path.join(_WEB_BASE, "data", "orca_filaments.json"), ] @@ -2152,18 +3115,18 @@ class KobraXBridge: return None async def handle_kx_filament_slot_profile(self, request): - """POST /kx/filament/slots//profile — speichert oder löscht - ein User-Override-Mapping für einen einzelnen AMS-Slot. + """POST /kx/filament/slots//profile - saves or deletes + a user override mapping for a single AMS slot. - Primärer Selector ist (vendor, name) — die ID ist im Orca-Datenmodell - nicht eindeutig (136 Profile teilen sich z.B. 'OGFL99'). Die ID wird - aus orca_filaments.json beim Speichern nachgeschlagen und als Hint - mitgeführt für OrcaSlicer's `tray_info_idx`. + The primary selector is (vendor, name) - the ID is not unique in the Orca + data model (136 profiles share e.g. 'OGFL99'). The ID is looked up + from orca_filaments.json on save and carried along as a hint + for OrcaSlicer's `tray_info_idx`. Body: {"vendor": "Polymaker", "name": "PolyTerra PLA"} {"vendor": "", "name": ""} → Mapping entfernen - (Backwards-Kompat: {"id":..., "vendor":...} wird akzeptiert, - aber `name` ist seit v0.9.18 der primäre Selector.) + (Backwards compat: {"id":..., "vendor":...} is accepted, + but `name` has been the primary selector since v0.9.18.) """ try: slot_idx = int(request.match_info.get("idx", "-1")) @@ -2179,8 +3142,8 @@ class KobraXBridge: new_name = (data.get("name") or "").strip() new_id = (data.get("id") or "").strip() # Backwards-Kompat-Hint if new_vendor and new_name: - # ID aus JSON lookup'en (nicht aus dem Request-Body, der könnte - # veraltet sein oder ein Generic-Fallback). + # Look up the ID from JSON (not from the request body, which could + # be stale or a generic fallback). looked_up_id = self._lookup_filament_id(new_vendor, new_name) self._filament_profiles[slot_idx] = { "vendor": new_vendor, @@ -2192,7 +3155,7 @@ class KobraXBridge: # Persistieren in config.ini try: import config_loader as _cl - _cl.save_filament_profiles(self._filament_profiles) + _cl.save_filament_profiles(self._filament_profiles, self._printer_id) except Exception as e: log.warning(f"save_filament_profiles failed: {e}") return self._json_cors({"error": str(e)}, status=500) @@ -2203,12 +3166,37 @@ class KobraXBridge: "name": entry.get("name", ""), "id": entry.get("id", "")}) + async def handle_kx_visible_vendors(self, request): + """GET/POST /kx/filament/visible_vendors — Vendor-Sichtbarkeitsfilter + for the slot profile dropdown (Issue #41 option A). + + GET → {"result": ["Polymaker", "eSUN", ...]} + POST {"vendors": [...]} → speichert in config.ini [filament_profiles] + visible_vendors. Empty list = all visible. NO bridge restart + needed (display filter only).""" + if request.method == "POST": + try: + data = await request.json() + except Exception: + data = {} + vendors = data.get("vendors") or [] + if not isinstance(vendors, list): + return self._json_cors({"error": "vendors must be a list"}, status=400) + self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()] + try: + import config_loader as _cl + _cl.save_visible_vendors(self._visible_vendors, self._printer_id) + except Exception as e: + log.warning(f"save_visible_vendors failed: {e}") + return self._json_cors({"error": str(e)}, status=500) + return self._json_cors({"result": self._visible_vendors}) + def _load_orca_filaments(self) -> list[dict]: - """Lädt System- + User-Profile aus dem Cache. System-Profile kommen - aus bridge/data/orca_filaments.json (Image-embedded), User-Profile - aus /orca_filaments.user.json (Volume-persistent — - überlebt Image-Updates). User-Profile bekommen ein `is_user: True`- - Flag damit das Frontend sie markieren kann.""" + """Loads system + user profiles from the cache. System profiles come + from bridge/data/orca_filaments.json (image-embedded), user profiles + from /orca_filaments.user.json (volume-persistent - + survives image updates). User profiles get an `is_user: True` + flag so the frontend can mark them.""" if getattr(self, "_orca_filaments_cache", None) is not None: return self._orca_filaments_cache merged: list[dict] = [] @@ -2234,8 +3222,8 @@ class KobraXBridge: return self._orca_filaments_cache def _orca_filaments_user_path(self) -> str: - """Pfad zur User-Profile-JSON. Liegt im Volume-Mount (KX_DATA_DIR), - damit Image-Updates die Daten nicht zerstören.""" + """Path to the user profiles JSON. Lives in the volume mount (KX_DATA_DIR) + so image updates do not destroy the data.""" data_dir = os.environ.get("KX_DATA_DIR") or os.path.join(_WEB_BASE, "data") os.makedirs(data_dir, exist_ok=True) return os.path.join(data_dir, "orca_filaments.user.json") @@ -2244,8 +3232,8 @@ class KobraXBridge: self._orca_filaments_cache = None def _lookup_filament_id(self, vendor: str, name: str) -> str: - """Sucht in orca_filaments.json die filament_id zu einem (vendor,name)- - Tupel. Liefert '' wenn nicht gefunden.""" + """Looks up the filament_id for a (vendor,name) tuple in + orca_filaments.json. Returns '' when not found.""" for p in self._load_orca_filaments(): if p.get("vendor") == vendor and p.get("name") == name: return p.get("id", "") @@ -2258,12 +3246,12 @@ class KobraXBridge: return self._json_cors({"result": jobs}) async def handle_kx_file_objects(self, request): - """Liefert die Objekt-Liste + optionales SVG für eine Datei. + """Returns the object list + optional SVG for a file. GET /kx/files/{id}/objects → {"names": [...], "svg_b64": "..."} - Wenn Datei noch keine Objekte hat (alter Eintrag): file/fileDetails - beim Drucker abfragen und Antwort abwarten ist Aufgabe des Frontends - (Reload nach Upload). Hier nur Datenbankstand zurückgeben. + If the file has no objects yet (old entry): querying file/fileDetails + from the printer and awaiting the response is the frontend's job + (reload after upload). Only return the database state here. """ fid = request.match_info.get("id", "") f = self._store.get_file(fid) @@ -2273,6 +3261,18 @@ class KobraXBridge: names = json.loads(f.get("objects_skip_parts") or "[]") except Exception: names = [] + # No objects in the store yet (fresh Orca/web upload): actively request + # file/fileDetails from the printer once. _on_file() backfills the store, + # the frontend polls this endpoint and receives the list on the next + # attempt (Issue #57 - skip parity outside the file browser too). + if not names: + fn = f.get("filename") or "" + if fn: + try: + self.client.publish("file", "fileDetails", + {"root": "local", "filename": fn}, timeout=0) + except Exception as e: + log.debug(f"fileDetails request failed: {e}") return self._json_cors({ "result": { "names": names, @@ -2281,7 +3281,7 @@ class KobraXBridge: }) async def handle_kx_skip(self, request): - """Mid-Print Skip auslösen. + """Trigger a mid-print skip. POST /kx/skip body={"names": ["..", ".."]} """ @@ -2299,29 +3299,8 @@ class KobraXBridge: return self._json_cors({"error": str(e)}, status=502) return self._json_cors({"result": "ok", "names": names}) - async def handle_kx_skip_query(self, request): - """Druck-Objektliste vom Drucker neu abfragen. - - POST /kx/skip/query → triggert skip/query_obj, gibt zuletzt bekannten - Stand zurück (skip/report kommt async, Frontend pollt /kx/skip/state). - """ - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, lambda: self.client.query_skip_objects()) - except Exception as e: - return self._json_cors({"error": str(e)}, status=502) - return self._json_cors({"result": self._skip_state}) - - async def handle_kx_skip_state(self, request): - """Aktueller Skip-State. - - Kombiniert: - - Gesamt-Objektliste: aus dem GCode-Store, gematcht über den aktuell - laufenden filename (file/report beim Druckstart hat die Liste gefüllt). - skip/query_obj liefert nämlich NUR die bereits geskippten zurück, - nicht die Gesamtliste. - - Geskippt: aus self._skip_state (von skip/report aktualisiert). - """ + def _build_skip_state_result(self) -> dict: + """Builds the combined skip state for UI endpoints.""" filename = self._state.get("filename", "") all_objects: list[str] = [] svg = "" @@ -2333,29 +3312,61 @@ class KobraXBridge: svg = f.get("svg_image") or "" except Exception as e: log.warning(f"skip_state lookup failed: {e}") - result = { + return { "objects": all_objects, "skipped": list(self._skip_state.get("skipped", [])), "svg_b64": svg, "ts": self._skip_state.get("ts", 0), "filename": filename, } - return self._json_cors({"result": result}) + + async def handle_kx_skip_query(self, request): + """Re-request the print object list from the printer. + + POST /kx/skip/query → triggert skip/query_obj, wartet kurz auf den + async skip/report and returns the merged skip state. + """ + prev_ts = int(self._skip_state.get("ts", 0) or 0) + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: self.client.query_skip_objects()) + except Exception as e: + return self._json_cors({"error": str(e)}, status=502) + + deadline = time.time() + 1.5 + while time.time() < deadline: + if int(self._skip_state.get("ts", 0) or 0) > prev_ts: + break + await asyncio.sleep(0.1) + + return self._json_cors({"result": self._build_skip_state_result()}) + + async def handle_kx_skip_state(self, request): + """Aktueller Skip-State. + + Kombiniert: + - Full object list: from the GCode store, matched via the currently + running filename (file/report at print start populated the list). + skip/query_obj only returns the already-skipped ones, + not the full list. + - Skipped: from self._skip_state (updated by skip/report). + """ + return self._json_cors({"result": self._build_skip_state_result()}) async def handle_kx_printers(self, request): - # Aktive Drucker (mit IP) sammeln + # Collect active printers (with IP) active = [(pid, br) for pid, br in self._all_bridges.items() if (br._args.printer_ip or "").strip()] - # Host für bridge_url: Browser-Sicht beibehalten, aber niemals "localhost" exportieren – - # sonst scheitern Fetches aus dem Browser, wenn die UI über die LAN-IP geöffnet ist. + # Host for bridge_url: keep the browser view, but never export "localhost" - + # otherwise browser fetches fail when the UI is opened via the LAN IP. host = request.host.split(":")[0] if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): host = "" out = [] for pid, br in active: port = getattr(br._args, "port", 7125) - # Nur bei Multi-Printer eine konkrete bridge_url setzen (Cross-Instance-Fetch). - # Single-Printer: leere bridge_url → JS nutzt relative Pfade (gleiche Origin wie UI). + # Only set a concrete bridge_url for multi-printer setups (cross-instance fetch). + # Single printer: empty bridge_url -> JS uses relative paths (same origin as the UI). bridge_url = "" if len(active) > 1 and host: bridge_url = f"http://{host}:{port}" @@ -2369,7 +3380,7 @@ class KobraXBridge: return self._json_cors({"result": out}) async def handle_kx_print(self, request): - """Druckstart aus dem GCode-Store mit optionalen Filament-Assignments.""" + """Print start from the GCode store with optional filament assignments.""" try: body = await request.json() except Exception: @@ -2399,51 +3410,36 @@ class KobraXBridge: if not ams_box_mapping: return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) else: - # Kein Dialog → alle belegten Slots wie bei normalem Upload-Druck + # No dialog -> all occupied slots as with a normal upload print ams_box_mapping = self._build_auto_ams_box_mapping() - use_ams = len(ams_box_mapping) > 0 - auto_leveling = getattr(self._args, "auto_leveling", 1) + auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) filename = gcode_file["filename"] file_path = gcode_file["path"] - # Datei über internes Serve-Endpoint bereitstellen + # Serve the file via the internal serve endpoint url = f"http://localhost:{self._args.port}/serve/{os.path.basename(file_path)}" - payload = { - "taskid": "-1", - "url": url, - "filename": filename, - "md5": "", - "filepath": None, - "filetype": 1, - "project_type": 1, - "filesize": gcode_file.get("size_bytes", 0), - "ams_settings": { - "use_ams": use_ams, - "ams_box_mapping": ams_box_mapping, - }, - "task_settings": { - "auto_leveling": auto_leveling, - "vibration_compensation": 0, - "flow_calibration": 0, - "dry_mode": 0, - "ai_settings": {"status": 0, "count": 0, "type": 1}, - "timelapse": {"status": 0, "count": 0, "type": 64}, - "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, - "model_objects_skip_parts": excluded_objects, - }, - } + payload = self._build_print_payload( + filename, url, "", gcode_file.get("size_bytes", 0), + ams_box_mapping=ams_box_mapping, + auto_leveling=auto_leveling, + excluded_objects=excluded_objects, + ) + self._reset_skip_state(excluded_objects) - log.info(f"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}") + log.info(f"KX store print start: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}") loop = asyncio.get_event_loop() result = await loop.run_in_executor( None, lambda: self.client.publish("print", "start", payload, timeout=15.0) ) if result is None: - return self._json_cors({"error": "Keine Antwort vom Drucker"}, status=504) + return self._json_cors({"error": "no response from printer"}, status=504) - # Job in History starten + if excluded_objects: + loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) + + # Start the job in the history self._current_job_id = self._store.start_job( gcode_file_id=gcode_file["id"], printer_id=getattr(self._args, "device_id", "unknown"), @@ -2547,23 +3543,27 @@ class KobraXBridge: }) return web.json_response({"result": files}) - async def handle_files_metadata(self, request): - """Moonraker /server/files/metadata — moonraker-obico-Plugin holt das - einmal pro Druck und liest daraus `object_height` (für `currentZ`- - Anzeige im Obico-UI: `mmProgress` braucht maxZ), `layer_count`, - `layer_height` und `first_layer_height` (für die Layer-Berechnung). + def _build_file_metadata(self, filename: str) -> dict: + """Builds the Moonraker file metadata for a file. Shared source + for HTTP /server/files/metadata AND the WS RPC server.files.metadata + (previously the WS path had its own broken logic with a non-existent + existierenden Store-Methode → leere Antwort → Mobileraker fragte in + endless loop, app hung on refresh, Issue #48). - Quelle: aktueller `_state` + GCode-Store-Eintrag wenn vorhanden. - Wenn Layer-Heights weder im State noch im Store sind, Fallback auf die - OrcaSlicer-Default-Filename-Heuristik (`_layer_height_from_filename`).""" - filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "") - if not filename: - return web.json_response({"result": {}}) + Liefert Mobileraker-kompatible Pflichtfelder: `filename`, `size`, + `modified` are non-nullable in GCodeFile; `print_start_time` and the + Slicer-Felder optional.""" s = self._state - layer_h = float(s.get("layer_height") or 0.0) - first_h = float(s.get("first_layer_height") or 0.0) - total_layers = int(s.get("total_layers") or 0) - est_time = int(s.get("slicer_time") or 0) + # Live _state values are only relevant for the currently/last tracked + # job's own file - using them as a starting point for a DIFFERENT + # filename leaked the tracked job's layer count/time into unrelated + # metadata queries (Issue #102). For any other filename, rely solely + # on that file's own GCodeStore row. + is_tracked_file = bool(filename) and filename == s.get("filename") + layer_h = float(s.get("layer_height") or 0.0) if is_tracked_file else 0.0 + first_h = float(s.get("first_layer_height") or 0.0) if is_tracked_file else 0.0 + total_layers = int(s.get("total_layers") or 0) if is_tracked_file else 0 + est_time = int(s.get("slicer_time") or 0) if is_tracked_file else 0 size_bytes = 0 try: gf = self._store.get_file_by_name(filename) or {} @@ -2582,25 +3582,36 @@ class KobraXBridge: if layer_h and not first_h: first_h = layer_h object_height = round(first_h + max(0, total_layers - 1) * layer_h, 3) if (layer_h and total_layers) else 0.0 - return web.json_response({"result": { + return { "filename": filename, - "size": size_bytes, + # GCodeFile (Mobileraker) requires size as a non-nullable int. + "size": size_bytes or 1, "modified": time.time(), "estimated_time": est_time or None, "layer_height": layer_h or None, "first_layer_height": first_h or None, "layer_count": total_layers or None, "object_height": object_height or None, - }}) + "thumbnails": [], + } - # ── Moonraker-Stubs für moonraker-obico ────────────────────────────────── + async def handle_files_metadata(self, request): + """Moonraker /server/files/metadata — moonraker-obico + Mobileraker + holen Datei-Metadaten (Slicer-Zeit, Layer, object_height). + Logic in _build_file_metadata (shared with WS RPC).""" + filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "") + if not filename: + return web.json_response({"result": {}}) + return web.json_response({"result": self._build_file_metadata(filename)}) + + # -- Moonraker stubs for moonraker-obico ---------------------------------- async def handle_access_api_key(self, request): - """Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück. - moonraker-obico stellt sonst eine WARNING ins Log.""" + """Moonraker /access/api_key - we have no auth, return a dummy. + moonraker-obico logs a WARNING otherwise.""" return web.json_response({"result": "kx-bridge-no-auth-required"}) async def handle_machine_update_status(self, request): - """Moonraker /machine/update/status — Obico zeigt installierte Plugins damit.""" + """Moonraker /machine/update/status - Obico uses this to show installed plugins.""" return web.json_response({ "result": { "busy": False, @@ -2612,9 +3623,9 @@ class KobraXBridge: }) async def handle_history_list(self, request): - """Moonraker /server/history/list — Job-Historie aus dem GCodeStore. + """Moonraker /server/history/list - job history from the GCodeStore. - moonraker-obico nutzt nur das letzte Element (limit=1, order=desc).""" + moonraker-obico only uses the last element (limit=1, order=desc).""" try: limit = int(request.rel_url.query.get("limit", "50")) except ValueError: @@ -2623,15 +3634,15 @@ class KobraXBridge: jobs = self._store.list_jobs(limit=limit) or [] except Exception: jobs = [] - # Mapping auf Moonraker-Schema. Moonraker liefert start_time als Unix- - # Timestamp (float), nicht ISO-String — moonraker-obico parsed das mit - # int(start_time) und crasht sonst. + # Mapping to the Moonraker schema. Moonraker returns start_time as a Unix + # timestamp (float), not an ISO string - moonraker-obico parses it with + # int(start_time) and crashes otherwise. def _to_unix_ts(iso: str | None) -> float: if not iso: return 0.0 try: from datetime import datetime - # Format aus GCodeStore: "2026-05-27T21:22:25Z" + # Format from GCodeStore: "2026-05-27T21:22:25Z" dt = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ") return dt.replace(tzinfo=__import__("datetime").timezone.utc).timestamp() except Exception: @@ -2655,7 +3666,20 @@ class KobraXBridge: return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}}) async def handle_webcams_list(self, request): - """Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier.""" + """Moonraker /server/webcams/list - Obico fetches the webcam URLs here. + + When the client comes from another host (e.g. moonraker-obico on a + separate server), it needs absolute URLs to reach the stream. + A Host header with localhost/127.0.0.1 is replaced by the real LAN IP.""" + host_hdr = request.headers.get("Host", "") if request else "" + host_name = (host_hdr or "").split(":")[0] + port_part = f":{host_hdr.split(':')[1]}" if ":" in (host_hdr or "") else f":{self._args.port}" + local_ip = getattr(self, "_local_ip", None) or host_name + if host_name in ("localhost", "127.0.0.1", ""): + host_name = local_ip + base = f"http://{host_name}{port_part}" + stream_url = f"{base}/api/camera/stream" + snapshot_url = f"{base}/api/camera/snapshot" return web.json_response({ "result": { "webcams": [ @@ -2667,8 +3691,8 @@ class KobraXBridge: "icon": "mdiWebcam", "target_fps": 5, "target_fps_idle": 2, - "stream_url": "/api/camera/stream", - "snapshot_url": "/api/camera/snapshot", + "stream_url": stream_url, + "snapshot_url": snapshot_url, "flip_horizontal": False, "flip_vertical": False, "rotation": 0, @@ -2711,10 +3735,22 @@ class KobraXBridge: if not file_data: return web.json_response({"error": "no file received"}, status=400) + # Only allow printable files (Issue #59) - the Kobra X accepts + # only .gcode and .bgcode; .3mf uploads are not processed by the + # printer and are therefore rejected (Issue #59, @gangoke). + _allowed_ext = (".gcode", ".bgcode") + _fn_lower = (remote_filename or "").lower() + if not _fn_lower.endswith(_allowed_ext): + log.warning(f"Upload rejected (not GCode): {remote_filename}") + return web.json_response( + {"error": f"only GCode files allowed ({', '.join(_allowed_ext)})"}, + status=400, + ) + file_md5 = hashlib.md5(file_data).hexdigest() file_size = len(file_data) - # Slicer-Zeitschätzung + Thumbnail aus GCode auslesen + # Read slicer time estimate + thumbnail from GCode est_time = _parse_gcode_estimated_time(file_data) self._state["slicer_time"] = est_time thumbnail_b64 = _extract_thumbnail(file_data) @@ -2723,7 +3759,7 @@ class KobraXBridge: self._state["layer_height"] = layer_h self._state["first_layer_height"] = first_h - # Datei persistent im GCode-Store ablegen + # Persist the file in the GCode store self._store.save_file( file_id=file_md5, filename=remote_filename, @@ -2736,12 +3772,12 @@ class KobraXBridge: first_layer_height=first_h, ) serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename)) - del file_data # RAM freigeben + del file_data # free RAM self._last_uploaded_file = remote_filename - log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} → Store + Drucker") + log.info(f"Upload: {remote_filename} ({file_size} bytes) md5={file_md5} -> store + printer") - # Datei per HTTP auf den Drucker hochladen (serve_path liegt bereits auf Disk) + # Upload the file to the printer via HTTP (serve_path is already on disk) upload_url = self._state.get("upload_url") or None loop = asyncio.get_event_loop() try: @@ -2749,20 +3785,20 @@ class KobraXBridge: None, self.client.upload_gcode, serve_path, remote_filename, upload_url ) except Exception as e: - log.error(f"Upload fehlgeschlagen: {e}") + log.error(f"Upload failed: {e}") return web.json_response({"error": str(e)}, status=500) - log.info(f"Upload erfolgreich: {result}") + log.info(f"Upload successful: {result}") - # Druck starten mit vollständigem Payload (inkl. serve-URL + md5 + size) + # Start the print with the full payload (incl. serve URL + md5 + size) serve_url = f"http://{request.host}/serve/{remote_filename}" - # print=true im Multipart-Formular (Moonraker) oder Query-String → Druck starten - # print=false oder fehlt → nur hochladen + # print=true in the multipart form (Moonraker) or query string -> start print + # print=false or missing -> upload only if not auto_print: auto_print = request.rel_url.query.get("print", "false").lower() == "true" - # Thumbnail immer anfordern (Drucker antwortet async mit file/report) + # Always request the thumbnail (printer responds async with file/report) self._thumbnail_b64 = "" self.client.publish("file", "fileDetails", {"root": "local", "filename": remote_filename}, timeout=0) @@ -2771,16 +3807,29 @@ class KobraXBridge: self._state["last_upload_size"] = file_size if auto_print: + mismatch = self._check_filament_mismatch(gcode_filaments) + if mismatch: + log.info(f"Upload+print blocked - filament mismatch: {mismatch}") + self._state["file_ready"] = remote_filename + self._state["filament_mismatch"] = mismatch + return self._octoprint_upload_response( + request, remote_filename, + extra={"filament_mismatch": True, "mismatch_details": mismatch}, + ) log.info(f"Upload+Print (print=true): {remote_filename}") self._state["file_ready"] = "" loop = asyncio.get_event_loop() loop.run_in_executor(None, lambda: self._start_print(remote_filename, serve_url, file_md5, file_size, gcode_filaments=gcode_filaments)) else: - log.info(f"Nur hochgeladen (print=false): {remote_filename}") + log.info(f"Upload only (print=false): {remote_filename}") self._state["file_ready"] = remote_filename - # OctoPrint-kompatibler Response (OrcaSlicer wertet refs aus) - return web.json_response({ + return self._octoprint_upload_response(request, remote_filename) + + @staticmethod + def _octoprint_upload_response(request, remote_filename: str, extra: dict | None = None): + """OctoPrint-compatible upload response (OrcaSlicer evaluates refs).""" + body = { "done": True, "files": { "local": { @@ -2797,18 +3846,102 @@ class KobraXBridge: "item": {"path": remote_filename, "root": "gcodes"}, "action": "create_file", } - }, status=201) + } + if extra: + body.update(extra) + return web.json_response(body, status=201) + + def _check_filament_mismatch(self, gcode_filaments: list | None) -> list[dict] | None: + """Compares GCode filaments (is_used=True) with currently occupied AMS slots. + + Returns a list of mismatch entries when at least one used + GCode slot has no matching material in the AMS - otherwise None. + Only triggered when AMS data is present (at least 1 occupied slot).""" + if not gcode_filaments: + return None + slots = self._ams_slots or [] + occupied = {s["global_index"]: s for s in slots if s.get("type") and s.get("status") == 5} + if not occupied: + return None + mismatches = [] + for f in gcode_filaments: + if not f.get("is_used"): + continue + idx = int(f.get("slot_index", -1)) + gcode_mat = (f.get("material") or "").upper().strip() + if not gcode_mat: + continue + slot = occupied.get(idx) + if slot is None: + mismatches.append({ + "slot_index": idx, + "gcode_material": gcode_mat, + "ams_material": None, + "reason": "empty", + }) + else: + ams_mat = (slot.get("type") or "").upper().strip() + if ams_mat and ams_mat != gcode_mat: + mismatches.append({ + "slot_index": idx, + "gcode_material": gcode_mat, + "ams_material": ams_mat, + "reason": "mismatch", + }) + return mismatches if mismatches else None + + def _build_print_payload(self, filename: str, url: str, md5: str, filesize: int, + ams_box_mapping: list, auto_leveling: int, + excluded_objects: list | None = None, + ai_type: int = 1, timelapse_type: int = 64) -> dict: + """Builds the complete print/start MQTT payload. Single source for all + three print start paths (upload, KX store, Moonraker API).""" + return { + "taskid": "-1", + "url": url, + "filename": filename, + "md5": md5, + "filepath": None, + "filetype": 1, + "project_type": 1, + "filesize": filesize, + "ams_settings": { + "use_ams": len(ams_box_mapping) > 0, + "ams_box_mapping": ams_box_mapping, + }, + "task_settings": { + "auto_leveling": auto_leveling, + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "flow_calibration": 0, + "dry_mode": 0, + "ai_settings": {"status": 0, "count": 0, "type": ai_type}, + "timelapse": {"status": 0, "count": 0, "type": timelapse_type}, + "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, + "model_objects_skip_parts": excluded_objects or [], + }, + } + + def _reset_skip_state(self, excluded_objects: list | None = None): + """Resets the skip state before a print start. The UI is marked as + "skipped" only after real printer confirmation.""" + self._skip_state = {"skipped": [], "ts": int(time.time())} + if excluded_objects: + self._pending_preprint_skip = [str(n) for n in excluded_objects if isinstance(n, str) and n] + self._pending_preprint_skip_deadline = time.time() + 12.0 + else: + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0, gcode_filaments: list | None = None): self._state["file_ready"] = "" loaded = self._select_loaded_slots_for_print(warn_on_empty_default=True) - # Nur die im GCode TATSÄCHLICH genutzten Paints auf Slots mappen. OrcaSlicer - # schreibt im Header alle konfigurierten Filamente (filament_colour=…;…;…;…), - # nutzt aber oft nur eines (z.B. einfarbig → nur T3). Würden wir alle - # belegten Slots mappen, erwartet der Drucker alle Farben und blockiert, - # wenn ein anderer (ungenutzter) Slot leer ist. Die genutzten Paint-Indizes + # Only map the paints ACTUALLY used in the GCode to slots. OrcaSlicer + # writes all configured filaments into the header (filament_colour=...;...;...), + # but often uses only one (e.g. single color -> only T3). If we mapped all + # occupied slots, the printer would expect all colors and block + # when another (unused) slot is empty. The used paint indices # liefert _extract_filament_info via is_used (echte T-Tool-Changes). used_paint_indices = None if gcode_filaments: @@ -2819,43 +3952,22 @@ class KobraXBridge: if used_paint_indices is not None: # GCode-Paint-Index N entspricht AMS-Slot N (global_index). Nur belegte - # genutzte Slots mappen; nicht-belegte genutzte → später Warnung möglich. + # used slots; used-but-unloaded -> a warning may follow later. loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] - use_ams = len(loaded) > 0 ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) - log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}") - auto_leveling = getattr(self._args, "auto_leveling", 1) - payload = { - "taskid": "-1", - "url": url, - "filename": filename, - "md5": md5, - "filepath": None, - "filetype": 1, - "project_type": 1, - "filesize": filesize, - "ams_settings": { - "use_ams": use_ams, - "ams_box_mapping": ams_box_mapping, - }, - "task_settings": { - "auto_leveling": auto_leveling, - "vibration_compensation": 0, - "flow_calibration": 0, - "dry_mode": 0, - "ai_settings": {"status": 0, "count": 0, "type": 1}, - "timelapse": {"status": 0, "count": 0, "type": 64}, - "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, - "model_objects_skip_parts": [], - }, - } + log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}") + payload = self._build_print_payload( + filename, url, md5, filesize, + ams_box_mapping=ams_box_mapping, + auto_leveling=getattr(self._args, "auto_leveling", 1), + ) log.info(f"print/start → {filename} url={url} ams={len(ams_box_mapping)} slots mode={self._filament_mode}") result = self.client.publish("print", "start", payload, timeout=15.0) if result: - log.info(f"Druckstart bestätigt: state={result.get('state')}") + log.info(f"Print start confirmed: state={result.get('state')}") else: - log.warning("Druckstart: keine Antwort vom Drucker") + log.warning("Print start: no response from printer") def _theme_index_path(self) -> str: return os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, "index.html") @@ -2892,60 +4004,64 @@ class KobraXBridge: if not filename: return web.json_response({"error": "no filename"}, status=400) - log.info(f"Druck starten: {filename}") + log.info(f"Starting print: {filename}") - # Optionale Slot-Auswahl aus dem Filament-Dialog + # Optional slot selection from the filament dialog filament_assignments = body.get("filament_assignments") # Pre-Print Skip (v0.9.10) excluded_objects = body.get("excluded_objects") or [] if not isinstance(excluded_objects, list): excluded_objects = [] - if filament_assignments is not None: - ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments) - if unused_count: - log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") - if invalid_count: - log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") - if not ams_box_mapping: - return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) - else: - # AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen - ams_box_mapping = self._build_auto_ams_box_mapping() - use_ams = len(ams_box_mapping) > 0 - auto_leveling = getattr(self._args, "auto_leveling", 1) + auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) url = self._state.get("last_upload_url", "") filesize = self._state.get("last_upload_size", 0) md5 = self._state.get("last_upload_md5", "") - payload = { - "taskid": "-1", - "url": url, - "filename": filename, - "md5": md5, - "filepath": None, - "filetype": 1, - "project_type": 1, - "filesize": filesize, - "ams_settings": { - "use_ams": use_ams, - "ams_box_mapping": ams_box_mapping, - }, - "task_settings": { - "auto_leveling": auto_leveling, - "vibration_compensation": 0, - "flow_calibration": 0, - "dry_mode": 0, - "ai_settings": {"status": 0, "count": 0, "type": 0}, - "timelapse": {"status": 0, "count": 0, "type": 0}, - "drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, - "model_objects_skip_parts": excluded_objects, - }, - } + if filament_assignments is not None: + # Explicit slot assignment from the filament dialog + ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments) + if unused_count: + log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}") + if invalid_count: + log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}") + if not ams_box_mapping: + return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400) + else: + # Dashboard reprint: load gcode_filaments from DB so the used_paint_indices + # filter applies and empty/shifted slots are not mapped incorrectly. + gcode_filaments = None + try: + db_file = self._store.get_file_by_name(filename) + if db_file and db_file.get("gcode_filaments"): + gcode_filaments = json.loads(db_file["gcode_filaments"]) + except Exception as e: + log.warning(f"Could not load cached gcode_filaments for {filename}: {e} " + "- slot mapping falls back to all occupied slots") + + # Set the pre-print skip before _start_print is called + self._reset_skip_state(excluded_objects) + + log.info(f"print/start api=1 mode={self._filament_mode} assignments=False gcode_filaments={gcode_filaments is not None}") + loop = asyncio.get_event_loop() + loop.run_in_executor(None, lambda: self._start_print( + filename, url, md5, filesize, + gcode_filaments=gcode_filaments, + )) + return web.json_response({"result": "ok"}) + + payload = self._build_print_payload( + filename, url, md5, filesize, + ams_box_mapping=ams_box_mapping, + auto_leveling=auto_leveling, + excluded_objects=excluded_objects, + ai_type=0, timelapse_type=0, + ) + self._reset_skip_state(excluded_objects) log.info( f"print/start api=1 mode={self._filament_mode} " - f"ams={len(ams_box_mapping)} slots assignments={filament_assignments is not None}" + f"ams={len(ams_box_mapping)} slots assignments=True" ) loop = asyncio.get_event_loop() @@ -2953,7 +4069,10 @@ class KobraXBridge: None, lambda: self.client.publish("print", "start", payload, timeout=15.0) ) if result is None: - return web.json_response({"error": "Keine Antwort vom Drucker"}, status=504) + return web.json_response({"error": "no response from printer"}, status=504) + + if excluded_objects: + loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) return web.json_response({"result": "ok"}) @@ -2977,6 +4096,7 @@ class KobraXBridge: async def handle_api_file_ready_clear(self, request): self._state["file_ready"] = "" + self._state["filament_mismatch"] = None self._thumbnail_b64 = "" self._push_status_update() return web.json_response({"result": "ok"}) @@ -2995,6 +4115,12 @@ class KobraXBridge: if ctype is not None: path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) + elif name.startswith("lib/"): + ext = os.path.splitext(name)[1].lower() + ctype = _KX_UI_LIB_TYPES.get(ext) + if not ctype: + raise web.HTTPNotFound() + path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) else: m = _KX_UI_TRANSLATION_RE.match(name) if not m: @@ -3021,9 +4147,9 @@ class KobraXBridge: tpl = self._load_index_template_cached() except OSError: p = self._theme_index_path() - log.error("Web-UI Theme-Datei fehlt oder nicht lesbar: %s (Theme: %s)", p, self._ui_theme) + log.error("Web UI theme file missing or unreadable: %s (theme: %s)", p, self._ui_theme) return web.Response( - text="
KX-Bridge: index.html nicht gefunden.\nErwartet:\n"
+                text="
KX-Bridge: index.html not found.\nExpected:\n"
                 + html.escape(p, quote=True)
                 + "
", status=500, @@ -3031,26 +4157,38 @@ class KobraXBridge: ) page = tpl.replace("__UI_ASSETS_VER__", self._ui_asset_cache_buster()) - # CSS + JS INLINE einbetten statt nur zu verlinken. OrcaSlicers - # eingebetteter Device-Tab-Webview lädt externe /', - "") - except OSError: - pass + + # Inline vendored lib CSS/JS too — the OrcaSlicer webview loads no + # external /") + except OSError: + pass + + _inline_css("lib/gridstack.min.css", '') + _inline_js("lib/gridstack-all.min.js", '') + _inline_css("style.css", '') + _inline_js("app.js", '', version_sub=True) return web.Response(text=page, content_type="text/html", headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) @@ -3091,7 +4229,7 @@ class KobraXBridge: await loop.run_in_executor(None, self.client.connect) self._state["print_state"] = "standby" self._state["kobra_state"] = "free" - log.info("Manuell verbunden") + log.info("Connected manually") return web.json_response({"result": "connected"}) except Exception as e: return web.json_response({"error": str(e)}, status=500) @@ -3108,7 +4246,7 @@ class KobraXBridge: return web.json_response({"result": "disconnected"}) async def handle_api_restart(self, request): - log.info("Neustart über API angefordert") + log.info("Restart requested via API") response = web.json_response({"status": "restarting"}) asyncio.get_event_loop().call_later(0.3, self._restart_bridge) return response @@ -3140,10 +4278,15 @@ class KobraXBridge: return web.json_response({"error": "color must be [r,g,b]"}, status=400) box_id, local_slot = self._global_to_box_slot(index) loop = asyncio.get_event_loop() - # setInfo geht über web/printer-Topic (wie tempature/set). Per + self._state["last_ams_set_error"] = False + # Remembered so a later state="failed" report (which carries no slot + # info of its own, see _on_multicolor_box) can be logged alongside the + # request that triggered it - otherwise the failure is unattributable. + self._last_ams_set_request = {"global": index, "box": box_id, "local_slot": local_slot, "type": mat, "color": color} + # setInfo goes via the web/printer topic (like tempature/set). Verified via # Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden - # die Slot-Änderungen vom Drucker ignoriert und beim nächsten - # multiColorBox/report mit dem alten Material überschrieben. + # slot changes are ignored by the printer and overwritten with the old + # material on the next multiColorBox/report. def _send(): self.client.publish_web( "multiColorBox", "setInfo", @@ -3153,7 +4296,7 @@ class KobraXBridge: await loop.run_in_executor(None, _send) # Optimistisches Update: cached slot sofort anpassen (Drucker echoed # gleich via multiColorBox/report — falls er den Befehl ignoriert, - # überschreibt der Report das wieder). + # the report overwrites it again). for s in self._ams_slots: if s.get("global_index") == index: s["type"] = mat @@ -3170,7 +4313,7 @@ class KobraXBridge: feed_type = int(body.get("type", 1)) if feed_type == 1: self._pending_load_slot = slot_index - # Ausziehen (type=2): wenn kein Slot explizit gewählt, den zuletzt geladenen nehmen + # Feed-out (type=2): if no slot was explicitly chosen, use the last loaded one if feed_type == 2 and self._ams_loaded_slot >= 0: slot_index = self._ams_loaded_slot box_id, local_slot = self._global_to_box_slot(slot_index) @@ -3350,11 +4493,11 @@ class KobraXBridge: {"taskid": taskid, "settings": {"target_hotbed_temp": b}}, )) else: - # Idle: tempature/set über `web/printer`-Topic mit `type`-Feld. - # Live-Sniff 2026-05-29 vom Anycubic Slicer Next bestätigt: + # Idle: tempature/set via the `web/printer` topic with a `type` field. + # Confirmed by live sniffing the Anycubic Slicer Next on 2026-05-29: # topic = web/printer/.../tempature # data = {"type": 0|1|2, "target_hotbed_temp": B, "target_nozzle_temp": N} - # type-Werte (aus Workbench-Vue): 0=Nozzle, 1=Bed, 2=beide. + # type values (from Workbench Vue): 0=nozzle, 1=bed, 2=both. # Ohne `type` ODER auf `slicer/printer`-Topic → Systemfehler am Drucker. if nozzle is not None and bed is not None: t, n, b = 2, int(float(nozzle)), int(float(bed)) @@ -3380,7 +4523,7 @@ class KobraXBridge: "video", "startCapture", None, timeout=8.0 )) state = (result or {}).get("state", "") - log.info(f"Kamera startCapture: state={state}") + log.info(f"Camera startCapture: state={state}") return web.json_response({"result": "ok", "state": state}) async def handle_api_camera_stop(self, request): @@ -3388,26 +4531,44 @@ class KobraXBridge: await loop.run_in_executor(None, lambda: self.client.publish( "video", "stopCapture", None, timeout=0 )) + # Prevents the auto-start guard from restarting the camera during the + # laufenden Drucks wieder einschaltet (State-Flicker-Problem). + self._camera_user_stopped = True return web.json_response({"result": "ok"}) - async def handle_api_camera_snapshot(self, request): - """Letzter JPEG-Frame aus dem CameraCache — instant aus dem RAM, - keine eigene ffmpeg-Instanz mehr (verhindert Single-Client-429 am - Drucker und ist ~1 s schneller).""" + async def handle_api_camera_reset(self, request): + """Reset the backoff counter and restart ffmpeg immediately. + Useful after a 429 lock (Retry-After expired) or after a printer restart.""" + self.camera_cache.reset() url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + log.warning("Camera reset requested but no camera_url is known yet (waiting for printer status)") + return web.json_response({ + "result": "no_url", + "message": "No camera URL known yet - wait for the next printer status update, or start a print/enable the camera first.", + }) self.camera_cache.set_url(url) await self.camera_cache.ensure_running() - # Initialer Warmup: bis zu 5 s auf ersten Frame warten + return web.json_response({"result": "ok", "url": url}) + + async def handle_api_camera_snapshot(self, request): + """Last JPEG frame from the CameraCache - instant from RAM, + no separate ffmpeg instance anymore (prevents the single-client 429 at the + printer and is ~1 s faster).""" + url = self._state.get("camera_url", "") + if not url: + return web.Response(status=503, text="No camera URL known") + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() + # Initial warmup: wait up to 5s for the first frame deadline = time.time() + 5.0 while not self.camera_cache.latest_jpeg and time.time() < deadline: await asyncio.sleep(0.1) jpeg = self.camera_cache.latest_jpeg if not jpeg: - return web.Response(status=503, text="Noch kein Frame im Cache") - # Wenn der letzte Frame älter als 10 s ist → der Cache-ffmpeg läuft - # vermutlich nicht mehr stabil, trotzdem ausliefern aber Stale-Header. + return web.Response(status=503, text="No frame in cache yet") + # If the last frame is older than 10 s -> the cache ffmpeg is probably + # no longer running stably; deliver anyway but with a stale header. age = time.time() - self.camera_cache.latest_jpeg_ts headers = {"Cache-Control": "no-cache"} if age > 10: @@ -3415,43 +4576,36 @@ class KobraXBridge: return web.Response(body=jpeg, content_type="image/jpeg", headers=headers) async def handle_camera_stream(self, request): - """MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace.""" + """MJPEG live view, served as multipart/x-mixed-replace. + + Fed from the central CameraCache fanout (same pattern as + handle_camera_h264) instead of spawning a dedicated ffmpeg process + per HTTP client. The printer's camera server only tolerates a very + limited number of concurrent connections (see CameraCache docstring) + - previously every consumer of this endpoint (dashboard, OrcaSlicer, + moonraker-obico, a second browser tab, ...) opened its own separate + connection, so two simultaneous viewers could already exhaust the + printer's connection limit and cause intermittent "stream + unavailable" failures. Now all consumers share one connection. + """ url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + return web.Response(status=503, text="No camera URL known") + self.camera_cache.set_url(url) + await self.camera_cache.ensure_running() - is_rtsp = url.lower().startswith("rtsp://") - ffmpeg_input_args = [ - "-fflags", "nobuffer", - "-flags", "low_delay", - ] - if is_rtsp: - ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] - else: - ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"] + q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8) + self.camera_cache.mjpeg_subscribers.add(q) - # ffmpeg erst starten BEVOR der StreamResponse geöffnet wird - # (damit wir bei Fehler noch eine normale HTTP-Response senden können) + # Wait for the first frame BEFORE resp.prepare() - once prepare() sends + # the response headers the status is committed to 200, so a stalled + # source (Issue #99) must be caught here to actually return a 503 + # instead of hanging the client forever with no frame ever arriving. try: - proc = await asyncio.create_subprocess_exec( - _find_ffmpeg(), "-loglevel", "quiet", - *ffmpeg_input_args, - "-i", url, - "-vf", "fps=15,scale=640:-1", - "-f", "image2pipe", - "-vcodec", "mjpeg", - "-q:v", "3", - "-flush_packets", "1", - "pipe:1", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) - except (FileNotFoundError, OSError) as e: - log.warning("Kamera: ffmpeg nicht gefunden – Kamerastream nicht verfügbar") - return web.Response(status=503, text="ffmpeg not found") - except Exception as e: - log.warning(f"Kamera: ffmpeg konnte nicht gestartet werden: {e}") - return web.Response(status=503, text=str(e)) + first_frame = await asyncio.wait_for(q.get(), timeout=5.0) + except asyncio.TimeoutError: + self.camera_cache.mjpeg_subscribers.discard(q) + return web.Response(status=503, text="No frame in cache yet") boundary = "kobraxframe" resp = web.StreamResponse(headers={ @@ -3460,56 +4614,35 @@ class KobraXBridge: "Connection": "keep-alive", }) await resp.prepare(request) - - buf = b"" try: + frame = first_frame while True: - chunk = await proc.stdout.read(65536) - if not chunk: + header = ( + f"--{boundary}\r\n" + f"Content-Type: image/jpeg\r\n" + f"Content-Length: {len(frame)}\r\n\r\n" + ).encode() + try: + await resp.write(header + frame + b"\r\n") + except (ConnectionResetError, asyncio.CancelledError): break - buf += chunk - # Extract complete JPEG frames (SOI=FFD8, EOI=FFD9) - while True: - start = buf.find(b"\xff\xd8") - if start == -1: - buf = b"" - break - end = buf.find(b"\xff\xd9", start + 2) - if end == -1: - buf = buf[start:] - break - frame = buf[start:end + 2] - buf = buf[end + 2:] - header = ( - f"--{boundary}\r\n" - f"Content-Type: image/jpeg\r\n" - f"Content-Length: {len(frame)}\r\n\r\n" - ).encode() - try: - await resp.write(header + frame + b"\r\n") - except Exception: - return resp + except Exception: + break + frame = await q.get() except Exception as e: - log.warning(f"Kamera-Stream unterbrochen: {e}") + log.warning(f"Camera stream interrupted: {e}") finally: - try: - proc.kill() - except Exception: - pass - try: - await proc.wait() - except Exception: - pass + self.camera_cache.mjpeg_subscribers.discard(q) return resp async def handle_camera_h264(self, request): - """H.264-Passthrough als MPEG-TS, gespeist aus dem zentralen - CameraCache-Fanout. Erlaubt mehrere parallele Konsumenten ohne - zusätzliche FLV-Verbindung zum Drucker (Single-Client-Limit).""" + """H.264 passthrough as MPEG-TS, fed from the central + CameraCache fanout. Allows multiple parallel consumers without an + additional FLV connection to the printer (single-client limit).""" url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + return web.Response(status=503, text="No camera URL known") self.camera_cache.set_url(url) await self.camera_cache.ensure_running() @@ -3536,22 +4669,22 @@ class KobraXBridge: return resp async def handle_serve_file(self, request): - """Liefert hochgeladene G-Code-Dateien vom Temp-Verzeichnis (für Drucker-Download).""" + """Serves uploaded G-code files from the temp directory (for printer download).""" filename = os.path.basename(request.match_info.get("filename", "")) serve_path = os.path.join(self._serve_dir_path, filename) if not os.path.isfile(serve_path): return web.Response(status=404, text="not found") size = os.path.getsize(serve_path) - log.info(f"Drucker lädt Datei ab: {filename} ({size} bytes)") + log.info(f"Printer downloading file: {filename} ({size} bytes)") return web.FileResponse(serve_path, headers={ "Content-Disposition": f'attachment; filename="{filename}"' }) async def handle_api_state(self, request): s = self._state - # Slicer-Zeit + Thumbnail sind nur flüchtig im State (werden beim Upload - # gesetzt). Nach Browser-Reload oder bei OrcaSlicer-Direktdruck (Datei kam - # nicht über den UI-Upload) fehlen sie → aus dem GCode-Store anhand des + # Slicer time + thumbnail are only transient in state (set during upload). + # After a browser reload or an OrcaSlicer direct print (file did not come + # through the UI upload) they are missing -> restore from the GCode store via the # laufenden Dateinamens nachladen. slicer_time = s["slicer_time"] thumbnail = self._thumbnail_b64 @@ -3580,13 +4713,17 @@ class KobraXBridge: "remain_time": s["remain_time"], "curr_layer": s["curr_layer"], "total_layers": s["total_layers"], + "z_mm": self._estimate_current_z(), "filename": s["filename"], "slicer_time": slicer_time, "camera_url": s["camera_url"], "fan_speed": s["fan_speed"], "print_speed_mode": s["print_speed_mode"], - "web_upload_warning": getattr(self._args, "web_upload_warning", 1), - "light_on": s["light_on"], + "auto_leveling": getattr(self._args, "auto_leveling", 1), + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "camera_on_print": getattr(self._args, "camera_on_print", 0), + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "light_on": s["light_on"], "light_brightness": s["light_brightness"], "ams_slots": self._ams_slots, "ams_loaded_slot": self._ams_loaded_slot, @@ -3598,7 +4735,10 @@ class KobraXBridge: "thumbnail": thumbnail, "connection_error": s["connection_error"], "file_ready": s["file_ready"], + "print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)), "version": self._read_version(), + "pause_msg": s.get("pause_msg", ""), + "error_code": s.get("error_code", 0), }) async def handle_moonraker_database(self, request): @@ -3623,9 +4763,9 @@ class KobraXBridge: "result": {"namespace": namespace, "key": key, "value": None} }) - # mainsail/presets: Obico fragt nach Temperatur-Presets. Schema wird in - # find_all_thermal_presets als data['value']['presets'].values() ausgewertet, - # also brauchen wir mindestens {presets: {}} damit kein Crash. + # mainsail/presets: Obico asks for temperature presets. The schema is evaluated in + # find_all_thermal_presets as data['value']['presets'].values(), + # so we need at least {presets: {}} to avoid a crash. if namespace == "mainsail": if key == "presets": return web.json_response({ @@ -3636,7 +4776,7 @@ class KobraXBridge: "result": {"namespace": "mainsail", "key": key, "value": {}} }) - # obico-namespace: in-memory KV-Store für Plugin-Settings (key=printer_id etc.) + # obico namespace: in-memory KV store for plugin settings (key=printer_id etc.) if namespace == "obico": store = self._moonraker_kv_store.setdefault("obico", {}) if key and key in store: @@ -3654,7 +4794,7 @@ class KobraXBridge: async def handle_moonraker_database_post(self, request): """POST /server/database/item — KV-Store-Write (von moonraker-obico verwendet). - moonraker-obico sendet namespace/key/value als form-urlencoded POST-params.""" + moonraker-obico sends namespace/key/value as form-urlencoded POST params.""" # Versuche JSON, fallback auf form-data, fallback auf Query-Params namespace = "" key = "" @@ -3686,7 +4826,7 @@ class KobraXBridge: return web.json_response({"error": {"code": 400, "message": "namespace + key required"}}, status=400) async def handle_database_list(self, request): - """OrcaSlicer prüft welche Namespaces vorhanden sind um MMU-Typ zu erkennen.""" + """OrcaSlicer checks which namespaces exist to detect the MMU type.""" return web.json_response({"result": {"namespaces": ["lane_data", "mainsail", "obico"]}}) def _get_ams_slots_fresh(self): @@ -3712,10 +4852,10 @@ class KobraXBridge: # ─── Settings ──────────────────────────────────────────────────────────── def _find_config_path(self) -> pathlib.Path: - """Gibt den Pfad zur config.ini zurück.""" + """Returns the path to config.ini.""" if hasattr(env_loader, "find_config_path"): return env_loader.find_config_path() - # Fallback für alten env_loader + # Fallback for the old env_loader script_dir = pathlib.Path(_BASE) for base in (script_dir, script_dir.parent): p = base / "config" / "config.ini" @@ -3733,10 +4873,18 @@ class KobraXBridge: "mode_id": self._args.mode_id, "device_id": self._args.device_id, "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), - "auto_leveling": getattr(self._args, "auto_leveling", 1), - "camera_on_print": getattr(self._args, "camera_on_print", 0), - "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "auto_leveling": getattr(self._args, "auto_leveling", 1), + "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "camera_on_print": getattr(self._args, "camera_on_print", 0), + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "print_start_dialog": getattr(self._args, "print_start_dialog", 1), + "poll_interval": getattr(self._args, "poll_interval", 3), + "verbose_http_log": getattr(self._args, "verbose_http_log", 0), + "filament_profiles": {str(k): v for k, v in self._filament_profiles.items()}, + "visible_vendors": self._visible_vendors, "ace_dry_presets": self._ace_dry_presets, + "spoolman_server": getattr(self._args, "spoolman_server", "") or "", + "spoolman_sync_rate": getattr(self._args, "spoolman_sync_rate", 0), }) async def handle_api_settings_post(self, request): @@ -3745,13 +4893,13 @@ class KobraXBridge: config_path = self._find_config_path() config_path.parent.mkdir(parents=True, exist_ok=True) - # Bestehende config.ini lesen (Kommentare gehen verloren, aber Werte bleiben) + # Read the existing config.ini (comments are lost, but values are kept) cfg = configparser.ConfigParser() if config_path.is_file(): cfg.read(config_path, encoding="utf-8") # Sections sicherstellen - for section in ("connection", "print", "bridge", "ace_dry_presets"): + for section in ("connection", "print", "bridge", "ace_dry_presets", "spoolman"): if not cfg.has_section(section): cfg.add_section(section) @@ -3763,17 +4911,39 @@ class KobraXBridge: cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or ""))) cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or ""))) cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto")))) - cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) - cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) + cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) + cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) + cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) - if not cfg.has_option("bridge", "poll_interval"): + cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) + if "poll_interval" in data: + try: + pi = max(1, min(60, int(data["poll_interval"]))) + except (TypeError, ValueError): + pi = 3 + cfg.set("bridge", "poll_interval", str(pi)) + elif not cfg.has_option("bridge", "poll_interval"): cfg.set("bridge", "poll_interval", "3") + verbose_http_log = int(bool(data.get("verbose_http_log", getattr(self._args, "verbose_http_log", 0)))) + cfg.set("bridge", "verbose_http_log", str(verbose_http_log)) + _set_verbose_http_log(bool(verbose_http_log)) + self._args.verbose_http_log = verbose_http_log printer_name = str(data.get("printer_name", "")).strip() if printer_name: cfg.set("bridge", "printer_name", printer_name) elif cfg.has_option("bridge", "printer_name"): cfg.remove_option("bridge", "printer_name") + # Spoolman + if "spoolman_server" in data: + cfg.set("spoolman", "server", str(data["spoolman_server"]).strip()) + if "spoolman_sync_rate" in data: + try: + sr = max(0, int(data["spoolman_sync_rate"])) + except (TypeError, ValueError): + sr = 30 + cfg.set("spoolman", "sync_rate", str(sr)) + incoming_presets = data.get("ace_dry_presets") if isinstance(data, dict) else None presets = self._sanitize_ace_dry_presets(incoming_presets if isinstance(incoming_presets, dict) else self._ace_dry_presets) for key, val in presets.items(): @@ -3786,14 +4956,14 @@ class KobraXBridge: with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n\n") cfg.write(f) - log.info(f"Settings gespeichert in {config_path}") - # Response senden, dann Neustart + log.info(f"Settings saved to {config_path}") + # Send the response, then restart response = web.json_response({"status": "restarting"}) asyncio.get_event_loop().call_later(0.3, self._restart_bridge) return response async def handle_kx_printer_add(self, request): - """Fügt einen Drucker hinzu: holt Credentials via IP, schreibt [printer_N], Neustart.""" + """Adds a printer: fetches credentials via IP, writes [printer_N], restarts.""" try: body = await request.json() except Exception: @@ -3805,7 +4975,7 @@ class KobraXBridge: try: creds = await _kx_fetch_credentials(ip) except Exception as e: - return self._json_cors({"error": f"Drucker nicht erreichbar oder Fehler: {e}"}, status=502) + return self._json_cors({"error": f"printer unreachable or error: {e}"}, status=502) import configparser config_path = self._find_config_path() @@ -3825,8 +4995,8 @@ class KobraXBridge: pass n += 1 - # Kein [printer_N], aber ein befüllter [connection]? → als printer_1 migrieren - # (leerer [connection] = kein bestehender Drucker → nicht migrieren, neuer wird printer_1) + # No [printer_N], but a populated [connection]? -> migrate as printer_1 + # (empty [connection] = no existing printer -> don't migrate, the new one becomes printer_1) if n == 1 and cfg.has_section("connection") and (cfg["connection"].get("printer_ip") or "").strip(): c = cfg["connection"] cfg.add_section("printer_1") @@ -3838,7 +5008,7 @@ class KobraXBridge: existing_ports.add(7125) n = 2 - # Neuen Drucker als [printer_n] anlegen, freien Port wählen + # Create the new printer as [printer_n], pick a free port new_port = 7125 + (n - 1) while new_port in existing_ports: new_port += 1 @@ -3857,19 +5027,19 @@ class KobraXBridge: with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n\n") cfg.write(f) - log.info(f"Drucker '{name or creds['model']}' als {sec} hinzugefügt (Port {new_port})") + log.info(f"Printer '{name or creds['model']}' added as {sec} (port {new_port})") response = self._json_cors({"status": "restarting", "section": sec, "http_port": new_port}) asyncio.get_event_loop().call_later(0.5, self._restart_bridge) return response async def handle_kx_printer_remove(self, request): - """Entfernt einen Drucker aus config.ini, dann Neustart. + """Removes a printer from config.ini, then restarts. - - Multi-Modus: [printer_N] wird gelöscht, übrige umnummeriert (printer_3 → printer_2), + - Multi mode: [printer_N] is deleted, the rest renumbered (printer_3 -> printer_2), printer_1 bekommt immer http_port 7125. - - Einzel-Modus (kein [printer_N], nur [connection]): pid "1" leert den [connection]-Block + - Single mode (no [printer_N], only [connection]): pid "1" clears the [connection] block → Bridge startet im Offline-Modus auf 7125, UI bleibt erreichbar. - - Wird der letzte [printer_N] entfernt: alle weg → ebenfalls "leerer" Zustand. + - When the last [printer_N] is removed: all gone -> also the "empty" state. """ pid = str(request.match_info.get("pid", "")).strip() if not pid: @@ -3886,8 +5056,8 @@ class KobraXBridge: if has_printer_sections: if not cfg.has_section(target): - return self._json_cors({"error": f"{target} nicht gefunden"}, status=404) - # Alle [printer_N] einsammeln (außer der zu löschenden), neu nummerieren + return self._json_cors({"error": f"{target} not found"}, status=404) + # Collect all [printer_N] (except the one being deleted), renumber kept = [] n = 1 while cfg.has_section(f"printer_{n}"): @@ -3902,15 +5072,15 @@ class KobraXBridge: cfg.set(sec, k, v) cfg.set(sec, "http_port", str(7125 + i - 1)) remaining = len(kept) - # War das der letzte Drucker? Dann auch [connection] leeren → wirklich "kein Drucker" + # Was that the last printer? Then also clear [connection] -> truly "no printer" if remaining == 0 and cfg.has_section("connection"): for k in ("printer_ip", "username", "password", "device_id"): cfg.set("connection", k, "") else: - # Einzel-Modus: nur pid "1" ist gültig (Pseudo-Eintrag aus handle_kx_printers) + # Single mode: only pid "1" is valid (pseudo entry from handle_kx_printers) if pid != "1": - return self._json_cors({"error": "kein Drucker mit dieser ID"}, status=404) - # [connection]-Werte leeren → Bridge startet ohne Drucker + return self._json_cors({"error": "no printer with this ID"}, status=404) + # Clear [connection] values -> bridge starts without a printer if cfg.has_section("connection"): for k in ("printer_ip", "username", "password", "device_id"): cfg.set("connection", k, "") @@ -3920,31 +5090,36 @@ class KobraXBridge: with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n\n") cfg.write(f) - log.info(f"Drucker {target} entfernt ({remaining} verbleibend)") + log.info(f"Printer {target} removed ({remaining} remaining)") response = self._json_cors({"status": "restarting", "removed": target, "remaining": remaining}) asyncio.get_event_loop().call_later(0.5, self._restart_bridge) return response def _restart_bridge(self): - log.info("Bridge wird neu gestartet …") - # config_loader cached config.ini-Werte in os.environ ("nur wenn nicht gesetzt"). - # Bei einem Restart muss environ bereinigt werden, sonst liest der neue Prozess - # die alten Werte statt der geänderten config.ini. - for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD", - "MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING", - "BRIDGE_PRINTER_NAME"): + log.info("Restarting bridge...") + # config_loader caches config.ini values in os.environ ("only if not set"). + # On restart, environ must be cleaned, otherwise the new process reads + # the old values instead of the modified config.ini. Keys are derived + # from config_loader.CONFIG_ENV_MAPPING (single source of truth) so a + # newly added setting can never be forgotten here again. + try: + import config_loader as _cl + _restart_env_keys = set(_cl.CONFIG_ENV_MAPPING.keys()) | {"FILE_READY_DIALOG"} + except Exception: + _restart_env_keys = () + for _k in _restart_env_keys: os.environ.pop(_k, None) in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER") if in_docker: - # Docker/systemd: Prozess beenden reicht – der Supervisor startet neu (frische environ) - log.info("Container-Umgebung erkannt – beende Prozess für Supervisor-Restart") + # Docker/systemd: exiting the process is enough - the supervisor restarts (fresh environ) + log.info("Container environment detected – exiting for supervisor restart") os._exit(0) frozen = getattr(sys, "frozen", False) - # Linux: os.execv ersetzt das Prozess-Image direkt – sauber auch bei PyInstaller-Onefile - # (subprocess+exit würde dort am gelöschten _MEIxxxx-Temp-Verzeichnis scheitern). + # Linux: os.execv replaces the process image directly - clean even with PyInstaller onefile + # (subprocess+exit would fail there on the deleted _MEIxxxx temp directory). if sys.platform != "win32": exe = sys.executable try: @@ -3953,28 +5128,29 @@ class KobraXBridge: else: os.execv(exe, [exe] + sys.argv) except Exception as e: - log.error(f"Restart (execv) fehlgeschlagen: {e} – bitte Bridge manuell neu starten") + log.error(f"Restart (execv) failed: {e} - please restart the bridge manually") os._exit(1) - # Windows: os.execv ist dort kaputt (neue PID, alter Prozess kehrt zurück) → subprocess + # Windows: os.execv is broken there (new PID, old process returns) -> subprocess cmd = ([sys.executable] + sys.argv[1:]) if frozen else ([sys.executable] + sys.argv) try: subprocess.Popen(cmd, cwd=os.getcwd(), creationflags=(subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP)) except Exception as e: - log.error(f"Restart fehlgeschlagen: {e} – bitte Bridge manuell neu starten") + log.error(f"Restart failed: {e} - please restart the bridge manually") os._exit(0) # ─── Update ────────────────────────────────────────────────────────────── - STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1" - DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true" - GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag" + STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1" + NIGHTLY_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=5&pre-release=true" + DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true" + GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag" def _read_version(self) -> str: - # PyInstaller-Onefile entpackt VERSION (per kx-bridge.spec datas) nach - # sys._MEIPASS — daher _WEB_BASE statt _BASE benutzen. + # PyInstaller onefile unpacks VERSION (via kx-bridge.spec datas) to + # sys._MEIPASS - therefore use _WEB_BASE instead of _BASE. for base in (pathlib.Path(_WEB_BASE), pathlib.Path(_BASE), pathlib.Path(_BASE).parent): p = base / "VERSION" if p.is_file(): @@ -3991,7 +5167,7 @@ class KobraXBridge: @staticmethod def _parse_version(v: str) -> "tuple[int, ...]": - """'v0.9.1-beta1' → (0, 9, 1) – nur numerische Teile vor dem ersten '-'""" + """'v0.9.1-beta1' -> (0, 9, 1) - only numeric parts before the first '-'""" v = v.lstrip("v").split("-")[0] parts = re.split(r"[.\s]+", v) result = [] @@ -4003,7 +5179,7 @@ class KobraXBridge: return tuple(result) or (0,) async def handle_api_log_stream(self, request): - """SSE-Endpoint: sendet Log-Einträge live an den Browser.""" + """SSE endpoint: streams log entries live to the browser.""" resp = web.StreamResponse(headers={ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -4031,9 +5207,9 @@ class KobraXBridge: return resp async def handle_api_log_download(self, request): - """Gibt alle gepufferten Log-Einträge als Plaintext zum Download.""" + """Returns all buffered log entries as plaintext for download.""" header = (f"# KX-Bridge Log | Version {self._read_version()} | " - f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {len(_log_buffer)} Einträge\n") + f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {len(_log_buffer)} entries\n") lines = [f"[{e['ts']}] {e['lvl']:<7} {e['name']}: {e['msg']}" for e in _log_buffer] text = header + "\n".join(lines) + "\n" fname = f"kx-bridge-log_{time.strftime('%Y%m%d-%H%M%S')}.txt" @@ -4045,8 +5221,14 @@ class KobraXBridge: async def handle_api_update_check(self, request): current = self._read_version() + is_nightly = "nightly" in current is_dev = "-dev+" in current - api_url = self.DEV_RELEASE_API if is_dev else self.STABLE_RELEASE_API + if is_nightly: + api_url = self.NIGHTLY_RELEASE_API + elif is_dev: + api_url = self.DEV_RELEASE_API + else: + api_url = self.STABLE_RELEASE_API try: async with aiohttp.ClientSession() as session: async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp: @@ -4054,15 +5236,38 @@ class KobraXBridge: return web.json_response({"error": f"Gitea HTTP {resp.status}"}, status=502) releases = await resp.json(content_type=None) if not releases: - return web.json_response({"error": "Keine Releases gefunden"}, status=404) - # Dev: neuestes Release mit "-dev+" im Tag suchen - if is_dev: + return web.json_response({"error": "no releases found"}, status=404) + + if is_nightly: + # Find the newest prerelease with a nightly tag + nightly_releases = [r for r in releases if r.get("prerelease") and "nightly" in r.get("tag_name", "")] + if not nightly_releases: + return web.json_response({"error": "no nightly releases found"}, status=404) + data = nightly_releases[0] + tag = data.get("tag_name", "") + # Tag-Format: "nightly-0.9.27-nightly4", current: "0.9.27-nightly4" + tag_version = tag[len("nightly-"):] if tag.startswith("nightly-") else tag + update_available = tag_version != current + latest = tag + return web.json_response({ + "current": current, + "latest": latest, + "update_available": update_available, + "tag": tag, + "docker_only": True, + "changelog": data.get("body", ""), + }) + elif is_dev: dev_releases = [r for r in releases if "-dev+" in r.get("tag_name", "")] if not dev_releases: - return web.json_response({"error": "Keine Dev-Releases gefunden"}, status=404) + return web.json_response({"error": "no dev releases found"}, status=404) data = dev_releases[0] else: - data = releases[0] + # Stable: only take non-prereleases + stable_releases = [r for r in releases if not r.get("prerelease")] + if not stable_releases: + return web.json_response({"error": "no stable releases found"}, status=404) + data = stable_releases[0] tag = data.get("tag_name", "") latest = tag.lstrip("v") if is_dev: @@ -4076,16 +5281,17 @@ class KobraXBridge: "update_available": update_available, "tag": tag, "download_url": download_url, + "docker_only": False, "changelog": data.get("body", ""), }) except Exception as e: return web.json_response({"error": str(e)}, status=502) - # Bridge-Python-Module, die das Self-Update mitziehen muss. Wird nur die - # Hauptdatei ersetzt, crasht die neue Version ggf. mit ModuleNotFoundError. - # Hinweis: das Frontend liegt seit dem Theme-System unter web/themes// - # (keine flache .py mehr); Theme-Dateien werden vom Self-Update derzeit NICHT - # mitgeladen – Theme-Änderungen kommen über Docker-Image/Binary-Update. + # Bridge Python modules the self-update must include. If only the + # main file is replaced, the new version may crash with ModuleNotFoundError. + # Note: since the theme system, the frontend lives under web/themes// + # (no flat .py anymore); theme files are currently NOT included in the + # self-update - theme changes arrive via Docker image/binary updates. _UPDATE_FILES = [ "kobrax_moonraker_bridge.py", "kobrax_client.py", @@ -4096,12 +5302,16 @@ class KobraXBridge: async def handle_api_update_apply(self, request): data = await request.json() new_tag = data.get("tag", "") + if "nightly" in self._read_version(): + return web.json_response( + {"error": "nightly updates are delivered via Docker: " + "docker compose pull && docker compose up -d"}, status=400) if getattr(sys, "frozen", False): return web.json_response( - {"error": "Self-Update wird im Binary-Modus nicht unterstützt – " - "bitte neue Binary/Docker-Image laden."}, status=400) + {"error": "self-update is not supported in binary mode - " + "please download the new binary/Docker image."}, status=400) if not new_tag: - return web.json_response({"error": "tag fehlt"}, status=400) + return web.json_response({"error": "missing tag"}, status=400) app_dir = pathlib.Path(__file__).resolve().parent try: @@ -4112,21 +5322,21 @@ class KobraXBridge: url = f"{self.GITEA_RAW_BASE}/{new_tag}/{fname}" async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: - # _web_assets.py o.ä. existiert evtl. in älteren Tags nicht – - # Hauptdatei ist Pflicht, optionale dürfen fehlen. + # _web_assets.py etc. may not exist in older tags - + # the main file is mandatory, optional ones may be missing. if fname == "kobrax_moonraker_bridge.py": return web.json_response( {"error": f"Download {fname}: HTTP {resp.status}"}, status=502) - log.warning(f"Update: {fname} nicht im Release ({resp.status}) – übersprungen") + log.warning(f"Update: {fname} not found in release ({resp.status}) – skipped") continue downloaded.append((app_dir / fname, await resp.read())) - # Phase 2: atomar ersetzen (erst nach komplettem, erfolgreichem Download) + # Phase 2: replace atomically (only after a complete, successful download) for path, content in downloaded: tmp = path.with_suffix(path.suffix + ".new") tmp.write_bytes(content) os.replace(tmp, path) self._write_version(new_tag.lstrip("v")) - log.info(f"Update auf {new_tag} installiert ({len(downloaded)} Dateien), starte neu …") + log.info(f"Update to {new_tag} installed ({len(downloaded)} files), restarting...") except Exception as e: return web.json_response({"error": str(e)}, status=502) response = web.json_response({"status": "updating"}) @@ -4139,7 +5349,7 @@ class KobraXBridge: return web.json_response({"result": {}}, status=200) async def handle_favicon(self, request): - # Minimales 1x1 ICO damit der Browser nicht 404 loggt + # Minimal 1x1 ICO so the browser doesn't log a 404 ico = bytes([ 0,0,1,0,1,0,1,1,0,0,1,0,24,0,40,0,0,0,22,0,0,0,40,0,0,0, 1,0,0,0,2,0,0,0,1,0,24,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0, @@ -4148,35 +5358,35 @@ class KobraXBridge: return web.Response(body=ico, content_type="image/x-icon") # ------------------------------------------------------------------------- - # Klipper G-Code-Script Emulation für moonraker-obico + # Klipper G-code script emulation for moonraker-obico # ------------------------------------------------------------------------- async def _exec_gcode_script(self, script: str) -> str: - """Mappt eine Klipper- oder Marlin-G-Code-Zeile auf einen MQTT-Befehl - an den Kobra X. Unterstützt: + """Maps a Klipper or Marlin G-code line to an MQTT command + for the Kobra X. Supports: - PAUSE / M25, RESUME / M24, CANCEL_PRINT / M0/M1/M524/ABORT - M104 S → Nozzle-Temperatur - M140 S → Bett-Temperatur - SET_HEATER_TEMPERATURE HEATER=extruder TARGET=200 (Klipper) - SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=60 (Klipper) - Unbekannte Scripts werden mit 'ok' quittiert (Obico schickt z.B. G28 - zum Home, das ignoriert die Bridge stillschweigend).""" + Unknown scripts are acknowledged with 'ok' (Obico e.g. sends G28 + for homing, which the bridge silently ignores).""" if not script: return "ok" s = script.strip().upper() loop = asyncio.get_event_loop() def _parse_marlin_temp(line: str) -> int | None: - """Aus 'M104 S200' oder 'M140 S60' den Temperatur-Wert ziehen.""" + """Extract the temperature value from 'M104 S200' or 'M140 S60'.""" try: return int(line.split("S", 1)[1].split()[0]) except Exception: return None def _parse_klipper_set_heater(line: str) -> tuple[str | None, int | None]: - """Aus 'SET_HEATER_TEMPERATURE HEATER=extruder TARGET=143' die - Heater-ID + Target rausziehen. Heater ist 'extruder' oder - 'heater_bed', Target ist int. Liefert (None,None) bei Fehler.""" + """Extract heater + target from 'SET_HEATER_TEMPERATURE HEATER=extruder TARGET=143'. + Heater ID + target. Heater is 'extruder' or + 'heater_bed', target is int. Returns (None,None) on error.""" heater = None target = None for part in line.split(): @@ -4190,8 +5400,8 @@ class KobraXBridge: return heater, target async def _set_temps(nozzle: int | None, bed: int | None): - """Setzt Nozzle/Bed-Temperatur über den richtigen MQTT-Pfad — - druckend: print/update mit taskid, idle: tempature/set mit beiden.""" + """Sets nozzle/bed temperature via the correct MQTT path - + printing: print/update with taskid, idle: tempature/set with both.""" is_printing = self._state.get("print_state") in ("printing", "paused") if is_printing: taskid = self._state.get("taskid", "") @@ -4206,7 +5416,7 @@ class KobraXBridge: {"taskid": taskid, "settings": {"target_hotbed_temp": int(bed)}}, )) else: - # Idle: tempature/set via web/printer-Topic mit type-Feld + # Idle: tempature/set via the web/printer topic with a type field # (Live-Sniff 2026-05-29). type: 0=Nozzle, 1=Bed, 2=beide. if nozzle is not None and bed is not None: t, n, b = 2, int(nozzle), int(bed) @@ -4280,7 +5490,7 @@ class KobraXBridge: await ws.prepare(request) ws._loop = asyncio.get_event_loop() self.ws_clients.add(ws) - log.info(f"WS client verbunden ({len(self.ws_clients)} gesamt)") + log.info(f"WS client connected ({len(self.ws_clients)} total)") # Send klippy_ready notification await ws.send_str(json.dumps({ @@ -4302,7 +5512,7 @@ class KobraXBridge: break self.ws_clients.discard(ws) - log.info(f"WS client getrennt ({len(self.ws_clients)} verbleibend)") + log.info(f"WS client disconnected ({len(self.ws_clients)} remaining)") return ws async def _handle_ws_rpc(self, ws: web.WebSocketResponse, raw: str): @@ -4388,22 +5598,25 @@ class KobraXBridge: script = (params.get("script") or "").strip().upper() if isinstance(params, dict) else "" result = await self._exec_gcode_script(script) elif method in ("server.connection.identify",): - # Obico identifiziert sich beim Connect. Connection-ID egal. + # Obico identifies itself on connect. Connection ID doesn't matter. result = {"connection_id": 1} elif method == "connection.register_remote_method": # Obico registriert obico_remote_event-Callback. Wir akzeptieren leer. result = "ok" elif method == "server.webcams.list": - # WS-Variante des HTTP-Endpoints + # WS variant: absolute URL with the real LAN IP instead of localhost + _lip = getattr(self, "_local_ip", None) or "127.0.0.1" + _base = f"http://{_lip}:{self._args.port}" result = {"webcams": [{ "name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer", - "enabled": True, "stream_url": "/api/camera/stream", - "snapshot_url": "/api/camera/snapshot", + "enabled": True, + "stream_url": f"{_base}/api/camera/stream", + "snapshot_url": f"{_base}/api/camera/snapshot", "flip_horizontal": False, "flip_vertical": False, "rotation": 0, "target_fps": 5, "aspect_ratio": "16:9", }]} elif method == "server.history.list": - # Reuse HTTP-Handler-Logik (Moonraker-Schema mit Unix-TS). + # Reuse the HTTP handler logic (Moonraker schema with Unix TS). try: jobs = self._store.list_jobs(limit=50) or [] except Exception: @@ -4427,28 +5640,18 @@ class KobraXBridge: elif method == "machine.update.status": result = {"busy": False, "version_info": {}} elif method == "server.files.metadata": - # Obico fragt nach Metadaten zu einer Datei (filename in params) + # Obico + Mobileraker request metadata for a file. Same + # logic as the HTTP endpoint (previously a separate broken path with + # a non-existent store method -> empty response -> + # Mobileraker-Endlosschleife, Issue #48). fname = (params or {}).get("filename") if isinstance(params, dict) else None - meta = {} - if fname: - try: - rec = self._store.get_file_by_filename(fname) if hasattr(self._store, "get_file_by_filename") else None - except Exception: - rec = None - if rec: - meta = { - "filename": rec.get("filename"), - "size": rec.get("size_bytes") or 0, - "modified": time.time(), - "estimated_time": rec.get("est_print_time_sec") or 0, - "thumbnails": [], - } - result = meta + fname = fname or self._state.get("filename", "") + result = self._build_file_metadata(fname) if fname else {} else: log.debug(f"Unbekannte RPC-Methode: {method}") result = {} except Exception as e: - log.error(f"RPC-Fehler für {method}: {e}") + log.error(f"RPC error for {method}: {e}") error = {"code": -32603, "message": str(e)} if rpc_id is not None: @@ -4464,7 +5667,7 @@ class KobraXBridge: # ------------------------------------------------------------------------- def _printer_reachable(self) -> bool: - """TCP-Probe auf den MQTT-Port – kein ICMP nötig, kein root erforderlich.""" + """TCP probe on the MQTT port - no ICMP needed, no root required.""" import socket as _socket try: with _socket.create_connection( @@ -4482,18 +5685,18 @@ class KobraXBridge: # ── Offline-Modus: warten bis Drucker wieder erreichbar ────────── if _offline: if self._printer_reachable(): - log.info("Drucker erreichbar – stelle MQTT-Verbindung her …") + log.info("Printer reachable - establishing MQTT connection...") try: self.client.connect() _offline = False self._state["print_state"] = "standby" self._state["kobra_state"] = "free" self._state["connection_error"] = "" - log.info("MQTT-Verbindung wiederhergestellt") + log.info("MQTT connection re-established") except Exception as e: err = _mqtt_error_msg(e) self._state["connection_error"] = err - log.warning(f"Verbindungsaufbau fehlgeschlagen: {err}") + log.warning(f"Connection attempt failed: {err}") stop_event.wait(_probe_interval) continue else: @@ -4505,12 +5708,20 @@ class KobraXBridge: info = self.client.query_info() if info: self._on_info(info) - # Während Druck: print/report direkt abfragen + # While printing: query print/report directly if self._state["print_state"] in ("printing", "preheating", "auto_leveling", "checking", "init"): print_r = self.client.publish("print", "query", timeout=3.0) if print_r: self._on_print(print_r) + # Spoolman mid-print sync + if (self._spoolman and self._spoolman.sync_rate > 0 + and self._spoolman_slot_spools + and self._state.get("print_state") == "printing"): + now = time.time() + if now - self._spoolman_last_sync >= self._spoolman.sync_rate: + self._spoolman_sync_midprint() + self._spoolman_last_sync = now box = self.client.query_multicolor_box() if box: data = box.get("data") or {} @@ -4527,11 +5738,20 @@ class KobraXBridge: if global_slots: self._ams_slots = global_slots self._ams_loaded_slot = global_loaded + self._spoolman_attribute_tick(activity_map) + else: + # No multiColorBox data — still attribute (no transitions to skip) + self._spoolman_attribute_tick({}) + # Recheck Spoolman reachability periodically so the UI status + # dot reflects the current state, not just the boot-time result. + if self._spoolman and time.time() - self._spoolman_last_health_check >= 30.0: + self._spoolman_reachable = self._spoolman.health_check() + self._spoolman_last_health_check = time.time() except Exception as e: - log.warning(f"Poll-Fehler: {e}") - # Prüfen ob Drucker wirklich weg ist + log.warning(f"Poll error: {e}") + # Check whether the printer is really gone if not self._printer_reachable(): - log.info("Drucker nicht erreichbar – wechsle in Offline-Modus") + log.info("Printer unreachable - switching to offline mode") self._state["print_state"] = "error" self._state["kobra_state"] = "offline" self._state["connection_error"] = f"Printer unreachable ({self._args.printer_ip})" @@ -4540,7 +5760,7 @@ class KobraXBridge: except Exception: pass _offline = True - stop_event.wait(3.0) + stop_event.wait(getattr(self._args, "poll_interval", 3)) # --------------------------------------------------------------------------- @@ -4590,7 +5810,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_post("/printer/print/resume", bridge.handle_print_resume) r.add_post("/printer/print/cancel", bridge.handle_print_cancel) - # Moonraker-Stubs für moonraker-obico + # Moonraker stubs for moonraker-obico r.add_get("/access/api_key", bridge.handle_access_api_key) r.add_get("/machine/update/status", bridge.handle_machine_update_status) r.add_get("/server/history/list", bridge.handle_history_list) @@ -4626,6 +5846,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_get("/api/camera/snapshot", bridge.handle_api_camera_snapshot) r.add_post("/api/camera/start", bridge.handle_api_camera_start) r.add_post("/api/camera/stop", bridge.handle_api_camera_stop) + r.add_post("/api/camera/reset", bridge.handle_api_camera_reset) r.add_get("/api/state", bridge.handle_api_state) r.add_get("/api/settings", bridge.handle_api_settings_get) r.add_post("/api/settings", bridge.handle_api_settings_post) @@ -4644,12 +5865,17 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete) r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download) r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify) + r.add_get("/kx/printer-files", bridge.handle_kx_printer_files) + r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete) + r.add_get("/kx/printer-files/{filename}/thumbnail", bridge.handle_kx_printer_file_thumbnail) r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots) r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles) r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile) - # Custom-Profile-Import (Issue #41) — User lädt eigene Orca-Filament- - # Profile als ZIP/JSON hoch (z.B. aus ~/.config/OrcaSlicer/user//filament/), - # weil die Bridge typischerweise nicht auf demselben Host wie OrcaSlicer läuft. + r.add_get("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors) + r.add_post("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors) + # Custom profile import (Issue #41) - the user uploads their own Orca filament + # profiles as ZIP/JSON (e.g. from ~/.config/OrcaSlicer/user//filament/), + # because the bridge typically does not run on the same host as OrcaSlicer. r.add_get("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_list) r.add_post("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_import) r.add_delete("/kx/filament/profiles/user", bridge.handle_kx_filament_profiles_user_delete) @@ -4659,6 +5885,9 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_post("/kx/skip", bridge.handle_kx_skip) r.add_post("/kx/skip/query", bridge.handle_kx_skip_query) r.add_get("/kx/skip/state", bridge.handle_kx_skip_state) + r.add_get("/kx/spoolman/status", bridge.handle_kx_spoolman_status) + r.add_get("/kx/spoolman/spools", bridge.handle_kx_spoolman_spools) + r.add_post("/kx/spoolman/active-spool", bridge.handle_kx_spoolman_set_active) r.add_route("OPTIONS", "/kx/{path:.*}", bridge.handle_kx_options) # Root + Printer-Routen (Single-Page, JS liest Pathname) @@ -4669,14 +5898,14 @@ def build_app(bridge: KobraXBridge) -> web.Application: # WebSocket r.add_get("/websocket", bridge.handle_websocket) - # Catch-all: alle unbekannten Requests loggen statt 404 + # Catch-all: log all unknown requests instead of 404 r.add_route("*", "/{path:.*}", bridge.handle_catchall) return app def _build_per_printer_args(base_args, p: dict): - """Kopiere CLI-Args, überschreibe mit Druckereintrag aus config.ini.""" + """Copy CLI args, override with the printer entry from config.ini.""" import copy a = copy.copy(base_args) a.printer_ip = p.get("printer_ip") or base_args.printer_ip @@ -4690,6 +5919,7 @@ def _build_per_printer_args(base_args, p: dict): async def run_bridge(args): + _set_verbose_http_log(bool(getattr(args, "verbose_http_log", 0))) printers = env_loader.list_printers() multi_mode = bool(printers) if not printers: @@ -4714,7 +5944,7 @@ async def run_bridge(args): for idx, p in enumerate(printers): pid = str(p.get("id") or (idx + 1)) per_args = _build_per_printer_args(args, p) - # Default-Port-Konvention: 7125 + (id-1) wenn kein http_port gesetzt + # Default port convention: 7125 + (id-1) when no http_port is set if not p.get("http_port") and multi_mode: try: per_args.port = 7125 + (int(pid) - 1) @@ -4734,19 +5964,19 @@ async def run_bridge(args): client, args=per_args, store=store, printer_id=pid, all_bridges=all_bridges, ) - # printer_name aus config.ini übernehmen falls gesetzt + # Adopt printer_name from config.ini if set if p.get("name"): bridge._state["printer_name"] = p["name"] bridge._name_locked = True all_bridges[pid] = bridge - log.info(f"[Drucker {pid}] Verbinde mit {per_args.printer_ip}:{per_args.mqtt_port} …") + log.info(f"[Printer {pid}] Connecting to {per_args.printer_ip}:{per_args.mqtt_port}...") try: await loop.run_in_executor(None, client.connect) - log.info(f"[Drucker {pid}] MQTT verbunden") + log.info(f"[Printer {pid}] MQTT connected") except Exception as e: err = _mqtt_error_msg(e) - log.warning(f"[Drucker {pid}] Verbindung fehlgeschlagen: {err} – Offline-Modus") + log.warning(f"[Printer {pid}] Connection failed: {err} - offline mode") bridge._state["print_state"] = "error" bridge._state["kobra_state"] = "offline" bridge._state["connection_error"] = err @@ -4762,18 +5992,30 @@ async def run_bridge(args): site = web.TCPSite(runner, args.host, per_args.port) await site.start() runners.append((runner, client, pid)) - log.info(f"[Drucker {pid}] Bridge läuft auf http://{args.host}:{per_args.port}") import socket as _socket - try: - with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s: - _s.connect(("8.8.8.8", 80)) - _local_ip = _s.getsockname()[0] - except Exception: - _local_ip = args.host - log.info(f"OrcaSlicer → Klipper → Host: {_local_ip} Ports: " + - ", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values())) - log.info("Ctrl-C zum Beenden") + _in_docker = os.path.exists("/.dockerenv") + _host_ip_override = env_loader.BRIDGE_HOST_IP.strip() + if _host_ip_override: + _local_ip = _host_ip_override + else: + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM) as _s: + _s.connect(("8.8.8.8", 80)) + _local_ip = _s.getsockname()[0] + except Exception: + _local_ip = args.host + # Propagate to all bridge instances - used for absolute webcam URLs + for _b in all_bridges.values(): + _b._local_ip = _local_ip + ports = ", ".join(str(getattr(b._args, 'port', 0)) for b in all_bridges.values()) + if _in_docker and not _host_ip_override: + # In a container the UDP trick only yields the Docker-internal IP - don't show it + log.info(f"OrcaSlicer → Klipper → http://:{ports}") + log.info("Running in Docker — set BRIDGE_HOST_IP to show the exact address") + else: + log.info(f"OrcaSlicer → Klipper → http://{_local_ip}:{ports}") + log.info("Press Ctrl-C to stop") try: while True: @@ -4791,12 +6033,12 @@ async def run_bridge(args): client.disconnect() except Exception: pass - log.info("Bridge beendet") + log.info("Bridge stopped") def _default_data_dir() -> str: """Persistenz-Verzeichnis: Docker setzt KX_DATA_DIR, Binary nutzt /data, - Dev-Script nutzt /data (oder /app/data falls vorhanden).""" + Dev script uses /data (or /app/data if present).""" if os.environ.get("KX_DATA_DIR"): return os.environ["KX_DATA_DIR"] if getattr(sys, "frozen", False): @@ -4807,7 +6049,7 @@ def _default_data_dir() -> str: def main(): - parser = argparse.ArgumentParser(description="Moonraker-Bridge für Anycubic Kobra X") + parser = argparse.ArgumentParser(description="Moonraker bridge for the Anycubic Kobra X") parser.add_argument("--printer-ip", default=env_loader.PRINTER_IP, help="IP-Adresse des Druckers") parser.add_argument("--mqtt-port", type=int, default=env_loader.MQTT_PORT) @@ -4816,16 +6058,27 @@ def main(): parser.add_argument("--mode-id", default=env_loader.MODE_ID) parser.add_argument("--device-id", default=env_loader.DEVICE_ID) parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT) - parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) - parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) + parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) + parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION) + parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING) + parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG) + parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int) + parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER, + help="Spoolman URL (e.g. http://192.168.x.x:7912); leave empty to disable") + parser.add_argument("--spoolman-sync-rate", type=int, default=env_loader.SPOOLMAN_SYNC_RATE, + help="Mid-print filament sync interval in seconds (0 = only on print end)") + parser.add_argument("--poll-interval", type=int, default=env_loader.POLL_INTERVAL, + help="Printer poll interval in seconds") + parser.add_argument("--verbose-http-log", type=int, default=env_loader.VERBOSE_HTTP_LOG, + help="Log every HTTP request (aiohttp access log)") parser.add_argument("--host", default="0.0.0.0", - help="Bind-Adresse für den Bridge-Server") + help="Bind address for the bridge server") parser.add_argument("--port", type=int, default=7125, help="HTTP/WS-Port (Moonraker-Standard: 7125)") parser.add_argument("--data-dir", default=_default_data_dir(), - help="Persistenz-Verzeichnis für GCode-Store und DB") + help="Persistence directory for the GCode store and DB") parser.add_argument( "--ui-theme", default=os.environ.get("KX_UI_THEME", "default"), @@ -4837,7 +6090,7 @@ def main(): if args.printer_ip and ":" in args.printer_ip: args.printer_ip = args.printer_ip.split(":")[0] - # Windows braucht ProactorEventLoop für asyncio.create_subprocess_exec + # Windows needs ProactorEventLoop for asyncio.create_subprocess_exec if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) diff --git a/moonraker-obico.cfg.example b/moonraker-obico.cfg.example new file mode 100644 index 0000000..e166677 --- /dev/null +++ b/moonraker-obico.cfg.example @@ -0,0 +1,18 @@ +[server] +url = http://127.0.0.1:3334 +auth_token = REPLACE_ME +sentry_opt = out + +[moonraker] +host = 127.0.0.1 +port = 7125 + +[webcam] +disable_video_streaming = False +snapshot_url = http://127.0.0.1:7125/api/camera/snapshot +stream_url = http://127.0.0.1:7125/api/camera/stream +target_fps = 5 + +[logging] +path = /opt/printer_data/logs/moonraker-obico.log +level = INFO diff --git a/orca_filaments.py b/orca_filaments.py index c8fa40f..c56a591 100644 --- a/orca_filaments.py +++ b/orca_filaments.py @@ -1,10 +1,10 @@ """OrcaSlicer Filament-Profil Parser. -Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem +Shared between the generator (tools/gen_orca_filament_list.py) and the Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py). -Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt -sie als normalisierte Liste mit (id, name, vendor, type, color) zurück. +Reads Orca filament JSON files (system or user profiles) and returns +them as a normalized list with (id, name, vendor, type, color). """ from __future__ import annotations @@ -13,8 +13,8 @@ import re def first_str(value, default: str = "") -> str: - """Orca-Profile speichern manche Felder als ['wert']. Liefert erstes - Element als String.""" + """Orca profiles store some fields as ['value']. Returns the first + element as a string.""" if isinstance(value, list): return str(value[0]) if value else default if isinstance(value, str): @@ -37,34 +37,34 @@ def parse_profile(data: dict, by_name: dict | None = None, path_vendor: str | None = None, source_path: str = "", system_index: list | None = None) -> dict | None: - """Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema. + """Parses a single Orca filament profile into the bridge schema. - `by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve - aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei - aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein — - die fertige System-Profile-Liste aus orca_filaments.json. Damit können - wir filament_id/vendor/type/color über die `inherits`-Kette aus dem - System-Parent ableiten, auch wenn das User-Profil diese Felder nicht - selbst setzt (typisch: User-Override-Profile haben nur Tweaks). + `by_name` is optionally a {name: [profile, ...]} index for inherits resolution + from the raw source tree (generator). For single-file imports (user file + from the OrcaSlicer user dir) we pass `system_index` instead - + the finished system profile list from orca_filaments.json. This lets + us derive filament_id/vendor/type/color via the `inherits` chain from + the system parent even when the user profile does not set these + fields itself (typically: user override profiles only contain tweaks). - Liefert {id, name, vendor, type, color} oder None wenn das Profil - keine filament_id hat (z.B. abstrakte @base-Templates). + Returns {id, name, vendor, type, color} or None when the profile + has no filament_id (e.g. abstract @base templates). """ if not isinstance(data, dict): return None - # User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld — - # das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type" - # explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt. + # User profiles from the OrcaSlicer user dir often set NO "type" field - + # it comes from the system parent. We accept that when either "type" + # is explicitly "filament" OR an "inherits" points to another profile. if data.get("type") not in (None, "filament") and not data.get("inherits"): return None if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"): - # type=filament aber kein parent + keine ID → wertloses Stub + # type=filament but no parent + no ID -> worthless stub return None inst = data.get("instantiation", "true") if isinstance(inst, str) and inst.lower() == "false": return None - # Build system-name-Index für den fallback-Lookup wenn system_index gesetzt. + # Build the system name index for the fallback lookup when system_index is set. sys_by_name: dict[str, dict] = {} if system_index: for p in system_index: @@ -92,7 +92,7 @@ def parse_profile(data: dict, by_name: dict | None = None, return None def _resolve_via_system_index(key: str): - """Inherits-Kette über system_index (clean_name-Match).""" + """Inherits chain via system_index (clean_name match).""" parent_raw = data.get("inherits") if not parent_raw or not sys_by_name: return None @@ -100,7 +100,7 @@ def parse_profile(data: dict, by_name: dict | None = None, sys_p = sys_by_name.get(parent_clean) if not sys_p: return None - # System-JSON benutzt schon das normalisierte Schema + # The system JSON already uses the normalized schema mapping = { "filament_id": "id", "filament_vendor": "vendor", @@ -136,10 +136,10 @@ def parse_profile(data: dict, by_name: dict | None = None, def parse_profile_bytes(blob: bytes, source_name: str = "", system_index: list | None = None) -> dict | None: - """Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad. - `system_index` ist optional die fertige Liste aus orca_filaments.json — - wird für die Inherits-Resolve von User-Profilen genutzt die das volle - Schema vom System-Parent erben.""" + """Reads a single profile from JSON bytes. For the file upload path. + `system_index` is optionally the finished list from orca_filaments.json - + used for the inherits resolution of user profiles that do not carry the full + schema from the system parent.""" try: data = json.loads(blob.decode("utf-8", errors="replace")) except Exception: diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 0000000..74818f8 --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,21 @@ +## Description + + +## Related Issue +Closes # + +## Type +- [ ] Bug fix +- [ ] Feature +- [ ] Documentation +- [ ] Refactoring + +## Tested with +- OrcaSlicer Version: +- Printer: +- Moonraker/Klipper Version: + +## Checklist +- [ ] Tests added/updated +- [ ] CHANGELOG.md updated +- [ ] No debug code included 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/releases/0.9.0-beta1/SHA256SUMS.txt b/releases/0.9.0-beta1/SHA256SUMS.txt deleted file mode 100644 index b6818cd..0000000 --- a/releases/0.9.0-beta1/SHA256SUMS.txt +++ /dev/null @@ -1,3 +0,0 @@ -fb4bf06b0cfb5bcac81e2faf99d8ace1c15771ea009837802a08a4dd5ba77a8f /home/coding/Source/kobrax/releases/0.9.0-beta1/extract_credentials -68f9bf800d1df0e71423edd35e90a8f5f7fb6e9e5220a8c12ed98cc6c4fb4833 /home/coding/Source/kobrax/releases/0.9.0-beta1/extract_credentials.exe -7c1a99953e21fc3881f60df444940d66a4689b009e9a17ec936396857a6b9dc0 /home/coding/Source/kobrax/releases/0.9.0-beta1/kx-bridge diff --git a/start.sh b/start.sh index eb8a72b..a5b6231 100755 --- a/start.sh +++ b/start.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash -# start.sh – KX-Bridge starten (baut Docker-Image automatisch wenn nötig) +# start.sh – KX-Bridge starten (zieht das fertige Image aus der Registry) set -euo pipefail cd "$(dirname "$0")" +IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge" + # .env anlegen falls nicht vorhanden if [[ ! -f .env ]]; then if [[ -f .env.example ]]; then @@ -30,38 +32,38 @@ if ! docker info > /dev/null 2>&1; then exit 1 fi -# Prüfen ob Build nötig ist -NEEDS_BUILD=0 -if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then - echo "[start] Image nicht vorhanden – baue kx-bridge:latest ..." - NEEDS_BUILD=1 +# Release-Kanal abfragen +CHANNEL="" +if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then + CHANNEL="$1" else - # Image-Erstellungszeit in Unix-Sekunden - IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \ - | python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \ - s=s[:26].rstrip('Z').replace('T',' '); \ - print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0) - - for f in Dockerfile \ - bridge/kobrax_moonraker_bridge.py \ - bridge/kobrax_client.py \ - bridge/env_loader.py \ - bridge/requirements.txt \ - bridge/anycubic_slicer.crt \ - bridge/anycubic_slicer.key; do - if [[ -f "$f" ]]; then - FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0) - if [[ $FILE_TS -gt $IMAGE_TS ]]; then - echo "[start] '$f' ist neuer als das Image – baue neu ..." - NEEDS_BUILD=1 - break - fi - fi - done + echo "" + echo " Welchen Release-Kanal möchtest du starten?" + echo " 1) stable (empfohlen)" + echo " 2) nightly (getestete Vorabversion)" + echo -n " Auswahl [1]: " + read -r CHOICE + case "$CHOICE" in + 2) CHANNEL="nightly" ;; + *) CHANNEL="stable" ;; + esac fi -if [[ $NEEDS_BUILD -eq 1 ]]; then - docker build -t kx-bridge:latest . +if [[ "$CHANNEL" == "nightly" ]]; then + IMAGE_TAG="nightly" +else + IMAGE_TAG="latest" +fi +IMAGE="$IMAGE_BASE:$IMAGE_TAG" + +echo "[start] Kanal: $CHANNEL → Image: $IMAGE" +echo "[start] Ziehe aktuelles Image ..." +docker pull "$IMAGE" + +# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile) +if [[ -f docker-compose.yml ]]; then + sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml + rm -f docker-compose.yml.bak fi # Container starten @@ -70,7 +72,7 @@ docker-compose down 2>/dev/null || true docker-compose up -d echo "" -echo " ✓ KX-Bridge läuft" +echo " ✓ KX-Bridge läuft ($CHANNEL)" echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125" echo " Logs : docker-compose logs -f" echo " Stop : docker-compose down" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..25bd6a8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,70 @@ +""" +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, tempfile, pytest, pytest_asyncio +from unittest.mock import MagicMock +from aiohttp.test_utils import TestClient, TestServer + +# ── Pfad ────────────────────────────────────────────────────────────────────── +# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root. +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent)) + +# ── 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, + data_dir = tempfile.mkdtemp(prefix="kxtest-"), + ) + 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_ace_rfid_vendor_matching.py b/tests/test_ace_rfid_vendor_matching.py new file mode 100644 index 0000000..88b9135 --- /dev/null +++ b/tests/test_ace_rfid_vendor_matching.py @@ -0,0 +1,111 @@ +"""Auto-matching for custom ACE-RFID filament tags (Issue #101). + +Anycubic's ACE RFID system concatenates vendor + material + a truncated +serial into one `type` string for custom (third-party) tags, e.g. +"GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for +"Basic"). Previously the bridge treated this whole string as an unknown +material and fell back to a neutral "Generic " profile, even though +the user had already imported a matching OrcaSlicer profile via the ZIP +import feature (Issue #41) - requiring a manual per-slot reassignment every +time that spool was loaded. + +_parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this +automatically against the merged system+user filament library. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +USER_PROFILES = [ + {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""}, + {"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True}, +] + + +def _bridge(profiles=USER_PROFILES): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxrfid-"), + ) + b = KobraXBridge(c, args=args) + b._orca_filaments_cache = profiles + return b + + +def test_combined_rfid_type_parses_known_vendor_and_family(): + b = _bridge() + vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas") + assert vendor == "Geeetech" + assert family == "PLA" + + +def test_plain_type_string_is_unaffected(): + """Regression guard: a normal type="PLA" report (no vendor prefix) must + not be mistaken for a combined RFID string.""" + b = _bridge() + vendor, family = b._parse_combined_rfid_type("PLA") + assert vendor == "" + assert family == "" + + +def test_unknown_vendor_prefix_returns_no_match(): + b = _bridge() + vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas") + assert vendor == "" + assert family == "" + + +def test_match_profile_by_vendor_family_finds_imported_profile(): + b = _bridge() + profile = b._match_profile_by_vendor_family("Geeetech", "PLA") + assert profile.get("name") == "Geeetech PLA Basic" + + +def test_match_profile_by_vendor_family_no_match_returns_empty(): + b = _bridge() + profile = b._match_profile_by_vendor_family("Geeetech", "PETG") + assert profile == {} + + +def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing(): + profiles = USER_PROFILES + [ + {"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True}, + ] + b = _bridge(profiles) + profile = b._match_profile_by_vendor_family("Geeetech", "PLA") + assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk") + + +def test_build_lane_data_auto_resolves_combined_rfid_slot(): + """End-to-end: a slot reporting the combined RFID string should surface + the imported Geeetech profile in lane_data instead of the Generic + fallback.""" + b = _bridge() + b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path + b._filament_mode = "ace_hub" + b._ams_slots = [ + {"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]}, + ] + lane = b._build_lane_data() + tray = lane["ams"][0]["tray"][0] + assert tray["vendor_name"] == "Geeetech" + assert tray["name"] == "Geeetech PLA Basic" + + +def test_build_lane_data_plain_type_still_uses_generic_fallback(): + """Regression guard: everyday type="PLA" slots must keep using the + existing Generic-library fallback, unaffected by the new matching path.""" + b = _bridge() + b._filament_profiles = {} # no manual per-slot override - isolate the fallback path + b._filament_mode = "ace_hub" + b._ams_slots = [ + {"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]}, + ] + lane = b._build_lane_data() + tray = lane["ams"][0]["tray"][0] + assert tray["name"] == "Generic PLA" + assert tray["vendor_name"] == "Generic" 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_auto_ams_box_mapping_empty_slot.py b/tests/test_auto_ams_box_mapping_empty_slot.py new file mode 100644 index 0000000..7c90466 --- /dev/null +++ b/tests/test_auto_ams_box_mapping_empty_slot.py @@ -0,0 +1,85 @@ +"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path. + +Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it +EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping` +inserts a positional placeholder at each gap whose `ams_index` points at the +gap's own (physically EMPTY) tray. The printer rejects a mapping entry that +references an empty tray, even for a tool the GCode never calls. + +Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray. +Positional alignment (entry N = TN, from a16062f) must be preserved. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3). +AMS_SLOTS = [ + {"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]}, + {"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]}, + {"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY + {"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]}, +] + + +def _bridge(slots=AMS_SLOTS): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxauto-"), + ) + b = KobraXBridge(c, args=args) + b._filament_mode = "toolhead" + b._ams_slots = [dict(s) for s in slots] + return b + + +def _loaded_ams_indices(b): + return {b._slot_to_print_ams_index(int(s["global_index"])) + for s in b._ams_slots if s["status"] == 5} + + +def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded(): + """_start_print filters loaded to the used slot only: loaded=[(3, slot3)]. + Positions 0..2 are placeholders and must NOT reference empty tray idx 2.""" + b = _bridge() + loaded = [(3, b._ams_slots[3])] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept + loaded_ams = _loaded_ams_indices(b) + bad = [e for e in mapping if e["ams_index"] not in loaded_ams] + assert not bad, f"entries reference an empty/non-loaded tray: {bad}" + + +def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set(): + """All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at + position 2 must not reference the empty tray idx 2.""" + b = _bridge() + loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] + loaded_ams = _loaded_ams_indices(b) + bad = [e for e in mapping if e["ams_index"] not in loaded_ams] + assert not bad, f"entries reference an empty/non-loaded tray: {bad}" + # Real loaded slots keep their own ams_index. + assert mapping[0]["ams_index"] == 0 + assert mapping[1]["ams_index"] == 1 + assert mapping[3]["ams_index"] == 3 + + +def test_all_full_is_unchanged(): + """All slots loaded -> no placeholders, identity mapping (the working case).""" + slots = [dict(s, status=5) for s in AMS_SLOTS] + b = _bridge(slots) + loaded = [(i, b._ams_slots[i]) for i in range(4)] + + mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded) + + assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3] diff --git a/tests/test_camera_cache_url_rotation.py b/tests/test_camera_cache_url_rotation.py new file mode 100644 index 0000000..8cb4caf --- /dev/null +++ b/tests/test_camera_cache_url_rotation.py @@ -0,0 +1,51 @@ +"""Camera stream hangs forever after printer reboot (Issue #99). + +The printer rotates its stream token on reboot, changing the camera URL. +CameraCache.set_url() used to be a bare assignment - the running ffmpeg loops +never noticed since they only re-read self._url at the top of their outer +loop, which they never reach while permanently blocked reading stdout from +the now-silent, stale-token connection. set_url() must detect the change and +tear the loops down so the next ensure_running() respawns them against the +new URL. +""" +from kobrax_moonraker_bridge import CameraCache + + +def test_set_url_first_time_does_not_reset(): + """No prior URL - nothing stale to tear down.""" + c = CameraCache() + calls = [] + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert c._url == "http://printer/live/tokenA" + assert not calls + + +def test_set_url_same_value_does_not_reset(): + c = CameraCache() + calls = [] + c.set_url("http://printer/live/tokenA") + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert not calls + + +def test_set_url_changed_triggers_reset(): + """The actual bug scenario: token rotates after a printer reboot.""" + c = CameraCache() + calls = [] + c.set_url("http://printer/live/tokenA") + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenB") + assert c._url == "http://printer/live/tokenB" + assert calls == [True] + + +def test_set_url_empty_to_value_does_not_reset(): + """Startup case: no URL known yet, first status push sets it - nothing + running to tear down.""" + c = CameraCache() + calls = [] + c.reset = lambda: calls.append(True) + c.set_url("http://printer/live/tokenA") + assert not calls diff --git a/tests/test_filament_profiles_per_printer.py b/tests/test_filament_profiles_per_printer.py new file mode 100644 index 0000000..c014576 --- /dev/null +++ b/tests/test_filament_profiles_per_printer.py @@ -0,0 +1,67 @@ +"""Per-printer filament-profile isolation (config_loader). + +Regression test for the multi-printer bug (issue #74): the slot->profile mapping +and ``visible_vendors`` lived in a single global ``[filament_profiles]`` section, +so configuring one printer overwrote the other and after a restart both loaded +the same map. Each printer now uses its own ``[filament_profiles_]`` section, +with a read-fallback to the legacy global section for backward compatibility. +""" +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root +import config_loader # noqa: E402 + +BASE_INI = ( + "[printer_1]\nname = K1\n\n" + "[printer_2]\nname = K2\n\n" + "[filament_profiles]\n" + "visible_vendors = Anycubic, SUNLU\n" + "slot_0_vendor = Anycubic\nslot_0_name = Anycubic PLA+\nslot_0_id = GFPLA+\n" +) + + +def _use_ini(monkeypatch, tmp_path, text=BASE_INI): + path = tmp_path / "config.ini" + path.write_text(text, encoding="utf-8") + monkeypatch.setattr(config_loader, "_find_config_file", lambda: path) + return path + + +def test_legacy_global_still_works(tmp_path, monkeypatch): + """No printer_id -> original global section (single-printer back-compat).""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+" + assert config_loader.list_visible_vendors() == ["Anycubic", "SUNLU"] + + +def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch): + """Before any per-printer save, both printers see the global mapping.""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+" + assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+" + + +def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch): + """Core regression: configuring printer 1 must not change printer 2.""" + _use_ini(monkeypatch, tmp_path) + config_loader.save_filament_profiles( + {0: {"vendor": "KINGROON", "name": "KINGROON PLA Basic", "id": "Pc0b8a01"}}, "1") + assert config_loader.list_filament_profiles("1")[0]["name"] == "KINGROON PLA Basic" + assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+" + # legacy global section preserved untouched + assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+" + + +def test_visible_vendors_isolated_per_printer(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path) + config_loader.save_visible_vendors(["KINGROON"], "1") + assert config_loader.list_visible_vendors("1") == ["KINGROON"] + assert config_loader.list_visible_vendors("2") == ["Anycubic", "SUNLU"] + + +def test_save_visible_vendors_keeps_slot_fallback(tmp_path, monkeypatch): + """Creating a per-printer section only for vendors must not orphan slots.""" + _use_ini(monkeypatch, tmp_path) + config_loader.save_visible_vendors(["KINGROON"], "1") + assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+" 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_metadata_and_print_state_reset.py b/tests/test_metadata_and_print_state_reset.py new file mode 100644 index 0000000..7a9b70d --- /dev/null +++ b/tests/test_metadata_and_print_state_reset.py @@ -0,0 +1,151 @@ +"""server/files/metadata state-leak + terminal-state reset gaps (Issue #102). + +Reported by @fmontagna via moonraker-obico: +1. Querying metadata for a file OTHER than the currently/last tracked job + leaked that job's live layer count / estimated time into the response, + because _build_file_metadata() read from self._state first and only fell + back to the file's own GCodeStore row when the state value was falsy. +2. curr_layer/total_layers (and, for a successful "finished" print, every + other per-job field) were never reset at print end - they stayed at the + last job's values until the next print happened to overwrite them. +3. The printer reports its own "progress" during pre-print phases + (preheating/auto_leveling/checking/...), which used to pass straight + through to display_status.progress/virtual_sdcard.progress and then jump + non-monotonically once real printing started and progress reset. +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + + +def _bridge(): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxmeta-"), + ) + return KobraXBridge(c, args=args) + + +def _insert_store_row(b, filename, layer_count=None, est_time=None, size_bytes=0): + with b._store._lock: + b._store._conn.execute( + "INSERT OR REPLACE INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) " + "VALUES (?,?,?,?,?,?,?)", + (filename, filename, "/tmp/" + filename, size_bytes, "2026-01-01T00:00:00Z", layer_count, est_time), + ) + b._store._conn.commit() + + +# --- Fix 1: metadata state-leak ------------------------------------------- + +def test_metadata_for_untracked_file_does_not_leak_running_job_state(): + b = _bridge() + # A job is "running": live state has its own layer/time values. + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 999 + b._state["slicer_time"] = 12345 + b._state["layer_height"] = 0.3 + + # Querying a DIFFERENT, unrelated file must use ITS OWN store row, not + # the running job's live state. + _insert_store_row(b, "other.gcode", layer_count=42, est_time=600, size_bytes=1000) + meta = b._build_file_metadata("other.gcode") + assert meta["layer_count"] == 42 + assert meta["estimated_time"] == 600 + assert meta["size"] == 1000 + + +def test_metadata_for_tracked_file_still_uses_live_state(): + """The currently-tracked file's OWN metadata query should still prefer + live state (fresher than what was known at upload time).""" + b = _bridge() + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 55 + b._state["slicer_time"] = 999 + _insert_store_row(b, "running.gcode", layer_count=1, est_time=1) + meta = b._build_file_metadata("running.gcode") + assert meta["layer_count"] == 55 + assert meta["estimated_time"] == 999 + + +def test_metadata_for_nonexistent_file_falls_back_cleanly(): + b = _bridge() + b._state["filename"] = "running.gcode" + b._state["total_layers"] = 999 + meta = b._build_file_metadata("DOES_NOT_EXIST.gcode") + assert meta["layer_count"] is None + assert meta["size"] == 1 # documented fallback, unrelated to this fix + + +# --- Fix 2: terminal-state reset ------------------------------------------- + +def _print_payload(state, **extra): + """print/report envelope: `state` is top-level, everything else is under + `data` (see _on_print: kobra_state = payload.get("state", "")). """ + d = {"filename": "job.gcode"} + d.update(extra) + return {"state": state, "data": d} + + +def test_finished_resets_layer_fields_like_stoped_canceled(): + b = _bridge() + b._state["curr_layer"] = 10 + b._state["total_layers"] = 20 + b._state["progress"] = 0.5 + b._state["filename"] = "job.gcode" + b._on_print(_print_payload("finished")) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + assert b._state["progress"] == 0.0 + assert b._state["filename"] == "" + + +def test_canceled_resets_layer_fields(): + b = _bridge() + b._state["curr_layer"] = 7 + b._state["total_layers"] = 20 + b._on_print(_print_payload("canceled")) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + + +def test_on_info_resets_layer_fields_on_terminal_state(): + b = _bridge() + b._state["curr_layer"] = 7 + b._state["total_layers"] = 20 + b._on_info({"data": {"project": {"state": "finished"}}}) + assert b._state["curr_layer"] == 0 + assert b._state["total_layers"] == 0 + + +# --- Fix 3: progress clamping during pre-print phases ---------------------- + +def test_progress_not_updated_during_auto_leveling(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_print(_print_payload("auto_leveling", progress=87)) + assert b._state["progress"] == 0.0 + + +def test_progress_not_updated_during_preheating(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_print(_print_payload("preheating", progress=42)) + assert b._state["progress"] == 0.0 + + +def test_progress_updates_normally_once_printing(): + b = _bridge() + b._on_print(_print_payload("printing", progress=33)) + assert b._state["progress"] == 0.33 + + +def test_on_info_progress_clamped_during_checking(): + b = _bridge() + b._state["progress"] = 0.0 + b._on_info({"data": {"project": {"state": "checking", "progress": 55}}}) + assert b._state["progress"] == 0.0 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_multi_ace_slots.py b/tests/test_multi_ace_slots.py new file mode 100644 index 0000000..fcba238 --- /dev/null +++ b/tests/test_multi_ace_slots.py @@ -0,0 +1,117 @@ +"""Multi-ACE aggregation in ace_direct mode (Issue #95, Kobra S1). + +A Kobra S1 with two daisy-chained ACE Pro units reports +multi_color_box = [{id:0, slots:[4]}, {id:1, slots:[4]}] with NO toolhead +entry (id:-1). The old ace_direct branch kept only ace_boxes[0], silently +dropping the second unit — the dashboard and the OrcaSlicer sync only ever +saw 4 of the 8 slots. Payloads below are trimmed from the real log attached +to the issue. +""" +from kobrax_moonraker_bridge import KobraXBridge + + +def _slot(index, type_="PLA", color=(1, 2, 3), status=5): + return { + "index": index, "sku": "", "type": type_, "color": list(color), + "edit_status": 0, "status": status, + "color_group": [list(color) + [255]], "icon_type": 0, + "consumables_percent": 50, + } + + +def _ace_box(box_id, loaded_slot=-1, n_slots=4): + return { + "id": box_id, "status": 1, "model_id": 0, "auto_feed": 1, + "loaded_slot": loaded_slot, + "feed_status": {"code": 200, "type": -1, "current_status": -1, "slot_index": -1}, + "temp": 30, "humidity": 0, + "drying_status": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0}, + "slots": [_slot(i) for i in range(n_slots)], + } + + +def _toolhead_box(n_slots=4): + box = _ace_box(-1, n_slots=n_slots) + return box + + +# ── mode detection ─────────────────────────────────────────────────────────── + +def test_two_ace_units_no_toolhead_is_ace_direct(): + boxes = [_ace_box(0), _ace_box(1)] + assert KobraXBridge._detect_filament_mode(boxes) == "ace_direct" + + +# ── ace_direct aggregation ─────────────────────────────────────────────────── + +def test_single_ace_unit_yields_4_slots(): + """Kobra X regression: one unit, global indices 0-3 exactly as before.""" + slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0)], "ace_direct") + assert len(slots) == 4 + assert [s["global_index"] for s in slots] == [0, 1, 2, 3] + assert all(s["box_id"] == 0 for s in slots) + assert loaded == -1 + + +def test_two_ace_units_yield_8_slots(): + """Issue #95: the second unit's slots must appear as global 4-7.""" + slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct") + assert len(slots) == 8 + assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7] + assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1] + + +def test_two_ace_units_report_order_does_not_matter(): + """Boxes sorted by id — global numbering stays stable if the firmware + reports unit 1 before unit 0.""" + slots, _ = KobraXBridge._aggregate_slots([_ace_box(1), _ace_box(0)], "ace_direct") + assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7] + assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1] + + +def test_loaded_slot_on_second_unit_maps_to_global(): + slots, loaded = KobraXBridge._aggregate_slots( + [_ace_box(0), _ace_box(1, loaded_slot=2)], "ace_direct") + assert loaded == 6 # 1*4 + 2 + + +def test_loaded_slot_on_first_unit_unchanged(): + slots, loaded = KobraXBridge._aggregate_slots( + [_ace_box(0, loaded_slot=3), _ace_box(1)], "ace_direct") + assert loaded == 3 + + +# ── ace_hub regression (unchanged behavior) ────────────────────────────────── + +def test_ace_hub_numbering_unchanged(): + boxes = [_toolhead_box(), _ace_box(0), _ace_box(1)] + assert KobraXBridge._detect_filament_mode(boxes) == "ace_hub" + slots, _ = KobraXBridge._aggregate_slots(boxes, "ace_hub") + # 3 toolhead + 4 + 4 ACE + assert len(slots) == 11 + assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + assert [s["box_id"] for s in slots][:3] == [-1, -1, -1] + + +# ── _box_local_to_global / _global_to_box_slot round-trip ──────────────────── + +def _bridge_with_mode(mode, slots): + b = object.__new__(KobraXBridge) + b._filament_mode = mode + b._ams_slots = slots + return b + + +def test_box_local_to_global_ace_direct_second_unit(): + b = _bridge_with_mode("ace_direct", []) + assert b._box_local_to_global(0, 2, []) == 2 + assert b._box_local_to_global(1, 2, []) == 6 + + +def test_global_to_box_slot_round_trip_two_units(): + slots, _ = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct") + b = _bridge_with_mode("ace_direct", slots) + for g in range(8): + box_id, local = b._global_to_box_slot(g) + assert (box_id, local) == (g // 4, g % 4) + assert b._box_local_to_global(box_id, local, []) == g diff --git a/tests/test_multicolor_box_failed_state.py b/tests/test_multicolor_box_failed_state.py new file mode 100644 index 0000000..3e3654e --- /dev/null +++ b/tests/test_multicolor_box_failed_state.py @@ -0,0 +1,81 @@ +"""AttributeError crash on multiColorBox/report failure (Issue #100). + +Real KX2-Pro bug: manually assigning a filament profile to an ACE slot +(custom-RFID / third-party filament) gets rejected by the printer. Instead of +echoing the normal success shape, the printer replies with `state: "failed"` +and `data` as a 2-element LIST (`["multi_color_box", [...]]`) instead of the +usual dict (`{"multi_color_box": [...]}`). `_on_multicolor_box` called +`data.get(...)` unconditionally, crashing with +`AttributeError: 'list' object has no attribute 'get'` and silently dropping +the report (including the slot-state update it would otherwise have done). +""" +import argparse +import tempfile +from unittest.mock import MagicMock + +from kobrax_moonraker_bridge import KobraXBridge + +# Exact failure payload from the Issue #100 log. +FAILED_PAYLOAD = { + "state": "failed", + "data": ["multi_color_box", [{"filaments": {"id": 2}, "id": 0}]], +} + +SUCCESS_PAYLOAD = { + "state": "success", + "data": { + "head_tools_model": 1, + "multi_color_box": [ + {"id": -1, "slots": []}, + { + "id": 0, + "slots": [ + {"index": 0, "type": "PLA", "color": [0, 156, 189], "status": 5}, + ], + }, + ], + }, +} + + +def _bridge(): + c = MagicMock(); c.callbacks = {}; c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxmcb-"), + ) + return KobraXBridge(c, args=args) + + +def test_failed_state_does_not_crash(): + b = _bridge() + b._on_multicolor_box(FAILED_PAYLOAD) # must not raise + assert b._state["last_ams_set_error"] is True + + +def test_success_after_failure_clears_error_flag(): + b = _bridge() + b._on_multicolor_box(FAILED_PAYLOAD) + assert b._state["last_ams_set_error"] is True + b._on_multicolor_box(SUCCESS_PAYLOAD) + assert b._state["last_ams_set_error"] is False + + +def test_non_dict_data_without_failed_state_does_not_crash(): + """Defensive guard: any future non-dict `data` shape must not crash, + even if the printer doesn't set state="failed" for it.""" + b = _bridge() + b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]}) + + +def test_failed_report_is_logged_with_the_triggering_request(caplog): + """The failure payload alone carries no slot/type/color info - the log + must correlate it with the setInfo request that triggered it, otherwise + the failure reason can't be diagnosed from bridge logs alone.""" + import logging + b = _bridge() + b._last_ams_set_request = {"global": 6, "box": 0, "local_slot": 3, "type": "PLA", "color": [33, 39, 33]} + with caplog.at_level(logging.WARNING): + b._on_multicolor_box(FAILED_PAYLOAD) + assert any("global" in r.message and "6" in r.message for r in caplog.records) diff --git a/tests/test_printer_files_endpoint.py b/tests/test_printer_files_endpoint.py new file mode 100644 index 0000000..6550f89 --- /dev/null +++ b/tests/test_printer_files_endpoint.py @@ -0,0 +1,233 @@ +""" +Tests für /kx/printer-files (list) und /kx/printer-files/delete — +der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt +(via MQTT file/listLocal + file/deleteBatch, live gegen den echten +Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md). + +Important: publish()'s own return value for these actions is just a +generic immediate ACK skeleton (code=0, empty fields) - the real answer +arrives asynchronously via the file/report callback (_on_file), same as +the existing fileDetails fire-and-forget pattern. So publish() itself +returns None/skeleton here, and the "real" response is delivered by +firing bridge._on_file(...) from a background thread, simulating what +the MQTT reader thread would do when the printer's file/report arrives. +""" +import threading +import time + +import pytest + + +LISTLOCAL_SUCCESS = { + "action": "listLocal", + "code": 200, + "state": "success", + "data": { + "list_mode": 0, + "records": [ + {"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000}, + {"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000}, + {"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000}, + ], + }, +} + +LISTLOCAL_FAILED = { + "action": "listLocal", + "code": 10112, + "state": "failed", + "data": None, +} + +DELETEBATCH_SUCCESS = { + "action": "deleteBatch", + "code": 200, + "state": "success", + "data": None, + "msg": "done", +} + +DELETEBATCH_FAILED = { + "action": "deleteBatch", + "code": 10112, + "state": "failed", + "data": None, +} + +FILEDETAILS_SUCCESS = { + "action": "fileDetails", + "code": 200, + "state": "done", + "data": { + "file_details": { + "thumbnail": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB", + "png_image": "", + "svg_image": "", + "objects_skip_parts": [], + }, + "filename": "a.gcode", + "root": "local", + }, +} + +FILEDETAILS_NO_THUMBNAIL = { + "action": "fileDetails", + "code": 200, + "state": "done", + "data": { + "file_details": {"thumbnail": "", "png_image": "", "svg_image": "", "objects_skip_parts": []}, + "filename": "a.gcode", + "root": "local", + }, +} + +FILEDETAILS_FAILED = { + "action": "fileDetails", + "code": 10112, + "state": "failed", + "data": None, +} + + +def _deliver_async(bridge, payload, delay=0.05): + """Simulates the MQTT reader thread delivering a file/report a moment + after the fire-and-forget publish() call returns.""" + def _fire(): + time.sleep(delay) + bridge._on_file(payload) + threading.Thread(target=_fire, daemon=True).start() + + +@pytest.mark.asyncio +async def test_printer_files_lists_files_and_excludes_dirs(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 200 + data = await resp.json() + filenames = [f["filename"] for f in data["result"]] + assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded + + +@pytest.mark.asyncio +async def test_printer_files_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + await c.get("/kx/printer-files") + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "listLocal" + assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"} + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_printer_failure(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_timeout(client): + c, bridge = client + bridge.client.publish.return_value = None # no file/report ever arrives + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_delete_success(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 200 + data = await resp.json() + assert data["result"] == "ok" + + +@pytest.mark.asyncio +async def test_printer_file_delete_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]}) + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "deleteBatch" + assert args[2] == { + "root": "local", + "files": [ + {"path": "/", "filename": "a.gcode"}, + {"path": "/", "filename": "b.gcode"}, + ], + } + + +@pytest.mark.asyncio +async def test_printer_file_delete_empty_filenames_returns_400(client): + c, _ = client + resp = await c.post("/kx/printer-files/delete", json={"filenames": []}) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_printer_file_delete_returns_502_on_printer_rejection(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_success(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 200 + data = await resp.json() + assert data["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + await c.get("/kx/printer-files/a.gcode/thumbnail") + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "fileDetails" + assert args[2] == {"root": "local", "filename": "a.gcode"} + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_empty_when_no_thumbnail_embedded(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_NO_THUMBNAIL), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 200 + data = await resp.json() + assert data["result"]["thumbnail"] == "" + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_returns_502_on_printer_failure(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_FAILED), None)[1] + resp = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_thumbnail_is_cached_after_first_fetch(client): + """Second request for the same filename must not call publish() again.""" + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1] + resp1 = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp1.status == 200 + call_count_after_first = bridge.client.publish.call_count + + resp2 = await c.get("/kx/printer-files/a.gcode/thumbnail") + assert resp2.status == 200 + data2 = await resp2.json() + assert data2["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + assert bridge.client.publish.call_count == call_count_after_first # no new MQTT call diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..f0f16e8 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,86 @@ +""" +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_config_ini(client): + """POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x).""" + c, bridge = client + + with tempfile.TemporaryDirectory() as tmpdir: + config_path = pathlib.Path(tmpdir) / "config.ini" + bridge._find_config_path = lambda: config_path + bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process + + 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 = config_path.read_text() + assert "printer_ip = 10.0.0.5" in content + assert "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 Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server).""" + c, bridge = client + + with tempfile.TemporaryDirectory() as tmpdir: + config_path = pathlib.Path(tmpdir) / "config.ini" + config_path.write_text( + "[spoolman]\nserver = http://192.168.1.50:7912\n\n" + "[connection]\nprinter_ip = old\n" + ) + bridge._find_config_path = lambda: config_path + bridge._restart_bridge = lambda: None + + await c.post("/api/settings", json={"printer_ip": "10.0.0.99"}) + + content = config_path.read_text() + assert "server = http://192.168.1.50:7912" in content + assert "printer_ip = 10.0.0.99" in content diff --git a/tests/test_slot_profile_material_guard.py b/tests/test_slot_profile_material_guard.py new file mode 100644 index 0000000..c5b5d88 --- /dev/null +++ b/tests/test_slot_profile_material_guard.py @@ -0,0 +1,157 @@ +"""Stale slot-profile guard: suppress a saved per-slot filament profile when the +AMS now reports a *different material family* than the profile was assigned for. + +Real-world bug (KX1): slot 1 held a PETG spool and got the profile +"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the +colour (live) but the saved profile stuck on PETG, so the panel + the slicer +hint kept showing/using PETG. Restarting did not help — the override lives in +config.ini. + +Fix = non-destructive suppression (Option A): resolve the effective profile as +"the saved override only if its material *family* matches the current AMS +material; otherwise none (fall back to the generic default)". The override is +never deleted, so putting the original material back reactivates it. + +Comparison must be by *family*, never strict string equality — PLA / PLA+ / +PLA SILK / PLA MATTE are the same family and must NOT invalidate each other +(regression guard for the earlier over-strict material compare). +""" +import argparse +import json +import tempfile +from unittest.mock import MagicMock + +# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader. +from kobrax_moonraker_bridge import KobraXBridge + +# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type). +LIBRARY = [ + {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"}, + {"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"}, + {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"}, +] + +PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"} +SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"} +UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"} + + +def _bridge(): + c = MagicMock() + c.callbacks = {} + c.connected = False + args = argparse.Namespace( + printer_ip="", mqtt_port=9883, username="", password="", + mode_id="20030", device_id="", host="127.0.0.1", port=7125, + data_dir=tempfile.mkdtemp(prefix="kxguard-"), + ) + b = KobraXBridge(c, args=args) + b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is + return b + + +# ── _material_family ────────────────────────────────────────────────────────── + +def test_material_family_collapses_pla_variants(): + fam = KobraXBridge._material_family + assert fam("PLA") == "PLA" + assert fam("PLA+") == "PLA" + assert fam("PLA SILK") == "PLA" + assert fam("PLA MATTE") == "PLA" + assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction + + +def test_material_family_collapses_petg_variants(): + fam = KobraXBridge._material_family + assert fam("PETG") == "PETG" + assert fam("PETG+") == "PETG" + + +def test_material_family_distinguishes_pla_from_petg(): + fam = KobraXBridge._material_family + assert fam("PLA") != fam("PETG") + assert fam("PLA SILK") != fam("PETG") + + +def test_material_family_empty_for_empty_input(): + assert KobraXBridge._material_family("") == "" + assert KobraXBridge._material_family(None) == "" + + +# ── _effective_slot_profile ─────────────────────────────────────────────────── + +def test_suppressed_when_family_changes_petg_profile_pla_loaded(): + """The exact KX1 bug: PETG profile, AMS now reports PLA → suppress.""" + b = _bridge() + b._filament_profiles = {0: dict(PETG_PROFILE)} + assert b._effective_slot_profile(0, "PLA") == {} + + +def test_kept_when_family_matches_petg_profile_petg_loaded(): + b = _bridge() + b._filament_profiles = {0: dict(PETG_PROFILE)} + assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE + + +def test_kept_for_pla_variant_no_false_positive(): + """PLA+ profile with a PLA SILK spool loaded is the same family → keep.""" + b = _bridge() + b._filament_profiles = {1: dict(SILK_PROFILE)} + assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE + + +def test_kept_when_profile_material_unknown_failsafe(): + """If the profile is not in the library we cannot know its family → never + suppress on uncertainty (fail-safe keeps the user's choice).""" + b = _bridge() + b._filament_profiles = {2: dict(UNKNOWN_PROFILE)} + assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE + + +def test_empty_when_no_override(): + b = _bridge() + b._filament_profiles = {} + assert b._effective_slot_profile(3, "PLA") == {} + + +# ── Integration: display endpoint (the visible panel) ───────────────────────── + +async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded(): + """/kx/filament/slots must not show the stale PETG identity once PLA loads.""" + b = _bridge() + b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0] + assert row["material"] == "PLA" # AMS truth, always + assert row["filament_name"] == "" # stale PETG identity gone + assert row["filament_vendor"] == "" + + +async def test_display_endpoint_keeps_profile_when_family_matches(): + b = _bridge() + b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0] + assert row["filament_name"] == "KINGROON PETG Basic" + assert row["filament_vendor"] == "KINGROON" + + +# ── Integration: print path (lane_data sent to OrcaSlicer) ──────────────────── + +async def test_lane_data_does_not_leak_stale_petg_identity(): + b = _bridge() + b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + tray = b._build_lane_data()["ams"][0]["tray"][0] + assert tray["tray_type"] == "PLA" + assert "PETG" not in tray["name"].upper() + assert tray["vendor_name"] != "KINGROON" + + +async def test_lane_data_keeps_profile_when_family_matches(): + b = _bridge() + b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}] + b._filament_profiles = {0: dict(PETG_PROFILE)} + tray = b._build_lane_data()["ams"][0]["tray"][0] + assert tray["name"] == "KINGROON PETG Basic" + assert tray["vendor_name"] == "KINGROON" diff --git a/tests/test_spoolman_slot_map.py b/tests/test_spoolman_slot_map.py new file mode 100644 index 0000000..c744694 --- /dev/null +++ b/tests/test_spoolman_slot_map.py @@ -0,0 +1,100 @@ +"""Per-printer Spoolman slot-map isolation + persistence (config_loader). + +Regression test for two bugs in the Spoolman slot→spool persistence: + + 1. The bridge referenced ``config_loader`` while the module alias is + ``env_loader`` → ``NameError`` swallowed by a bare ``except``, so the map + was never loaded nor saved (persistence looked implemented but was dead). + 2. The map lived in a single global ``[spoolman] slot_spools`` key, so two + printers/two AMS units overwrote each other (same class as issue #74/#75). + +Each printer now uses its own ``[spoolman_]`` section, with a read-fallback +to the legacy global key for backward compatibility. The global ``[spoolman]`` +section keeps ``server`` / ``sync_rate``. +""" +import sys +import pathlib +import configparser + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root +import config_loader # noqa: E402 + +BASE_INI = ( + "[printer_1]\nname = K1\n\n" + "[printer_2]\nname = K2\n\n" + "[spoolman]\n" + "server = http://192.168.3.200:7912\n" + "sync_rate = 0\n" + "slot_spools = 0:1,1:2\n" +) + + +def _use_ini(monkeypatch, tmp_path, text=BASE_INI): + path = tmp_path / "config.ini" + path.write_text(text, encoding="utf-8") + monkeypatch.setattr(config_loader, "_find_config_file", lambda: path) + return path + + +def test_legacy_global_read(tmp_path, monkeypatch): + """No printer_id -> original global [spoolman] slot_spools (back-compat).""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_spool_map() == {0: 1, 1: 2} + + +def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch): + """Before any per-printer save, both printers see the global mapping.""" + _use_ini(monkeypatch, tmp_path) + assert config_loader.list_spool_map("1") == {0: 1, 1: 2} + assert config_loader.list_spool_map("2") == {0: 1, 1: 2} + + +def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch): + """Core regression: mapping printer 1 must not change printer 2.""" + _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42, 1: 17}, "1") + assert config_loader.list_spool_map("1") == {0: 42, 1: 17} + # printer 2 has no own section yet -> still the global fallback + assert config_loader.list_spool_map("2") == {0: 1, 1: 2} + # legacy global key preserved untouched + assert config_loader.list_spool_map() == {0: 1, 1: 2} + + +def test_both_printers_isolated_after_each_saves(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42, 1: 17}, "1") + config_loader.save_spool_map({0: 5, 1: 6}, "2") + assert config_loader.list_spool_map("1") == {0: 42, 1: 17} + assert config_loader.list_spool_map("2") == {0: 5, 1: 6} + + +def test_save_preserves_server_and_sync_rate(tmp_path, monkeypatch): + """Writing a per-printer map must not clobber [spoolman] server/sync_rate.""" + path = _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42}, "1") + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + assert cfg.get("spoolman", "server") == "http://192.168.3.200:7912" + assert cfg.get("spoolman", "sync_rate") == "0" + assert cfg.get("spoolman_1", "slot_spools") == "0:42" + + +def test_persistence_round_trips(tmp_path, monkeypatch): + """Save then read back (simulates a bridge restart) — the map survives.""" + _use_ini(monkeypatch, tmp_path, text="[spoolman]\nserver = http://x:7912\n") + config_loader.save_spool_map({0: 7, 2: 9}, "1") + assert config_loader.list_spool_map("1") == {0: 7, 2: 9} + + +def test_empty_map_clears_the_key(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path) + config_loader.save_spool_map({0: 42}, "1") + config_loader.save_spool_map({}, "1") # clear + # per-printer key gone -> falls back to the legacy global map + assert config_loader.list_spool_map("1") == {0: 1, 1: 2} + + +def test_parse_ignores_malformed_and_nonpositive(tmp_path, monkeypatch): + _use_ini(monkeypatch, tmp_path, + text="[spoolman]\nslot_spools = 0:1, x:y, 2:0, 3:-4, 4:5, junk\n") + assert config_loader.list_spool_map() == {0: 1, 4: 5} diff --git a/web/themes/default/app.js b/web/themes/default/app.js index fb20486..aa8d591 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -1,11 +1,18 @@ // ── State ── var S={nozzle_temp:0,nozzle_target:0,bed_temp:0,bed_target:0, print_state:'standby',filename:'',progress:0,print_duration:0,remain_time:0, - curr_layer:0,total_layers:0,printer_name:'Kobra X',firmware_version:'–', + curr_layer:0,total_layers:0,z_mm:0,printer_name:'Kobra X',firmware_version:'–', camera_url:'',fan_speed:0,print_speed_mode:2,light_on:false,light_brightness:80, ams_slots:[],filament_mode:'toolhead',ace_units:[],ace_dry_presets:null,ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,current_temp:null,units:[]},web_upload_warning:1}; var tempHistory={n:[],b:[]}; var camOn=false; +var camUserStopped=false; // user stopped camera manually — suppress auto-restart for this print +var _camPollInterval=null; // snapshot-polling interval for Android (no MJPEG support) +var _lastLoadedFile=null; // zuletzt geladene/gedruckte Datei für Progress-Karten-Aktionen (Issue #55) +var _idleCleared=false; // User hat idle-Datei explizit „geleert" → kein Nachladen von s.filename (Issue #57) +var _fdDialogOpen=false; // Dialog ist gerade offen +var _fdAutoOpenedFile=sessionStorage.getItem('fdAutoOpenedFile')||null; +var _fdUserCancelled=sessionStorage.getItem('fdUserCancelled')==='1'; var currentStep=1; var currentPanel='dashboard'; var aceAutoRefillPrefs=(function(){ @@ -37,6 +44,78 @@ var ACE_DRY_PRESETS={ custom_3:{name:'Custom 3',temp:45,duration_sec:4*3600} }; +// Spoolman state +var _spoolmanStatus={configured:false,reachable:false,server:'',sync_rate:0,slot_spools:{}}; +var _spoolmanSpools=[]; +var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment + +function _loadSpoolmanStatus(){ + fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){ + _spoolmanStatus=d; + _slotSpoolMap=d.slot_spools||{}; + _updateSpoolmanStatusDot(); + _buildSpoolmanSection(); + renderSpoolmanSlotCard(); + if(d.configured){ + fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(sd){ + _spoolmanSpools=sd.spools||[]; + }); + } + }).catch(function(){}); +} +function _updateSpoolmanStatusDot(){ + var dot=document.getElementById('spoolman-status-dot'); + var lbl=document.getElementById('spoolman-status-lbl'); + if(!dot||!lbl)return; + if(!_spoolmanStatus.configured){ + dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert'; + } else if(_spoolmanStatus.reachable){ + dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden'; + } else { + dot.style.color='var(--err)';lbl.textContent=(_spoolmanStatus.server||'')+' (nicht erreichbar)'; + } +} + +function _buildSpoolmanSection(){ + var sec=document.getElementById('fd-spoolman-section'); + var rows=document.getElementById('fd-spoolman-rows'); + var loading=document.getElementById('fd-spoolman-loading'); + if(!sec||!rows)return; + if(!_spoolmanStatus.configured){sec.style.display='none';return;} + sec.style.display=''; + rows.innerHTML=''; + if(loading)loading.style.display=''; + + var usedSlots={}; + (_amsSlots||[]).forEach(function(slot){ + usedSlots[slot.slot_index]=slot; + }); + + fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){ + if(loading)loading.style.display='none'; + _spoolmanSpools=d.spools||[]; + var slotKeys=Object.keys(usedSlots).map(Number).sort(function(a,b){return a-b;}); + if(!slotKeys.length){rows.innerHTML='';return;} + rows.innerHTML=slotKeys.map(function(idx){ + var slot=usedSlots[idx]; + var col=(slot.color_hex||'#888'); + var currentSpool=_slotSpoolMap[String(idx)]||''; + var opts=''+_spoolmanSpools.map(function(sp){ + var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':''; + var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':''; + var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool'; + return ''; + }).join(''); + return '
'+ + ''+ + 'Slot '+(idx+1)+''+ + '
'; + }).join(''); + }).catch(function(){if(loading)loading.style.display='none';}); +} + function _aceAutoRefillGet(aceId){return !!aceAutoRefillPrefs[String(aceId)];} function _aceAutoRefillSet(aceId,on){ aceAutoRefillPrefs[String(aceId)]=!!on; @@ -101,6 +180,8 @@ function tr(key,fallback){ function _langToggleLabel(lang){ if(lang==='de')return 'Deutsch'; if(lang==='en')return 'English'; + if(lang==='fr')return 'Français'; + if(lang==='it')return 'Italiano'; if(lang==='zh-cn')return '简体中文'; return 'Espanol'; } @@ -108,10 +189,10 @@ function _langToggleLabel(lang){ function _mapSupportedLang(lang){ if(!lang)return ''; var l=String(lang).toLowerCase().replace(/_/g,'-').trim(); - if(l==='de'||l==='en'||l==='es'||l==='fr'||l==='zh-cn')return l; + if(l==='de'||l==='en'||l==='es'||l==='fr'||l==='it'||l==='zh-cn')return l; var base=l.split('-')[0]; - if(base==='de'||base==='en'||base==='es'||base==='fr')return base; + if(base==='de'||base==='en'||base==='es'||base==='fr'||base==='it')return base; if(base==='zh'){ if(l.indexOf('cn')>=0||l.indexOf('hans')>=0||l==='zh')return 'zh-cn'; @@ -164,6 +245,8 @@ async function setLanguage(lang){ localStorage.setItem('lang',l); var langSel=document.getElementById('lang-select'); if(langSel)langSel.value=l; + var sLangSel=document.getElementById('s-lang-select'); + if(sLangSel)sLangSel.value=l; document.documentElement.setAttribute('lang',l); applyLang(); } @@ -211,7 +294,7 @@ function renderPrinterDropdown(){ menu.innerHTML=_printers.map(function(p){ var active=_activePrinter&&String(p.id)===String(_activePrinter.id); var num=p.id; - return ''+ + return ''+ (active?'▶ ':'')+p.name+''; }).join(''); } @@ -250,7 +333,7 @@ function applyLang(){ setText('skip-title',T.skip_title); setText('skip-hint',T.skip_hint); setText('d-btn-skip-label',T.skip_btn_label); - setText('fd-objects-hint',T.fd_objects_hint); + setText('fd-objects-toggle-lbl',T.fd_objects_toggle); setText('apd-lbl-ip',T.apd_lbl_ip); setText('apd-lbl-name',T.apd_lbl_name); var apn=document.getElementById('apd-name');if(apn)apn.setAttribute('placeholder',T.apd_placeholder_name); @@ -271,6 +354,14 @@ function applyLang(){ setText('store-web-verify-msg',T.store_web_verify_msg); setText('store-web-verify-confirm',T.store_web_verify_confirm); setText('store-web-verify-abort',T.store_web_verify_abort); + setText('store-lbl-select-all',T.store_select_all||'Select All'); + setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); + setText('store-lbl-exit-select',T.store_exit_select||'Cancel'); + setText('btab-lbl-uploaded',T.browser_tab_uploaded||'Uploaded'); + setText('btab-lbl-printer',T.browser_tab_printer||'On Printer'); + setText('printer-store-lbl-select-all',T.store_select_all||'Select All'); + setText('printer-store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); + setText('printer-store-lbl-exit-select',T.store_exit_select||'Cancel'); // Dashboard card titles setText('d-card-progress',T.card_progress); setText('d-card-temps',T.card_temps); @@ -282,6 +373,7 @@ function applyLang(){ setText('d-lbl-remain',T.lbl_remaining); setText('d-slicer-label',T.lbl_slicer_time); setText('d-lbl-layers',T.lbl_layers); + setText('d-lbl-zpos',T.lbl_zpos); setText('d-lbl-light',T.lbl_light); setText('d-lbl-nozzle',T.label_nozzle); setText('d-lbl-bed',T.label_bed); @@ -297,21 +389,48 @@ function applyLang(){ setText('d-chart-label',T.panel_temps_chart); // Axis labels setText('ptitle-motion-xy',T.panel_motion_xy); - setText('ptitle-motion-z',T.panel_motion_z); document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z); document.querySelectorAll('.lbl-home-xy').forEach(e=>e.textContent=T.btn_home_xy); document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all); document.querySelectorAll('.lbl-disable-motors').forEach(e=>e.textContent=T.btn_disable_motors); - document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step); document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':',''))); // Console setText('ptitle-console',T.panel_console_title); - // Settings modal - setText('modal-title-settings',T.settings_title); + // Settings-Panel setText('modal-sec-connection',T.settings_connection); setText('modal-sec-print',T.settings_print); - setText('modal-sec-poll',T.settings_poll); setText('modal-sec-version',T.settings_version); + // Nav + Kategorie-Labels (mit Fallback falls i18n-Key noch fehlt) + setText('nav-settings',T.nav_settings||'Einstellungen'); + setText('setcat-lbl-connection',T.settings_connection||'Verbindung'); + setText('setcat-lbl-printer',T.settings_print||'Drucker'); + setText('setcat-lbl-display',T.settings_cat_display||'Darstellung'); + setText('setcat-lbl-display2',T.settings_cat_display||'Darstellung'); + setText('setcat-lbl-filament',T.settings_cat_filament||'Filament'); + setText('setcat-lbl-integrations',T.settings_integrations||'Integrationen'); + setText('modal-sec-spoolman',T.modal_sec_spoolman||'Spoolman'); + setText('lbl-spoolman-url',T.lbl_spoolman_url||'Server-URL'); + setText('lbl-spoolman-sync-rate',T.lbl_spoolman_sync_rate||'Sync-Rate (s, 0=aus)'); + setText('modal-sec-obico',T.modal_sec_obico||'Obico'); + setText('setcat-lbl-system',T.settings_version||'System'); + setText('lbl-set-lang',T.settings_cat_language||'Sprache'); + setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten'); + setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)'); + setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)'); + var dashLbl=document.getElementById('dash-lbl-edit'); + if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard'); + setText('dash-lbl-reset',T.dash_reset||'Reset'); + setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset'); + var dashPresetStd=document.getElementById('dash-preset-standard'); + if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard'; + var dashPresetWide=document.getElementById('dash-preset-wide89'); + if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop'; + if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar(); + setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)'); + setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern'); + setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)'); + setText('visible-vendors-hint',T.settings_visible_vendors_hint||'Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.'); + setText('lbl-visible-vendors-save',T.settings_visible_vendors_save||'Auswahl speichern'); // Custom-Profile-Import (Issue #41) setText('modal-sec-orca-profiles',T.orca_profile_section); setText('orca-profiles-hint',T.orca_profile_hint); @@ -334,11 +453,21 @@ function applyLang(){ setText('lbl-default-slot',T.settings_default_slot); setText('opt-slot-auto',T.settings_slot_auto); setText('lbl-auto-leveling',T.settings_auto_leveling); + setText('lbl-vibration-compensation',T.settings_vibration_compensation); + setText('lbl-file-ready-mode',T.settings_file_ready_mode); + setText('opt-file-ready-dialog',T.settings_file_ready_dialog); + setText('opt-file-ready-banner',T.settings_file_ready_banner); setText('lbl-camera-on-print',T.settings_camera_on_print); setText('lbl-web-upload-warning',T.settings_web_upload_warning); + setText('fd-options-title',T.fd_options_title); + setText('fd-lbl-auto-leveling',T.print_auto_leveling); setText('lbl-update-check',T.update_check); setText('lbl-update-apply',T.update_apply); + // Progress-Karten-Aktionen für geladene/idle Datei (Issue #55) + setText('d-idle-print-lbl',T.progress_action_print||'Drucken'); + setText('d-idle-slots-lbl',T.progress_action_slots||'Slots zuordnen'); + setText('d-idle-clear-lbl',T.progress_action_clear||'Leeren'); // Speed buttons setText('d-spd-lbl-1',T.speed_silent.replace(/^\S+\s/,'')); setText('d-spd-lbl-2',T.speed_normal.replace(/^\S+\s/,'')); @@ -443,6 +572,7 @@ function ensureAceDryCards(){ // defer until DOM ready window.addEventListener('DOMContentLoaded',function(){ setLanguage(currentLang).catch(function(){}); + _loadSpoolmanStatus(); // Kein Drucker konfiguriert? → direkt in den Drucker-Tab (zeigt "+ Drucker hinzufügen") fetch('/kx/printers').then(function(r){return r.json()}).then(function(d){ if(!d.result||!d.result.length){showPanel('printers');loadPrinterTab();} @@ -458,6 +588,26 @@ function showPanel(id){ var nb=document.getElementById('nb-'+id);if(nb)nb.classList.add('active'); var bnb=document.getElementById('bnb-'+id);if(bnb)bnb.classList.add('active'); currentPanel=id; + if(id==='settings')openSettings(); +} + +// Settings-Kategorie umschalten (Master-Detail) +function showSettingsCat(name){ + document.querySelectorAll('.set-group').forEach(g=>g.classList.remove('active')); + document.querySelectorAll('.set-cat').forEach(b=>b.classList.remove('active')); + var g=document.getElementById('setgrp-'+name);if(g)g.classList.add('active'); + var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active'); +} + +// Browser-Sub-Tab umschalten: hochgeladene Dateien (Bridge-Store) vs. Dateien +// auf dem Drucker selbst (interner Speicher, via listLocal MQTT). +var _printerFilesLoaded=false; +function showBrowserTab(name){ + document.querySelectorAll('.browser-group').forEach(g=>g.classList.remove('active')); + document.querySelectorAll('.browser-tab').forEach(b=>b.classList.remove('active')); + var g=document.getElementById('browser-group-'+name);if(g)g.classList.add('active'); + var t=document.getElementById('btab-'+name);if(t)t.classList.add('active'); + if(name==='printer'&&!_printerFilesLoaded)loadPrinterFiles(); } // ── Console log ── @@ -615,11 +765,40 @@ function applyState(){ // connection error banner – nur wenn überhaupt ein Drucker konfiguriert ist var banner=document.getElementById('conn-error-banner'); if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}} + var pauseBanner=document.getElementById('pause-msg-banner'); + if(pauseBanner){ + if(s.pause_msg && s.print_state==='paused'){ + var codePart = (s.error_code==0) ? ' ' : + ' ['+s.error_code+'] '; + pauseBanner.innerHTML='⏸ '+tr('lbl_pause_reason')+codePart+s.pause_msg; + pauseBanner.style.display='block'; + }else{ + pauseBanner.style.display='none'; + } + } + var bannerVisible=false; var frb=document.getElementById('file-ready-banner'); if(frb){ + var shouldAutoOpen=(s.print_start_dialog===undefined?true:!!s.print_start_dialog); if(s.file_ready&&s.print_state==='standby'){ document.getElementById('file-ready-name').textContent=s.file_ready; - frb.style.display='flex'; + // Neue Datei → Abbruch-Sperre aufheben + if(_fdAutoOpenedFile&&_fdAutoOpenedFile!==s.file_ready){ + _fdUserCancelled=false; + sessionStorage.removeItem('fdUserCancelled'); + sessionStorage.removeItem('fdAutoOpenedFile'); + } + if(shouldAutoOpen){ + // Dialog-Modus: Banner niemals anzeigen. + frb.style.display='none'; + if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){ + _fdAutoOpenedFile=s.file_ready; + startReadyFileWithSlots(s.file_ready,true,s.filament_mismatch||null); + } + } else { + frb.style.display='flex'; + bannerVisible=true; + } }else{frb.style.display='none';} } // skip-button (mid-print) – nur sichtbar wenn aktuell gedruckt wird @@ -631,6 +810,28 @@ function applyState(){ var ctrlBtns=document.getElementById('d-ctrl-btns'); if(ctrlBtns) ctrlBtns.style.display=printing?'':'none'; updatePauseResumeBtn(); + // Zuletzt geladene Datei merken (Issue #55): solange sie über den State + // sichtbar ist. Beim Druckende/Abbruch leert die Bridge file_ready+filename + // (Issue #29) — die gemerkte Referenz bleibt für die Karten-Aktionen. + // Echte ready-Datei oder laufender Druck hebt einen vorherigen „Clear" auf. + if(s.file_ready||printing) _idleCleared=false; + if(s.file_ready) _lastLoadedFile=s.file_ready; + else if(s.filename && !_idleCleared) _lastLoadedFile=s.filename; + else if(_idleCleared) _lastLoadedFile=null; + // Idle-Aktionen (Drucken/Slots/Leeren) nur wenn nicht gedruckt wird, eine + // Datei bekannt ist und der grüne Banner nicht ohnehin schon dieselbe Aktion + // anbietet. + var idleBtns=document.getElementById('d-idle-btns'); + if(idleBtns){ + var showIdle=(!printing && _lastLoadedFile && !bannerVisible); + idleBtns.style.display=showIdle?'':'none'; + if(showIdle){ + var dfn=document.getElementById('d-fname'); + if(dfn && (!dfn.textContent || dfn.textContent==='–')){ + dfn.textContent=_lastLoadedFile;dfn.title=_lastLoadedFile; + } + } + } // header var b=document.getElementById('h-badge'); @@ -659,6 +860,7 @@ function applyState(){ var layers=s.curr_layer&&s.total_layers?'L '+s.curr_layer+' / '+s.total_layers:'–'; var dlayers=document.getElementById('d-layers');if(dlayers)dlayers.textContent=layers; + var dzpos=document.getElementById('d-zpos');if(dzpos)dzpos.textContent=s.z_mm>0?s.z_mm.toFixed(2)+' mm':'–'; var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=fmtTime(s.print_duration); var dremain=document.getElementById('d-remain');if(dremain)dremain.textContent=s.remain_time>0?fmtTime(s.remain_time):'–'; @@ -792,19 +994,42 @@ function applyState(){ var activity=(slot.activity||''); var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–'); var slotLabel=T.label_slot+' '+(globalIdx+1); - var profile=(window._slotProfileMap||{})[globalIdx]; + // Gemapptes Profil nur für belegte Slots verwenden — sonst zeigt ein + // verwaistes Mapping (Slot wurde geleert) ein „Geister"-Profil (Issue #57). + var profile=empty?null:(window._slotProfileMap||{})[globalIdx]; + var genericType=(slot.type||slot.material_type||'–'); + // Material-Label: bei belegtem Slot mit Mapping den konkreten Profilnamen + // (z.B. „eSUN PLA+") statt nur des generischen Typs zeigen (Issue #57 Punkt 4). + var materialLabel=empty?'–':((profile&&profile.name)?profile.name:genericType); var vendorBadge=''; if(!empty && profile && profile.vendor){ var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':''); vendorBadge='
'+profile.vendor+'
'; } + var spoolSel=''; + if(_spoolmanStatus.configured&&!empty&&_spoolmanSpools.length){ + var curSpool=_slotSpoolMap[String(globalIdx)]||''; + var spoolOpts=''+_spoolmanSpools.map(function(sp){ + var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':''; + var name=sp.filament?sp.filament.name:'#'+sp.id; + var rem=sp.remaining_weight!=null?' '+sp.remaining_weight.toFixed(0)+'g':''; + return ''; + }).join(''); + spoolSel='
' + +'
🧵 Spoolman
' + +'' + +'
'; + } html+='
' +'
' - +'
'+(empty?'–':(slot.type||slot.material_type||'–'))+'
' + +'
'+materialLabel+'
' +vendorBadge +'
'+slotLabel+'
' +'
'+pct+'
' + +spoolSel +'
' +'
'; }); @@ -815,17 +1040,24 @@ function applyState(){ } html+=''; }); - document.getElementById('ams-slots').innerHTML=html; + // Nicht rendern wenn ein Spool-Dropdown gerade offen ist (verhindert Schließen beim Poll) + var activeEl=document.activeElement; + var spoolOpen=activeEl&&activeEl.tagName==='SELECT'&&activeEl.dataset.spoolSlot!=null; + if(!spoolOpen) document.getElementById('ams-slots').innerHTML=html; } // camera overlay var co=document.getElementById('cam-overlay'); if(co)co.style.display=(s.print_state==='printing'&&camOn)?'block':'none'; - // auto-start camera during print - if(s.print_state==='printing'&&!camOn&&s.camera_url){ + // auto-start camera during print (unless user explicitly stopped it) + if(s.print_state==='printing'&&!camOn&&s.camera_url&&!camUserStopped&&s.camera_on_print){ camStart(); } + // reset user-stopped flag when print ends so next print auto-starts again + if(s.print_state!=='printing'){ + camUserStopped=false; + } updateConnBtn(); } @@ -887,10 +1119,6 @@ function drawChart(id,_,series){ var _updateTag=''; var _updateUrl=''; function openSettings(){ - // Titel mit aktivem Drucker-Namen aktualisieren - var pname=_activePrinter&&_activePrinter.name?_activePrinter.name:null; - var title=document.getElementById('modal-title-settings'); - if(title)title.textContent=T.settings_title+(pname?' – '+pname:''); fetch(_apiUrl('/api/settings')).then(function(r){return r.json()}).then(function(d){ document.getElementById('s-printer-name').value=d.printer_name||''; document.getElementById('s-printer-ip').value=d.printer_ip||''; @@ -901,25 +1129,231 @@ function openSettings(){ document.getElementById('s-mode-id').value=d.mode_id||''; document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); + var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation; var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; + var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0)); var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); + // Poll-Intervall (Sekunden) — Backend hat Vorrang vor localStorage + var pi=document.getElementById('s-poll-interval'); + if(pi){ + var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3; + pi.value=sec; + } + var vhl=document.getElementById('s-verbose-http-log');if(vhl)vhl.checked=!!d.verbose_http_log; + renderFilamentMapping(d.filament_profiles||{}); + renderSpoolmanSlotCard(); + // Spoolman + var su=document.getElementById('s-spoolman-url');if(su)su.value=d.spoolman_server||''; + var sr=document.getElementById('s-spoolman-sync-rate');if(sr)sr.value=(d.spoolman_sync_rate!==undefined?d.spoolman_sync_rate:30); + _updateSpoolmanStatusDot(); }); - var v=localStorage.getItem('pollInterval')||'2000'; - document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); - var pb=document.getElementById('poll-'+Math.round(parseInt(v)/1000)); - if(pb)pb.classList.add('active'); + // Sprach-Select im Settings-Panel mit aktueller Sprache spiegeln + var ls=document.getElementById('s-lang-select'); + if(ls)ls.value=(localStorage.getItem('lang')||document.documentElement.lang||'de'); document.getElementById('s-version-label').textContent='v'+('__VERSION__'||'?'); document.getElementById('update-status').textContent=''; document.getElementById('btn-update-apply').style.display='none'; var cl=document.getElementById('update-changelog');if(cl)cl.style.display='none'; _updateTag='';_updateUrl=''; - document.getElementById('settings-modal').classList.add('open'); - // Custom-Profile-Liste laden (Issue #41 — Verwaltung von User-importierten - // OrcaSlicer-Filament-Profilen) + // Custom-Profile-Liste laden (Issue #41) refreshUserProfileList(); + // Vendor-Sichtbarkeitsfilter (Issue #41 Option A) + loadVendorChecklist(); } function closeSettings(){ - document.getElementById('settings-modal').classList.remove('open'); + // Panel-Variante: zurück zum Dashboard + showPanel('dashboard'); +} + +// Poll-Intervall-Feld → Live-Poll sofort übernehmen (Persistenz erst beim Speichern) +function onPollIntervalInput(){ + var pi=document.getElementById('s-poll-interval'); + if(!pi)return; + var sec=parseInt(pi.value,10); + if(sec>=1&&sec<=60)setPoll(sec*1000); +} + +// ── Filament-Profil-Mapping pro Slot ([filament_profiles]) ── +// Pro Slot ein einzelnes Profil-Dropdown (vendor+name gemeinsam, gekeyt per +// _profileKey). Kein Freitext mehr → das (vendor,name)→id-Matching kann nicht +// mehr durch manuelle Eingabe brechen (Issue #57 Punkt 1). Optionen werden aus +// /kx/filament/profiles geladen, nach Vendor gruppiert, User-Profile zuerst, +// mit demselben Vendor-Sichtbarkeitsfilter wie das Slot-Edit-Dropdown. +function renderFilamentMapping(map){ + var el=document.getElementById('filament-mapping-list'); + if(!el)return; + var rows=''; + for(var i=0;i<4;i++){ + var m=map[i]||map[String(i)]||{}; + var idHint=m.id?' ('+m.id+')':''; + rows+=''; + } + el.innerHTML=rows; + // Dropdowns befüllen (async, geteilter Profil-Cache + Vendor-Filter) + for(var j=0;j<4;j++){ _fillMappingDropdown(j); } +} +function _fillMappingDropdown(slot){ + var sel=document.getElementById('fmap-'+slot); + if(!sel) return; + var wantKey=_profileKey(sel.dataset.vendor, sel.dataset.name); + _loadOrcaFilaments(function(profiles){ + sel.innerHTML=''; + var userProfs=profiles.filter(function(p){return p.is_user;}); + var systemProfs=profiles.filter(function(p){return !p.is_user;}); + function _opt(g,p){ + var o=document.createElement('option'); + o.value=_profileKey(p.vendor,p.name); + o.dataset.vendor=p.vendor; o.dataset.name=p.name; o.dataset.id=p.id||''; + o.textContent=(p.is_user?'★ ':'')+p.name+(p.vendor?' — '+p.vendor:''); + if(o.value===wantKey)o.selected=true; + g.appendChild(o); + } + if(userProfs.length){ + var gUser=document.createElement('optgroup'); + gUser.label='★ '+(tr('orca_profile_user_label')||'Eigene Profile'); + userProfs.forEach(function(p){_opt(gUser,p);}); + sel.appendChild(gUser); + } + _loadVisibleVendors(function(vis){ + var filtered=systemProfs; + if(vis&&vis.length){ + var allow={};vis.forEach(function(v){allow[v]=1;});allow['Generic']=1; + filtered=systemProfs.filter(function(p){return allow[p.vendor];}); + } + var byVendor={}; + filtered.forEach(function(p){(byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p);}); + Object.keys(byVendor).sort().forEach(function(v){ + var g=document.createElement('optgroup');g.label=v; + byVendor[v].forEach(function(p){_opt(g,p);}); + sel.appendChild(g); + }); + }); + }); +} +function saveFilamentMapping(){ + // Nutzt den per-Slot-Endpoint (vendor,name → ID-Lookup im Backend). + // Leere Auswahl ("") = Mapping entfernen. + var chain=Promise.resolve(); + for(var i=0;i<4;i++){ + (function(slot){ + var sel=document.getElementById('fmap-'+slot); + var opt=sel?sel.options[sel.selectedIndex]:null; + var vendor=(opt&&opt.dataset.vendor)||''; + var name=(opt&&opt.dataset.name)||''; + chain=chain.then(function(){ + return fetch(_apiUrl('/kx/filament/slots/'+slot+'/profile'), + {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({vendor:vendor,name:name})}); + }); + })(i); + } + chain.then(function(){ + clog(tr('log_filament_mapping_saved')||'Filament-Mapping gespeichert','msg-ok'); + openSettings(); // neu laden → ID-Hints aktualisieren + }).catch(function(e){clog('Mapping-Fehler: '+e,'msg-err');}); +} + +// ── Vendor-Sichtbarkeitsfilter (Issue #41 Option A) ── +var _vendorChecklistSel={}; // {vendor:true} — laufende Auswahl im UI +function loadVendorChecklist(){ + // aktuelle Auswahl aus Backend, dann alle verfügbaren Vendoren rendern + _visibleVendors=null; // Cache invalidieren + _loadVisibleVendors(function(vis){ + _vendorChecklistSel={}; + (vis||[]).forEach(function(v){_vendorChecklistSel[v]=true;}); + renderVendorChecklist(); + }); +} +function renderVendorChecklist(){ + var el=document.getElementById('visible-vendors-list'); + if(!el)return; + _loadOrcaFilaments(function(profiles){ + // alle System-Vendoren (ohne Generic — der ist immer sichtbar) sammeln + var set={}; + profiles.forEach(function(p){ if(!p.is_user && p.vendor && p.vendor!=='Generic') set[p.vendor]=1; }); + var vendors=Object.keys(set).sort(); + var q=((document.getElementById('vendor-filter-search')||{}).value||'').toLowerCase(); + if(q)vendors=vendors.filter(function(v){return v.toLowerCase().indexOf(q)>=0;}); + el.innerHTML=vendors.map(function(v){ + var ck=_vendorChecklistSel[v]?'checked':''; + var safe=v.replace(/"/g,'"'); + return ''; + }).join('')||''; + }); +} +function _vendorCheck(cb){ + var v=cb.getAttribute('data-vendor'); + if(cb.checked)_vendorChecklistSel[v]=true; else delete _vendorChecklistSel[v]; +} +function renderSpoolmanSlotCard(){ + var card=document.getElementById('spoolman-slot-card'); + var rows=document.getElementById('spoolman-slot-rows'); + if(!card||!rows)return; + if(!_spoolmanStatus.configured){card.style.display='none';return;} + card.style.display=''; + Promise.all([ + fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}), + fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}) + ]).then(function(res){ + var spools=res[0].spools||[]; + var slots=(res[1].result||[]).sort(function(a,b){return a.slot_index-b.slot_index;}); + if(!slots.length){rows.innerHTML='Keine AMS-Slots bekannt.';return;} + rows.innerHTML=slots.map(function(slot){ + var idx=parseInt(slot.slot_index); + var col=slot.color_hex||'#888'; + var mat=slot.material||''; + var current=_slotSpoolMap[String(idx)]||''; + var opts=''+spools.map(function(sp){ + var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':''; + var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':''; + var name=sp.filament?sp.filament.name:'Spool #'+sp.id; + var mat2=sp.filament&&sp.filament.material?' · '+sp.filament.material:''; + return ''; + }).join(''); + return '
'+ + ''+ + 'Slot '+(idx+1)+' '+escHtml(mat)+''+ + '
'; + }).join(''); + }).catch(function(){rows.innerHTML='Spoolman nicht erreichbar';}); +} + +function onAmsSpoolChange(sel){ + var idx=sel.getAttribute('data-spool-slot'); + var val=sel.value; + if(val) _slotSpoolMap[String(idx)]=parseInt(val); + else delete _slotSpoolMap[String(idx)]; + var mapping={}; + Object.keys(_slotSpoolMap).forEach(function(k){mapping[k]=_slotSpoolMap[k];}); + fetch(_apiUrl('/kx/spoolman/active-spool'),{method:'POST', + headers:{'Content-Type':'application/json'},body:JSON.stringify({slot_spools:mapping})}); +} + +function saveSpoolmanSlots(){ + var mapping={}; + document.querySelectorAll('#spoolman-slot-rows select[data-spool-slot]').forEach(function(sel){ + var idx=sel.getAttribute('data-spool-slot'); + var val=sel.value; + if(val)mapping[idx]=parseInt(val); + }); + fetch(_apiUrl('/kx/spoolman/active-spool'),{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({slot_spools:mapping})}).then(function(r){return r.json();}).then(function(d){ + _slotSpoolMap=d.slot_spools||{}; + }); +} + +function saveVisibleVendors(){ + var vendors=Object.keys(_vendorChecklistSel); + fetch(_apiUrl('/kx/filament/visible_vendors'),{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({vendors:vendors})}).then(function(r){return r.json();}).then(function(){ + _visibleVendors=vendors.slice(); // Dropdown-Cache aktualisieren + clog(tr('log_visible_vendors_saved')||'Hersteller-Auswahl gespeichert','msg-ok'); + }).catch(function(e){clog('Vendor-Filter-Fehler: '+e,'msg-err');}); } // ── Custom Filament Profile Import (Issue #41) ── @@ -993,6 +1427,9 @@ function doProfileImportUpload(files){ _orcaFilamentCache=null; refreshImportDialogList(); refreshUserProfileList(); + // Vendor-Checkliste neu aufbauen — ein Import kann einen bisher + // unbekannten System-Vendor mitbringen (Issue #41). + if(document.getElementById('visible-vendors-list')) renderVendorChecklist(); // Wenn Slot-Edit offen ist, Dropdown gleich neu befüllen var mat=document.getElementById('slot-edit-mat'); if(mat && document.getElementById('slot-edit-modal').classList.contains('open')){ @@ -1021,7 +1458,15 @@ function doProfileImportUpload(files){ // ── AMS Slot Edit ── var _slotEditIndex=-1; var _slotEditLoaded=false; -var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS']; +var _MAT_PRESETS=['PLA','PLA+','PLA SILK','PLA MATTE','PETG','ABS','ASA','TPU','PA','PC','HIPS']; +function _normalizeMat(m){ + var s=m.toUpperCase().trim().replace(/-/g,' ').replace(/_/g,' '); + var aliases={'PLAPLUS':'PLA+','PLA PLUS':'PLA+','SILK PLA':'PLA SILK','PLASILK':'PLA SILK', + 'PLA MATTE':'PLA','PLA MARBLE':'PLA','PLA WOOD':'PLA','TPE':'TPU', + 'PETG PLUS':'PETG+','PA6':'PA','PA12':'PA','PA66':'PA'}; + return aliases[s]||s; +} +var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK']; function updateSlotEditFeedButton(){ var btn=document.getElementById('btn-slot-edit-feed'); if(!btn)return; @@ -1033,6 +1478,14 @@ function updateSlotEditFeedButton(){ btn.textContent=_slotEditLoaded?tr('slot_edit_unload'):tr('slot_edit_load'); } var _orcaFilamentCache=null; // [{id,name,vendor,type,color}, …] +var _visibleVendors=null; // Vendor-Sichtbarkeitsfilter (Issue #41); [] = alle +function _loadVisibleVendors(cb){ + if(_visibleVendors!==null){ cb(_visibleVendors); return; } + fetch(_apiUrl('/kx/filament/visible_vendors')).then(function(r){return r.json();}).then(function(d){ + _visibleVendors=d.result||[]; + cb(_visibleVendors); + }).catch(function(){ _visibleVendors=[]; cb([]); }); +} function _loadOrcaFilaments(cb){ if(_orcaFilamentCache){ cb(_orcaFilamentCache); return; } fetch(_apiUrl('/kx/filament/profiles')).then(function(r){return r.json();}).then(function(d){ @@ -1053,10 +1506,21 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){ _loadOrcaFilaments(function(profiles){ // Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten) var matU=(material||'').toUpperCase().trim(); + // PLA-Varianten: Drucker meldet "PLA SILK"/"PLA+"/"PLA MATTE", OrcaSlicer + // speichert alle unter type=PLA — Namens-Keyword als Zusatzfilter. + var _PLA_VARIANT_KW={'PLA SILK':'silk','PLA+':'pla+','PLA MATTE':'matte', + 'PLA MARBLE':'marble','PLA WOOD':'wood'}; + var variantKw=_PLA_VARIANT_KW[matU]||null; + var baseMat=variantKw?'PLA':matU; var matched=profiles.filter(function(p){ var pt=(p.type||'').toUpperCase(); - // PLA-CF, PLA-SILK etc. zählen auch zu PLA - return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' '); + var nameL=(p.name||'').toLowerCase(); + // Basis-Typ muss passen + var typeOk=baseMat===''||pt===baseMat||pt.startsWith(baseMat+'-')||pt.startsWith(baseMat+' '); + if(!typeOk) return false; + // Bei Variante: Name muss Keyword enthalten (z.B. "silk", "matte", "pla+") + if(variantKw) return nameL.indexOf(variantKw)!==-1; + return true; }); sel.innerHTML=''; // User-Profile (is_user) zuerst — eigene Optgroup '★ Eigene' an erster Stelle. @@ -1078,16 +1542,130 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){ userProfs.forEach(function(p){ _appendOption(gUser, p); }); sel.appendChild(gUser); } - // System-Profile nach Vendor gruppieren - var byVendor={}; - systemProfs.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); }); - Object.keys(byVendor).sort().forEach(function(v){ - var g=document.createElement('optgroup'); g.label=v; - byVendor[v].forEach(function(p){ _appendOption(g, p); }); - sel.appendChild(g); + // Vendor-Sichtbarkeitsfilter (Issue #41 Option A): nur gewählte Vendoren + + // Generic. Leere Liste = alle (rückwärtskompatibel). Eigene Profile (is_user) + // sind oben bereits unkonditional drin. + _loadVisibleVendors(function(vis){ + var filtered=systemProfs; + if(vis&&vis.length){ + var allow={};vis.forEach(function(v){allow[v]=1;}); + allow['Generic']=1; + filtered=systemProfs.filter(function(p){return allow[p.vendor];}); + } + var byVendor={}; + filtered.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); }); + Object.keys(byVendor).sort().forEach(function(v){ + var g=document.createElement('optgroup'); g.label=v; + byVendor[v].forEach(function(p){ _appendOption(g, p); }); + sel.appendChild(g); + }); }); }); } +// ── Pickr color picker ────────────────────────────────────────────────────── +var _pickr=null; + +function _initPickr(hex){ + // destroy previous instance if exists + if(_pickr){ try{ _pickr.destroyAndRemove(); }catch(e){} _pickr=null; } + var anchor=document.getElementById('slot-pickr-anchor'); + if(!anchor||typeof Pickr==='undefined') return; + // fresh button element so Pickr can mount + anchor.innerHTML='
'; + _pickr=Pickr.create({ + el:'#slot-pickr-btn', + theme:'nano', + default: hex||'#808080', + inline: true, + showAlways: true, + components:{ + preview:true, opacity:false, hue:true, + interaction:{ hex:true, rgba:false, input:true, save:false, clear:false } + } + }); + _pickr.on('change',function(color){ + var h=color.toHEXA().toString().slice(0,7); + document.getElementById('slot-edit-color').value=h; + document.getElementById('slot-edit-preview').style.background=h; + }); + // Theme anpassen: Pickr benutzt eigene CSS-Variablen, wir überschreiben via style + requestAnimationFrame(function(){ + var el=anchor.querySelector('.pickr'); + if(el) el.style.cssText='width:100%'; + var app=anchor.querySelector('.pcr-app'); + if(app){ + app.style.cssText='position:relative;width:100%;box-shadow:none;background:transparent'; + var btn=app.querySelector('.pcr-result'); + if(btn) btn.style.cssText='background:var(--raised);border:1px solid var(--border);color:var(--txt);border-radius:6px;font-size:12px'; + } + }); +} + +// ── Color swatches (localStorage, max 16) ────────────────────────────────── +var _SWATCH_KEY='kxb_color_swatches'; +var _SWATCH_MAX=16; + +function _loadSwatches(){ + try{ return JSON.parse(localStorage.getItem(_SWATCH_KEY)||'[]'); }catch(e){ return []; } +} +function _saveSwatches(arr){ try{ localStorage.setItem(_SWATCH_KEY, JSON.stringify(arr)); }catch(e){} } + +function _addSwatch(hex){ + var arr=_loadSwatches().filter(function(c){ return c.toLowerCase()!==hex.toLowerCase(); }); + arr.unshift(hex); + if(arr.length>_SWATCH_MAX) arr=arr.slice(0,_SWATCH_MAX); + _saveSwatches(arr); +} + +function _renderSwatches(){ + var el=document.getElementById('slot-color-swatches'); + if(!el) return; + var arr=_loadSwatches(); + if(!arr.length){ el.style.display='none'; return; } + el.style.display='flex'; + el.innerHTML=arr.map(function(c){ + return '
'; + }).join(''); +} + +function slotPickSwatch(hex){ + if(_pickr){ _pickr.setColor(hex); } + var ci=document.getElementById('slot-edit-color'); + if(ci) ci.value=hex; + document.getElementById('slot-edit-preview').style.background=hex; +} + +// ── Copy color from other slot ────────────────────────────────────────────── +function _renderCopyFromSlot(currentGlobalIdx){ + var slots=(window._amsSlots||[]).filter(function(s){ + return s.global_index!==currentGlobalIdx && s.status==5 && Array.isArray(s.color); + }); + var row=document.getElementById('slot-copy-row'); + var sel=document.getElementById('slot-copy-select'); + if(!row||!sel) return; + if(!slots.length){ row.style.display='none'; return; } + row.style.display=''; + var ph=document.getElementById('lbl-slot-copy-from'); + var phTxt=ph?ph.textContent:(T.slot_copy_from||'Copy color from slot…'); + sel.innerHTML=''+slots.map(function(s){ + var rgb=s.color; + var hex='#'+rgb.map(function(v){return('0'+Math.min(255,v).toString(16)).slice(-2)}).join(''); + return ''; + }).join(''); +} + +function slotCopyColor(sel){ + if(!sel.value) return; + var ci=document.getElementById('slot-edit-color'); + if(!ci) return; + ci.value=sel.value; + document.getElementById('slot-edit-preview').style.background=sel.value; + sel.selectedIndex=0; +} + +// ─────────────────────────────────────────────────────────────────────────── + function openSlotEdit(i){ var slot=(window._amsSlots||[])[i]||{}; var globalIdx=slot.global_index!=null?slot.global_index:(slot.index!=null?slot.index:i); @@ -1099,13 +1677,19 @@ function openSlotEdit(i){ var ci=document.getElementById('slot-edit-color'); ci.value=hex; document.getElementById('slot-edit-preview').style.background=hex; + _initPickr(hex); + _renderSwatches(); + _renderCopyFromSlot(globalIdx); var mat=(slot.type||'PLA').toUpperCase(); document.getElementById('slot-edit-mat').value=mat; + // Normalisieren für Button-Highlighting: PLA-Varianten auf nächsten Preset mappen + var matNorm=_normalizeMat(mat); var btns=document.getElementById('slot-mat-btns'); btns.innerHTML=_MAT_PRESETS.map(function(m){ + var active=m===mat||m===matNorm; return ''; + +(active?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+''; }).join(''); // OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot // aus /kx/filament/slots holen (enthält vendor+name+id). @@ -1138,29 +1722,69 @@ function slotEditFeed(){ }) .catch(function(){}); } -function startReadyFile(){ - var currentFile=(storeFiles||[]).find(function(f){return f.filename===S.file_ready;}); - if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){ - maybeGateWebUpload(currentFile, function(){ startReadyFile(); }); +function startReadyFile(filename){ + var fn=filename||S.file_ready; + function _doStartReadyFile(){ + var btn=document.getElementById('file-ready-btn'); + if(btn){btn.disabled=true;btn.textContent='…';} + post('/printer/print/start',{filename:fn}) + .then(function(r){return r.json();}) + .then(function(){ + document.getElementById('file-ready-banner').style.display='none'; + if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);} + }) + .catch(function(e){ + clog(tr('log_error')+' '+e,'msg-err'); + if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);} + }); + } + function _gateAndStart(fileObj){ + if(fileObj && fileObj.web_unverified && webUploadWarningEnabled()){ + maybeGateWebUpload(fileObj, function(){ startReadyFile(fn); }); + return; + } + _doStartReadyFile(); + } + var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;}); + if(currentFile){ + _gateAndStart(currentFile); return; } - var btn=document.getElementById('file-ready-btn'); - if(btn){btn.disabled=true;btn.textContent='…';} - post('/printer/print/start',{filename:S.file_ready}) - .then(function(r){return r.json();}) - .then(function(r){ - document.getElementById('file-ready-banner').style.display='none'; - if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);} - }) - .catch(function(e){ - clog(tr('log_error')+' '+e,'msg-err'); - if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);} - }); + fetch(_apiUrl('/kx/files')).then(function(r){return r.json();}).then(function(d){ + storeFiles=d.result||[]; + var refreshed=(storeFiles||[]).find(function(f){return f.filename===fn;})||null; + _gateAndStart(refreshed); + }).catch(function(){ + _doStartReadyFile(); + }); } function cancelReadyFile(){ post('/api/file_ready/clear',{}) .then(function(){document.getElementById('file-ready-banner').style.display='none';}); } + +// ── Aktionen für geladene/idle Datei in der Progress-Karte (Issue #55) ── +function startIdleFile(){ + if(_lastLoadedFile) startReadyFile(_lastLoadedFile); +} +function startIdleFileWithSlots(){ + if(_lastLoadedFile) startReadyFileWithSlots(_lastLoadedFile); +} +function clearIdleFile(){ + _lastLoadedFile=null; + _idleCleared=true; // verhindert Nachladen von s.filename im nächsten poll() (Issue #57) + _fdAutoOpenedFile=null; // nächster Upload derselben Datei soll Dialog wieder öffnen + _fdUserCancelled=false; + _fdDialogOpen=false; + sessionStorage.removeItem('fdAutoOpenedFile'); + sessionStorage.removeItem('fdUserCancelled'); + sessionStorage.removeItem('webVerifyCancelledFileId'); + S.file_ready=''; S.filename=''; S.thumbnail=''; // sofort lokal leeren, kein Warten auf nächsten Poll + var ib=document.getElementById('d-idle-btns');if(ib)ib.style.display='none'; + var fn=document.getElementById('d-fname');if(fn){fn.textContent='–';fn.title='';} + var thumb=document.getElementById('d-thumbnail');if(thumb){thumb.style.display='none';thumb.src='';} + post('/api/file_ready/clear',{}).catch(function(){}); +} function selectMatPreset(m){ document.getElementById('slot-edit-mat').value=m; highlightMatBtn(m); @@ -1183,6 +1807,7 @@ function hexToRgb(hex){ } function saveSlotEdit(){ var hex=document.getElementById('slot-edit-color').value; + _addSwatch(hex); var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA'; var color=hexToRgb(hex); var slotIdx=_slotEditIndex; @@ -1248,9 +1873,6 @@ document.addEventListener('DOMContentLoaded',function(){ }); }); function setPoll(ms){ - document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); - var id='poll-'+Math.round(ms/1000); - var pb=document.getElementById(id);if(pb)pb.classList.add('active'); localStorage.setItem('pollInterval',ms); clearInterval(pollTimer); pollTimer=setInterval(poll,ms); @@ -1260,6 +1882,11 @@ function saveSettings(){ btn.disabled=true;btn.textContent='…'; var webUploadWarning=(document.getElementById('s-web-upload-warning')||{}).checked?1:0; S.web_upload_warning=webUploadWarning; + // Start-Print-Behavior-Wechsel könnte den Auto-Open sonst dauerhaft blockieren + // (alter _fdUserCancelled bei gleicher file_ready) → Dialog-State zurücksetzen (Issue #57). + _fdUserCancelled=false;_fdAutoOpenedFile=null; + sessionStorage.removeItem('fdUserCancelled');sessionStorage.removeItem('fdAutoOpenedFile'); + sessionStorage.removeItem('webVerifyCancelledFileId'); post('/api/settings',{ printer_name: document.getElementById('s-printer-name').value, printer_ip: document.getElementById('s-printer-ip').value, @@ -1269,15 +1896,22 @@ function saveSettings(){ device_id: document.getElementById('s-device-id').value, mode_id: document.getElementById('s-mode-id').value, default_ams_slot: document.getElementById('s-default-slot').value, - auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, - camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, + auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, + vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0, + camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, + print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10), web_upload_warning:webUploadWarning, + poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)), + verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0, + spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'', + spoolman_sync_rate: Math.max(0,parseInt((document.getElementById('s-spoolman-sync-rate')||{}).value||'30',10)), }).then(function(){ btn.textContent=T.update_restarting; setTimeout(function(){ btn.disabled=false; setText('btn-save-settings',T.settings_save); closeSettings(); + _loadSpoolmanStatus(); poll(); },4000); }).catch(function(e){ @@ -1285,21 +1919,33 @@ function saveSettings(){ clog('Settings-Fehler: '+e,'msg-err'); }); } +var _updateDockerOnly=false; function checkUpdate(){ var sb=document.getElementById('update-status'); sb.textContent=T.update_checking; document.getElementById('btn-update-apply').style.display='none'; - _updateTag='';_updateUrl=''; + _updateTag='';_updateUrl='';_updateDockerOnly=false; fetch(_apiUrl('/api/update/check')).then(function(r){return r.json()}).then(function(d){ if(d.error){sb.textContent=T.update_error+': '+d.error;return;} var cl=document.getElementById('update-changelog'); - if(d.changelog&&d.changelog.trim()){cl.textContent=d.changelog;cl.style.display='block';} - else{cl.style.display='none';} + if(d.changelog&&d.changelog.trim()){ + // Changelog als Markdown-Text (pre-formatiert) anzeigen + cl.textContent=d.changelog;cl.style.display='block'; + } else {cl.style.display='none';} + _updateDockerOnly=!!d.docker_only; if(d.update_available){ - sb.textContent='v'+d.latest+' '+T.update_available; + sb.textContent=d.latest+' '+T.update_available; sb.style.color='var(--ok)'; - _updateTag=d.tag;_updateUrl=d.download_url; - document.getElementById('btn-update-apply').style.display='inline-block'; + _updateTag=d.tag;_updateUrl=d.download_url||''; + var btn=document.getElementById('btn-update-apply'); + if(_updateDockerOnly){ + btn.textContent=T.update_docker||'docker compose pull'; + btn.title='docker compose pull && docker compose up -d'; + } else { + btn.textContent=T.update_apply; + btn.title=''; + } + btn.style.display='inline-block'; } else { sb.textContent=T.update_none; sb.style.color='var(--txt2)'; @@ -1307,9 +1953,21 @@ function checkUpdate(){ }).catch(function(e){sb.textContent=T.update_error+': '+e;}); } function applyUpdate(){ - if(!_updateUrl)return; var sb=document.getElementById('update-status'); var btn=document.getElementById('btn-update-apply'); + if(_updateDockerOnly){ + // Nightly: kein Self-Update, Docker-Befehl in Zwischenablage kopieren + var cmd='docker compose pull && docker compose up -d'; + if(navigator.clipboard){ + navigator.clipboard.writeText(cmd).then(function(){ + sb.textContent=T.update_docker_copied||'Befehl kopiert: '+cmd; + }); + } else { + sb.textContent=cmd; + } + return; + } + if(!_updateUrl)return; btn.disabled=true;sb.textContent=T.update_applying; post('/api/update/apply',{download_url:_updateUrl,tag:_updateTag}).then(function(){ sb.textContent=T.update_restarting; @@ -1351,8 +2009,314 @@ var pollTimer; }); }).catch(function(){}); poll();pollTimer=setInterval(poll,ms); + setInterval(_loadSpoolmanStatus,30000); + // initDashGrid() is called at the very end of the file, after all the + // DASH_* var declarations below have executed (they are hoisted but not yet + // assigned at this point in the IIFE). })(); +// ── Dashboard Free Grid (Issue #89) ──────────────────────────────────────── +// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js). +// Cards move + resize on a 12-column snap grid; layout persisted per browser +// in localStorage. Built on documented APIs only: +// - float:false (default) -> compact packing, no holes between cards +// - staticGrid:true -> locked by default, setStatic(false) in edit mode +// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id +// - draggable.cancel -> inputs/buttons/img excluded from drag start +// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware) +var DASH_STORAGE_KEY='dashLayout'; +var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states +var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams']; +// Layout arrays in GridStack save()/load() format. cellHeight=60px. +var DASH_DEFAULT_LAYOUT=[ + {id:'camera', x:0,y:0, w:12,h:7}, + {id:'progress',x:0,y:7, w:12,h:6}, + {id:'temps', x:0,y:13,w:6, h:7}, + {id:'motion', x:6,y:13,w:6, h:7}, + {id:'speed', x:0,y:20,w:6, h:3}, + {id:'fan', x:6,y:20,w:6, h:3}, + {id:'ams', x:0,y:23,w:12,h:4}, +]; +// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center, +// motion right. +var DASH_PRESET_WIDE=[ + {id:'progress',x:0, y:0, w:12,h:4}, + {id:'camera', x:0, y:4, w:6, h:11}, + {id:'temps', x:6, y:4, w:3, h:7}, + {id:'speed', x:6, y:11,w:3, h:2}, + {id:'fan', x:6, y:13,w:3, h:2}, + {id:'motion', x:9, y:4, w:3, h:11}, + {id:'ams', x:0, y:15,w:12,h:4}, +]; +var _dashGrid=null; +var _dashEditing=false; +var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show) + +// GridStack requires .grid-stack-item > .grid-stack-item-content around each +// card — wrap the existing .card elements in place (IDs/event handlers survive, +// appendChild moves nodes without recreating them). +function _dashWrapCards(){ + var grid=document.getElementById('dash-grid'); + if(!grid)return; + DASH_CARD_KEYS.forEach(function(key){ + var card=grid.querySelector('[data-card="'+key+'"]'); + if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return; + var item=document.createElement('div'); + item.className='grid-stack-item'; + item.setAttribute('gs-id',key); + var content=document.createElement('div'); + content.className='grid-stack-item-content'; + grid.insertBefore(item,card); + item.appendChild(content); + content.appendChild(card); + }); +} + +function _dashItem(key){ + return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]'); +} + +function _dashLoadState(){ + try{ + var raw=localStorage.getItem(DASH_STORAGE_KEY); + if(!raw)return null; + var s=JSON.parse(raw); + if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null; + return s; + }catch(e){return null;} +} +function _dashSaveState(){ + if(!_dashGrid)return; + localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({ + version:DASH_LAYOUT_VERSION, + layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only + hidden:_dashHidden.slice(), + })); +} + +function initDashGrid(){ + var el=document.getElementById('dash-grid'); + if(!el||typeof GridStack==='undefined')return; + _dashWrapCards(); + _dashGrid=GridStack.init({ + cellHeight:60, + margin:8, + staticGrid:true, // locked by default; setStatic(false) enables drag+resize + columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware) + draggable:{cancel:'input,textarea,button,select,option,img,.slider'}, + },el); + var state=_dashLoadState(); + if(state){ + _dashHidden=Array.isArray(state.hidden)?state.hidden:[]; + _dashGrid.load(state.layout,false); // update matching ids, no add/remove + _dashHidden.forEach(function(n){ + var item=_dashItem(n.id); + if(item){_dashGrid.removeWidget(item,false);item.style.display='none';} + }); + }else{ + _dashGrid.load(DASH_DEFAULT_LAYOUT,false); + } + _dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();}); + _dashRefreshPresetDropdown(); +} + +function toggleDashEdit(){ + if(!_dashGrid)return; + _dashEditing=!_dashEditing; + _dashGrid.setStatic(!_dashEditing); + document.getElementById('dash-grid').classList.toggle('editing',_dashEditing); + document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard'); + document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none'; + document.getElementById('dash-preset').style.display=_dashEditing?'':'none'; + document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none'; + _dashUpdatePresetDeleteBtn(); + if(_dashEditing)_dashInjectControls(); else _dashRemoveControls(); + _dashRenderHiddenBar(); + if(!_dashEditing)_dashSaveState(); +} + +// ── Custom presets (user-saved layouts) ──────────────────────────────────── +var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets'; +var DASH_BUILTIN_PRESET_IDS=['standard','wide89']; + +function _dashLoadCustomPresets(){ + try{ + var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY); + var obj=raw?JSON.parse(raw):{}; + return (obj&&typeof obj==='object')?obj:{}; + }catch(e){return {};} +} +function _dashSaveCustomPresets(presets){ + localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets)); +} + +function _dashRefreshPresetDropdown(){ + var sel=document.getElementById('dash-preset'); + if(!sel)return; + var current=sel.value; + var customs=_dashLoadCustomPresets(); + // Remove previously-rendered custom