Compare commits

..

45 Commits

Author SHA1 Message Date
cd11542352 fix(filament): PLA-Varianten (PLA+, Silk, Matte) korrekt erkennen und an OrcaSlicer übermitteln
All checks were successful
Nightly Build / build (push) Successful in 11m42s
- _normalize_material() normalisiert Drucker-Typen auf kanonische Keys
- _TRAY_INFO_IDX erweitert um Silk/Matte/CF-Varianten und Schreibweisen
- _default_filament_name() mappt Varianten auf korrekte Generic-Profile
- Filament-Dropdown zeigt Hersteller-Profile der jeweiligen Variante
- Material-Buttons: PLA+, PLA Silk, PLA Matte hinzugefügt

Fixes #82
2026-07-02 21:54:42 +02:00
51f22947c5 Merge pull request #83: fix(spoolman): repair dead slot-map persistence + isolate it per printer
Some checks failed
Nightly Build / build (push) Has been cancelled
2026-07-02 21:36:59 +02:00
Walter Almada B
a39226d2dd fix(spoolman): show vendor name in the spool dropdown (was "[object Object]")
Some checks failed
PR Check / lint-and-test (pull_request) Has been cancelled
The print-dialog spool dropdown built its option label from
sp.filament.vendor (the whole vendor object) instead of
sp.filament.vendor.name, so options rendered as "#5 [object Object] PLA+
(1000g)". The sibling builder in the slot card already uses .vendor.name;
this aligns the two.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:21:59 -07:00
Walter Almada B
2a13f1f0dd fix(spoolman): repair dead slot-map persistence + isolate it per printer
The AMS-slot -> Spoolman-spool persistence never worked: KobraXBridge
referenced `config_loader` in both the load (__init__) and save
(handle_kx_spoolman_set_active) paths, but the module alias is `env_loader`
(kobrax_moonraker_bridge.py:32). The resulting NameError was swallowed by a
bare `except`, so the map was neither loaded on startup nor written on change
- it only appeared to persist.

The map also lived in a single global `[spoolman] slot_spools` key, so on a
multi-printer bridge two AMS units clobbered each other's mapping (same class
of bug as #74/#75 for filament profiles).

- config_loader: add list_spool_map()/save_spool_map(printer_id) using a
  per-printer `[spoolman_<id>]` section with read-fallback to the legacy
  global key, mirroring _filament_section/list_filament_profiles. The global
  `[spoolman]` section keeps server/sync_rate.
- bridge: load via config_loader.list_spool_map(self._printer_id); persist via
  save_spool_map(..., self._printer_id); surface failures via log.warning
  instead of a silent except.
- _build_mmu_object: emit real gate_spool_id from the per-printer map (was
  hardcoded [-1]*num_gates) so Happy-Hare/OrcaSlicer can show the bound spool.
- config.ini.example: document the [spoolman] section.
- tests: tests/test_spoolman_slot_map.py (per-printer isolation, persistence
  round-trip, server/sync_rate preservation, parser robustness).

Verified on a 2-printer bridge: after restart KX1 loads its spools and KX2
loads its own, isolated; a real multicolor print deducted per slot (white spool
1.02g vs 0.98g slicer estimate) against the correct printer's spools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:42:40 -07:00
a16062f44f fix(ams): ams_box_mapping mit Platzhaltern für fehlende Paint-Indizes auffüllen
All checks were successful
Nightly Build / build (push) Successful in 11m17s
Drucker interpretiert ams_box_mapping als geordnete Liste (Eintrag N = TN).
Bei Drucken die T0 nicht nutzen wurden die Einträge um 1 verschoben,
sodass T2 (rot) auf den Slot von T3 (weiß) zeigte.

Fixes #78
2026-07-01 20:51:12 +02:00
4f5aa8d126 Revert "fix(ams): paint_index im auto-mapping auf global_index setzen statt enumerate-Zähler"
All checks were successful
Nightly Build / build (push) Successful in 10m12s
This reverts commit c313e014ad.
2026-06-30 23:01:03 +02:00
c313e014ad fix(ams): paint_index im auto-mapping auf global_index setzen statt enumerate-Zähler
Some checks failed
Nightly Build / build (push) Has been cancelled
Bei Multicolor-Drucken mit nicht bei 0 startenden Paint-Indizes (T2, T3...)
wurde paint_index als 0,1,2... statt als tatsächlicher GCode-T-Index gesendet.
Drucker hat dadurch die falschen Slots für die falschen Farben verwendet.

Fixes #78
2026-06-30 22:56:34 +02:00
6e9ba0672f fix(spoolman): Slot-Spool-Zuordnung in config.ini persistieren + beim Start laden; API-Feldname-Kompatibilität (slot_spools/slot_map)
All checks were successful
Nightly Build / build (push) Successful in 10m48s
2026-06-30 15:43:20 +02:00
44383fabec fix(docker): gcc + python3-dev für pycryptodome arm/v7 Kompilierung
All checks were successful
Nightly Build / build (push) Successful in 12m26s
2026-06-30 14:30:10 +02:00
48bec55611 feat(ci): linux/arm/v7 Platform zu Docker-Build hinzugefügt (Raspberry Pi 2/3 32-bit)
Some checks failed
Nightly Build / build (push) Failing after 4m53s
2026-06-30 12:21:20 +02:00
ab44e234be fix(ui): AMS-Spool-Dropdown bleibt offen während Poll-Tick (kein innerHTML-Reset bei fokussiertem Select)
Some checks failed
Nightly Build / build (push) Has been cancelled
2026-06-30 12:17:03 +02:00
74fc2ddab0 feat: color picker, unified UI styling, filament mismatch detection, Spoolman slot assignment
All checks were successful
Nightly Build / build (push) Successful in 4m27s
- Slot color editor: Pickr HSV color picker (offline, served from lib/),
  recent swatches (up to 16, localStorage), copy color from other slot
- Unified axes control panel: XY+Z merged, shared step size + custom mm input
- Language selector moved from header to Settings → Appearance
- Filament mismatch detection blocks Upload-and-Print on material mismatch,
  slot mapper opens automatically
- Spoolman spool-per-slot assignment in AMS status tab and Filaments settings
- Fix: Spoolman sync rate label — 0=end of print, not disabled (Issue #76)
- Fix: lib/ assets served by bridge static handler for offline use
- UI: global unified select + input styling, set-row labels match modal-field
2026-06-30 11:13:34 +02:00
771599be0c Merge pull request 'fix: isolate filament profiles per printer in multi-printer bridge' (#75) from walterioo/KX-Bridge-Release:fix/per-printer-filament-profiles into nightly
All checks were successful
Nightly Build / build (push) Successful in 4m21s
2026-06-30 10:21:56 +02:00
0e1d46ee7f fix: isolate filament profiles per printer in multi-printer bridge (#74)
Some checks failed
PR Check / lint-and-test (pull_request) Has been cancelled
Per-printer [filament_profiles_<id>] sections so configuring one printer no
longer overwrites another (read-fallback to the legacy global section keeps
single-printer setups unchanged). Dropdown/switch links now navigate to each
printer's own bridge_url. Adds pytest coverage and a CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:13:10 +02:00
15e28244af fix(ci): jq static binary installieren falls nicht vorhanden
All checks were successful
Nightly Build / build (push) Successful in 4m12s
2026-06-29 09:57:46 +02:00
c1a3b9238d fix(ci): jq statt python3 für JSON-Serialisierung (python3 not found im Runner)
Some checks failed
Nightly Build / build (push) Failing after 4m10s
2026-06-29 07:03:09 +02:00
aad3833301 fix(ci): SIGPIPE bei head -1 in Pipelines beheben — read+drain statt head
Some checks failed
Nightly Build / build (push) Failing after 4m5s
2026-06-29 06:58:13 +02:00
a2f658e701 fix(ci): Python-Skript ausgelagert — kein Inline-Code in YAML mehr
Some checks failed
Nightly Build / build (push) Failing after 16s
2026-06-29 06:57:00 +02:00
ab2cf6e4ed fix(ci): Heredoc aus YAML entfernen — python3 -c statt <<PYEOF 2026-06-29 06:55:31 +02:00
1b05362c2b fix(ci): JSON-Serialisierung via python3 statt awk-Pipeline (SIGPIPE/exit-141-Fix) 2026-06-28 23:24:49 +02:00
cfe70430d3 feat: unified axes panel, language selector cleanup, filament mismatch detection, Spoolman slot assignment
Some checks failed
Nightly Build / build (push) Failing after 5m11s
- Merged XY + Z axis cards into single Axes Control panel with shared step
  size row and free-text mm input; Home XY/Z placed below respective pads;
  Home All + Motors Off centred at bottom
- Language selector removed from header bar, now only in Settings → Appearance
- Upload-and-Print blocked on GCode/AMS material mismatch; slot mapper dialog
  opens automatically with mismatch warning highlighting affected slots
- Spoolman spool-per-slot assignment: dropdown in AMS status tab per kachel,
  dedicated card in Filaments settings tab with save button
- nightly.yml: NIGHTLY_CHANGELOG.md takes precedence over auto-generated
  commit log when present; CI uses it as release body verbatim
2026-06-28 23:17:58 +02:00
2c8a62f130 fix(ci): Changelog nur feat/fix-Commits seit letztem Stable-Tag
Some checks failed
Nightly Build / build (push) Failing after 5m3s
2026-06-28 21:59:38 +02:00
e4b0716330 fix(ci): --tags beim fetch, kein --depth=1 (Tags für Versionsberechnung nötig)
All checks were successful
Nightly Build / build (push) Successful in 4m40s
2026-06-28 19:16:31 +02:00
d9fcc15c53 fix(ci): kein VERSION-Commit im CI (verhindert Push-Loop + Konflikt)
Some checks failed
Nightly Build / build (push) Failing after 2s
2026-06-28 19:16:11 +02:00
31dcf4c8fd ci: nightly.yml in paths-Filter aufnehmen (Workflow-Änderungen triggern Build)
Some checks failed
Nightly Build / build (push) Failing after 2s
2026-06-28 19:14:34 +02:00
319a8d5ccb fix(ci): Nightly-Version automatisch aus letztem Stable-Tag berechnen
Keine manuelle VERSION-Pflege mehr für Nightlies. CI ermittelt:
- letzten Stable-Tag (z.B. v0.9.27)
- nächste Patch-Version (0.9.28)
- laufenden Nightly-Counter aus vorhandenen Tags (nightly-0.9.28-nightlyN)

VERSION-Datei im Repo bleibt auf dem letzten Stable, wird vom CI
für jeden Nightly-Build überschrieben und committet.
2026-06-28 19:09:08 +02:00
700459085b fix(ci): Nightly-Changelog dynamisch aus Git-Log generieren (Delta seit letztem Tag)
CHANGES.md war statisch und zeigte immer denselben alten Stand.
Jetzt: git log <prev-tag>..HEAD, chore-Nightly-Commits rausgefiltert,
englisch, nur echte feat/fix/docs-Commits.
2026-06-28 19:05:20 +02:00
f93c07a971 chore: nightly 0.9.27-nightly10 2026-06-28 18:55:22 +02:00
81906cfffc fix(ui): Spoolman-Section im Filaments-Tab nach asynchronem Status-Load anzeigen
All checks were successful
Nightly Build / build (push) Successful in 4m8s
_loadSpoolmanStatus() rief _buildSpoolmanSection() nicht auf — Section blieb
versteckt weil configured-Flag beim Tab-Öffnen noch false war.
2026-06-28 18:54:30 +02:00
3f915b058b chore: releases/ komplett aus Git entfernen (Binaries gehören nicht ins Repo) 2026-06-28 17:54:53 +02:00
8b66172ca1 chore: nightly auf v0.9.27-Stand bringen 2026-06-28 17:54:35 +02:00
efde35130b release: v0.9.27
All checks were successful
Stable Release / release (push) Successful in 4m9s
2026-06-28 17:00:49 +02:00
a31e01d28c chore: Version auf 0.9.27 erhöhen 2026-06-28 16:55:39 +02:00
cec7cb2a5a chore: nightly auf master-Stand bringen (Ein-Repo)
All checks were successful
Nightly Build / build (push) Successful in 3m58s
2026-06-28 16:51:40 +02:00
7a43698ecc chore: Ein-Repo-Modell — Tests, Doku, gitignore (CLAUDE.md+release.sh) 2026-06-28 16:51:35 +02:00
6b12bfb321 chore: master auf nightly9-Stand bringen 2026-06-28 16:49:14 +02:00
823cbfe1a9 build: sources for v0.9.27-rc1
All checks were successful
Stable Release / release (push) Successful in 4m21s
2026-06-28 16:21:04 +02:00
ce416f3b9a ci: release.yml nur noch Docker-Build (Release macht release.sh)
All checks were successful
Nightly Build / build (push) Successful in 3m36s
- "Create Gitea Release"-Step entfernt → keine doppelte Release-Erstellung
  mehr (release.sh legt Release + englischen Auto-Changelog + Assets an).
- Image-Tag strippt fuehrendes 'v' (VERSION-Datei hat keins).
- Tag-Pattern auf 'v*' erweitert (vorher matchte v0.9.x.y-Hotfixes nicht).
2026-06-26 23:45:23 +02:00
67c013f4ff nightly: 0.9.27-nightly9
All checks were successful
Nightly Build / build (push) Successful in 3m41s
2026-06-26 23:19:02 +02:00
40f85b1eb6 nightly: 0.9.27-nightly8 2026-06-26 23:10:27 +02:00
54ce101f99 ci: Changelog aus CHANGES.md lesen (von release.sh aus Dev-Repo generiert) 2026-06-26 23:10:22 +02:00
3531cad0ef nightly: 0.9.27-nightly7
All checks were successful
Nightly Build / build (push) Successful in 4m4s
2026-06-26 22:51:32 +02:00
f192a9943d ci: apk/wget-Fallback für curl korrekt klammern
All checks were successful
Nightly Build / build (push) Successful in 3m23s
2026-06-25 23:47:34 +02:00
eb7fd44f68 nightly: 0.9.27-nightly6 2026-06-25 23:41:36 +02:00
e5b2a19192 ci: curl via apk/static-binary statt BusyBox-wget für API-Calls 2026-06-25 23:41:25 +02:00
37 changed files with 1742 additions and 272 deletions

View File

@@ -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)

View File

@@ -10,6 +10,7 @@ on:
- 'requirements.txt'
- 'web/**'
- 'data/**'
- '.gitea/workflows/nightly.yml'
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
@@ -21,11 +22,11 @@ jobs:
- name: Checkout
run: |
if [ -d .git ]; then
git fetch origin nightly
git fetch --tags origin nightly
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 .
git clone --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
fi
- name: Install Docker CLI
@@ -64,11 +65,36 @@ jobs:
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-<BASE>-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: |
VERSION=$(cat VERSION)
. /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 \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push \
--provenance=false \
--no-cache \
@@ -80,53 +106,71 @@ jobs:
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
VERSION=$(cat VERSION)
. /tmp/nightly_version.env
TAG="nightly-${VERSION}"
git config user.name "gitea-actions"
git config user.email "actions@it-drui.de"
# Letzten nightly-Tag finden (für Changelog-Range)
git fetch --tags origin 2>/dev/null || true
PREV_TAG=$(git tag | grep '^nightly-' | sort | tail -1)
# 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)
# Commits seit letztem Tag sammeln
if [ -n "$PREV_TAG" ]; then
COMMITS=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s" --no-merges 2>/dev/null || true)
else
COMMITS=$(git log --pretty=format:"- %s" --no-merges -20 2>/dev/null || true)
fi
[ -z "$COMMITS" ] && COMMITS="- Automatischer Nightly-Build"
# Body in Temp-Datei (vermeidet YAML-Probleme mit Sonderzeichen wie > oder ```)
# 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 '[experimentell] Ungetestete Features, nur fuer Tester geeignet.\n\n' >> "$BODY_FILE"
printf '### Aenderungen seit `%s`\n\n' "${PREV_TAG:-erstem Commit}" >> "$BODY_FILE"
printf '%s\n\n---\n\n' "$COMMITS" >> "$BODY_FILE"
printf '### Docker-Image aktualisieren\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"
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 -f "$TAG"
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG" --force
git tag "$TAG"
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG"
# Altes Release loeschen falls vorhanden
wget -q --method=DELETE \
--header="Authorization: token ${GITEA_TOKEN}" \
# 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-Body via awk escapen, kein python3/curl nötig)
BODY_JSON=$(awk '{
gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t");
printf "%s\\n", $0
}' "$BODY_FILE" | awk 'BEGIN{printf "\""} {printf "%s", $0} END{printf "\""}')
JSON_PAYLOAD="{\"tag_name\":\"${TAG}\",\"name\":\"KX-Bridge ${VERSION} Nightly\",\"body\":${BODY_JSON},\"draft\":false,\"prerelease\":true}"
printf '%s' "$JSON_PAYLOAD" > /tmp/release_body.json
wget -q -O- \
--method=POST \
--header="Authorization: token ${GITEA_TOKEN}" \
--header="Content-Type: application/json" \
--body-file=/tmp/release_body.json \
# 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

View File

@@ -3,7 +3,7 @@ name: Stable Release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v*'
jobs:
release:
@@ -56,11 +56,12 @@ jobs:
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/}"
VERSION="${GITHUB_REF#refs/tags/v}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push \
--provenance=false \
--no-cache \
@@ -68,20 +69,6 @@ jobs:
-t "gitea.it-drui.de/viewit/kx-bridge:${VERSION}" \
.
- name: Create Gitea Release
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
VERSION="${GITHUB_REF#refs/tags/}"
CHANGELOG=$(awk "/^## \[${VERSION}\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md || echo "")
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases" \
-d "{
\"tag_name\": \"${VERSION}\",
\"name\": \"KX-Bridge ${VERSION}\",
\"body\": $(echo "$CHANGELOG" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),
\"draft\": false,
\"prerelease\": false
}"
# 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.

8
.gitignore vendored
View File

@@ -4,9 +4,7 @@ __pycache__/
build/
dist/
*.spec
releases/*/kx-bridge
releases/*/extract_credentials
releases/*/extract_credentials.exe
releases/
!kx-bridge.spec
@@ -18,3 +16,7 @@ data/
!data/orca_filaments.json
.runner-token
# Dev-only Dateien — nicht ins öffentliche Repo
CLAUDE.md
release.sh

View File

@@ -1,5 +1,21 @@
# Changelog
## [Unreleased]
### Fixed
- **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_<id>]` 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

View File

@@ -63,13 +63,13 @@ The PR template will be pre-filled. Set the target branch to **`nightly`**.
## Branch model
```
main ← stable releases only (merged by maintainer)
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 → main` for a new stable release.
Your PR always targets `nightly`. The maintainer periodically merges `nightly → master` for a new stable release.
---

View File

@@ -2,10 +2,11 @@ FROM python:3.11-slim-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
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/

7
NIGHTLY_CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
## Changes in this build
- Fix: Spoolman spool-slot assignment persistence was silently broken (NameError on config_loader swallowed by bare except) — now uses correct module reference and logs failures; per-printer isolation added so multi-printer setups no longer overwrite each other (Issue #80, PR #83 by @walterioo)
- Fix: spool dropdown in print dialog showed "[object Object]" instead of vendor name (PR #83)
- Fix: gate_spool_id in MMU object now populated from per-printer slot map instead of hardcoded -1 (PR #83)
- Fix: PLA variants (PLA+, PLA Silk, PLA Matte) now recognized as their material family — correct tray_info_idx sent to printer, correct Generic profile synced to OrcaSlicer, and vendor profiles for the variant shown in slot editor dropdown (Issue #82)
- UI: PLA+, PLA Silk and PLA Matte buttons added to slot material selector

View File

@@ -21,7 +21,7 @@ Feedback willkommen.</sub>
&nbsp;
[![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)
&nbsp;
[![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)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
@@ -29,6 +29,11 @@ Feedback willkommen.</sub>
</div>
> [!CAUTION]
> **Laufende Wartungsarbeiten** — Wir strukturieren das Repository um (Branch-Modell, CI-Workflows, Beitragsprozess). Es kann zu Änderungen bei Branch-Namen, PR-Templates und der Art der Veröffentlichungen kommen. Wir entschuldigen uns für etwaige Unannehmlichkeiten. Handhabung, Workflow und langfristige Wartbarkeit werden dadurch deutlich verbessert.
>
> 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen.
---
## ✨ Was kann KX-Bridge?
@@ -45,7 +50,7 @@ Feedback willkommen.</sub>
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
| | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
| 🔁 | **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 |
@@ -184,6 +189,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
<details>

147
README.dev.md Normal file
View File

@@ -0,0 +1,147 @@
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
# 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:
```
<basis-version>-dev+<git-hash>
```
**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 <repo-url> -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 = <DRUCKER_IP_1>
mqtt_port = 9883
username = <MQTT_USER>
password = <MQTT_PASSWORT>
mode_id = 20030
device_id = <DEVICE_ID_1>
http_port = 7125
[printer_2]
name = Drucker 2
printer_ip = <DRUCKER_IP_2>
mqtt_port = 9883
username = <MQTT_USER>
password = <MQTT_PASSWORT>
mode_id = 20030
device_id = <DEVICE_ID_2>
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)

View File

@@ -14,22 +14,13 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
<sub>🇬🇧 <a href="README.md">English version</a> · 🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
</div>
> [!CAUTION]
> **Trabajos de mantenimiento en curso esta semana** — 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 los inconvenientes. El manejo, el flujo de trabajo y la mantenibilidad a largo plazo mejorarán significativamente como resultado.
>
> 👉 ¿Quieres contribuir? Por favor lee primero [CONTRIBUTING.md](CONTRIBUTING.md).
<div align="center">
<br>
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Apoya%20este%20proyecto-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/viewitde)
&nbsp;
[![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)
&nbsp;
[![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)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
@@ -37,6 +28,11 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
</div>
> [!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
@@ -53,7 +49,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
| 🧩 | **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 |
| 🔁 | **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 |
@@ -75,43 +71,17 @@ docker compose up -d
**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
@@ -139,6 +109,11 @@ 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)
---
## 🎨 Slicer recomendado
@@ -212,6 +187,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
<details>

View File

@@ -20,7 +20,7 @@ officially tested or supported. Feedback welcome.</sub>
&nbsp;
[![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)
&nbsp;
[![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)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
@@ -28,6 +28,11 @@ officially tested or supported. Feedback welcome.</sub>
</div>
> [!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
@@ -44,7 +49,7 @@ officially tested or supported. Feedback welcome.</sub>
| 🧩 | **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 |
| 🔁 | **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 |
@@ -182,6 +187,24 @@ docker compose up -d --build # rebuild locally (instead of pulling)
---
## 🌙 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
<details>

View File

@@ -1 +1 @@
0.9.27-nightly5
0.9.27

View File

@@ -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).

View File

@@ -7,6 +7,7 @@ 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
@@ -182,9 +183,27 @@ def list_printers() -> list[dict]:
return printers
def list_filament_profiles() -> dict[int, dict]:
def _filament_section(printer_id: Optional[str] = None) -> str:
"""Section name holding a printer's filament-profile mapping.
Multi-printer (one bridge, N printers): each printer keeps its own
``[filament_profiles_<id>]`` 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]:
"""Liest die [filament_profiles]-Sektion aus config.ini.
With ``printer_id`` set, reads the per-printer ``[filament_profiles_<id>]``
section and falls back to the legacy global ``[filament_profiles]`` while
that printer has no own section yet.
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
@@ -208,10 +227,13 @@ 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"):
for key, value in cfg.items(section):
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
if not key.startswith("slot_"):
continue
@@ -231,74 +253,173 @@ def list_filament_profiles() -> dict[int, dict]:
return result
def save_filament_profiles(profiles: dict[int, dict]) -> bool:
def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool:
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
With ``printer_id`` set, writes the per-printer ``[filament_profiles_<id>]``
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")
section = _filament_section(printer_id)
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
# First save of a per-printer section inherits the legacy global filter.
preserved_vendors = None
if cfg.has_option("filament_profiles", "visible_vendors"):
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("filament_profiles"):
cfg.remove_section("filament_profiles")
if cfg.has_section(section):
cfg.remove_section(section)
if profiles or preserved_vendors:
cfg["filament_profiles"] = {}
cfg[section] = {}
if preserved_vendors:
cfg["filament_profiles"]["visible_vendors"] = 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() -> list[str]:
def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
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")
if not cfg.has_option("filament_profiles", "visible_vendors"):
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("filament_profiles", "visible_vendors")
raw = cfg.get(section, "visible_vendors")
return [v.strip() for v in raw.split(",") if v.strip()]
def save_visible_vendors(vendors: list[str]) -> bool:
def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool:
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder."""
(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")
if not cfg.has_section("filament_profiles"):
cfg.add_section("filament_profiles")
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["filament_profiles"]["visible_vendors"] = ", ".join(clean)
elif cfg.has_option("filament_profiles", "visible_vendors"):
cfg.remove_option("filament_profiles", "visible_vendors")
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_<id>]`` 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_<id>]
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_<id>]``
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

View File

@@ -142,6 +142,11 @@ _KX_UI_ASSETS: dict[str, str] = {
"style.css": "text/css",
"app.js": "application/javascript",
}
# Dateien aus lib/ werden anhand der Extension ausgeliefert (kein Whitelist-Eintrag nötig)
_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)
@@ -828,14 +833,14 @@ class KobraXBridge:
# Marke ("PolyTerra PLA — Polymaker") statt nur "Generic PLA" anzeigt.
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-Sichtbarkeitsfilter fürs Slot-Profil-Dropdown (Issue #41 Option A).
# Leere Liste = alle Vendoren sichtbar (rückwärtskompatibel).
try:
import config_loader as _cl
self._visible_vendors: list[str] = _cl.list_visible_vendors()
self._visible_vendors: list[str] = _cl.list_visible_vendors(self._printer_id)
except Exception:
self._visible_vendors = []
self._last_state: dict = {}
@@ -870,6 +875,7 @@ 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,
@@ -905,7 +911,16 @@ class KobraXBridge:
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
if _sm_url else None
)
self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id}
# Persistierte Spool-Zuordnung (AMS-Slot → Spoolman-Spool) je Drucker laden.
# Fix: hier wurde `config_loader` referenziert, aber der Modul-Alias ist
# `env_loader` (Zeile 32) → NameError, den das bare `except` verschluckte,
# sodass die Persistenz nie lud. Jetzt über den lokalen Import + per-Drucker.
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: Slot-Map laden fehlgeschlagen: %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
@@ -1052,11 +1067,19 @@ class KobraXBridge:
data = await request.json()
except Exception:
return self._json_cors({"error": "invalid JSON"}, status=400)
slot_map = data.get("slot_map") or {}
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
}
# Persistieren je Drucker (eigene [spoolman_<id>]-Sektion), damit die
# Zuordnung Bridge-Neustart überlebt und zwei AMS sich nicht überschreiben.
# (Vorher: NameError auf `config_loader` → nichts wurde je gespeichert.)
try:
import config_loader as _cl
_cl.save_spool_map(self._spoolman_slot_spools, self._printer_id)
except Exception as _e:
log.warning("Spoolman: Slot-Map speichern fehlgeschlagen: %s", _e)
self._spoolman_slot_usage = {}
self._spoolman_slot_reported = {}
self._spoolman_last_usage = 0.0
@@ -1564,16 +1587,32 @@ 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())
# Drucker interpretiert ams_box_mapping als geordnete Liste (Eintrag N = TN).
# Fehlende Slots müssen als Platzhalter rein, sonst verschiebt sich alles.
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": self._slot_to_print_ams_index(i),
"paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255],
"material_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.
@@ -1816,23 +1855,55 @@ class KobraXBridge:
# `compatible_printers: []` (= mit allen Druckern kompatibel).
_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",
}
# Normalisiert Material-Typ-Strings auf den kanonischen Key für _TRAY_INFO_IDX
# und _default_filament_name. PLA-Varianten die nicht exakt matchen fallen
# auf ihre Basisfamilie zurück (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
def _build_lane_data(self) -> dict:
"""Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0.
@@ -1871,7 +1942,7 @@ class KobraXBridge:
color_hex = color_raw[:6].upper() + "FF"
else:
color_hex = "FFFFFFFF"
material = slot.get("type", "PLA").upper()
material = self._normalize_material(slot.get("type", "PLA"))
# 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),
@@ -2043,10 +2114,11 @@ 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).
@@ -2065,6 +2137,9 @@ class KobraXBridge:
gate_filament_name.append(fila_name)
else:
gate_filament_name.append("")
# Spoolman-Spool-ID je Gate aus der (druckerspezifischen) Slot-Map, damit
# Happy-Hare/OrcaSlicer den gebundenen Spool anzeigen kann (-1 = keiner).
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)
@@ -2077,7 +2152,7 @@ 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,
@@ -2093,8 +2168,22 @@ class KobraXBridge:
kann pro Slot eine konkrete Marke setzen wenn er das will."""
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
# alle Varianten unter type=PLA mit dem Variant-Namen im name-Feld.
_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 + " ")
@@ -2600,7 +2689,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)
@@ -2630,7 +2719,7 @@ class KobraXBridge:
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)
_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)
@@ -3273,6 +3362,31 @@ 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 blockiert — Filament-Mismatch: {mismatch}")
self._state["file_ready"] = remote_filename
self._state["filament_mismatch"] = mismatch
return web.json_response({
"done": True,
"filament_mismatch": True,
"mismatch_details": mismatch,
"files": {
"local": {
"name": remote_filename,
"origin": "local",
"path": remote_filename,
"refs": {
"download": f"http://{request.host}/api/files/local/{remote_filename}",
"resource": f"http://{request.host}/api/files/local/{remote_filename}",
}
}
},
"result": {
"item": {"path": remote_filename, "root": "gcodes"},
"action": "create_file",
}
}, status=201)
log.info(f"Upload+Print (print=true): {remote_filename}")
self._state["file_ready"] = ""
loop = asyncio.get_event_loop()
@@ -3301,6 +3415,45 @@ class KobraXBridge:
}
}, status=201)
def _check_filament_mismatch(self, gcode_filaments: list | None) -> list[dict] | None:
"""Vergleicht GCode-Filamente (is_used=True) mit aktuell belegten AMS-Slots.
Gibt Liste von Mismatch-Einträgen zurück wenn mindestens ein genutzter
GCode-Slot kein passendes Material im AMS hat — sonst None.
Wird nur ausgelöst wenn AMS-Daten vorhanden sind (mindestens 1 belegter 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 _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0,
gcode_filaments: list | None = None):
self._state["file_ready"] = ""
@@ -3492,6 +3645,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"})
@@ -3510,6 +3664,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:
@@ -4488,7 +4648,8 @@ class KobraXBridge:
for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
"MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING",
"CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "PRINT_START_DIALOG",
"FILE_READY_DIALOG", "BRIDGE_PRINTER_NAME"):
"FILE_READY_DIALOG", "BRIDGE_PRINTER_NAME",
"SPOOLMAN_SERVER", "SPOOLMAN_SYNC_RATE"):
os.environ.pop(_k, None)
in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER")

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

View File

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

68
tests/conftest.py Normal file
View File

@@ -0,0 +1,68 @@
"""
Shared fixtures für KX-Bridge Tests.
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
"""
import sys, types, argparse, pytest, pytest_asyncio
from unittest.mock import MagicMock
from aiohttp.test_utils import TestClient, TestServer
# ── Pfad ──────────────────────────────────────────────────────────────────────
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
env_mod = types.ModuleType("env_loader")
env_mod.PRINTER_IP = ""
env_mod.MQTT_PORT = 9883
env_mod.USERNAME = ""
env_mod.PASSWORD = ""
env_mod.MODE_ID = "20030"
env_mod.DEVICE_ID = ""
sys.modules["env_loader"] = env_mod
# ── Bridge + App importieren ───────────────────────────────────────────────────
from kobrax_moonraker_bridge import KobraXBridge, build_app # noqa: E402
def make_mock_client():
"""Minimaler Mock-MQTT-Client — keine Verbindung, keine Threads."""
c = MagicMock()
c.callbacks = {}
c.connected = False
return c
def make_args(**overrides):
args = argparse.Namespace(
printer_ip = "",
mqtt_port = 9883,
username = "",
password = "",
mode_id = "20030",
device_id = "",
host = "127.0.0.1",
port = 7125,
)
for k, v in overrides.items():
setattr(args, k, v)
return args
@pytest_asyncio.fixture
async def client():
"""TestClient mit frischer Bridge-Instanz, ohne MQTT-Verbindung."""
mock_client = make_mock_client()
bridge = KobraXBridge(mock_client, args=make_args())
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
yield c, bridge
@pytest_asyncio.fixture
async def client_configured():
"""TestClient mit bereits konfigurierten Zugangsdaten."""
mock_client = make_mock_client()
args = make_args(printer_ip="192.168.1.100", device_id="abc123deadbeef")
bridge = KobraXBridge(mock_client, args=args)
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
yield c, bridge

View File

@@ -0,0 +1,3 @@
pytest
pytest-asyncio
aiohttp

113
tests/test_api_state.py Normal file
View File

@@ -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"] == ""

View File

@@ -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_<id>]`` 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+"

109
tests/test_install.sh Normal file
View File

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

77
tests/test_moonraker.py Normal file
View File

@@ -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)

82
tests/test_settings.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Tests für /api/settings — Lesen und Schreiben der Verbindungseinstellungen.
"""
import pytest
import tempfile
import pathlib
@pytest.mark.asyncio
async def test_settings_get_returns_200(client):
c, _ = client
resp = await c.get("/api/settings")
assert resp.status == 200
@pytest.mark.asyncio
async def test_settings_get_schema(client):
c, _ = client
data = await (await c.get("/api/settings")).json()
for key in ("printer_ip", "mqtt_port", "username", "password", "device_id", "mode_id"):
assert key in data
@pytest.mark.asyncio
async def test_settings_get_empty_when_unconfigured(client):
"""Frische Bridge ohne Zugangsdaten → printer_ip und device_id leer."""
c, _ = client
data = await (await c.get("/api/settings")).json()
assert data["printer_ip"] == ""
assert data["device_id"] == ""
@pytest.mark.asyncio
async def test_settings_get_returns_configured_values(client_configured):
"""Bridge mit Zugangsdaten → Werte korrekt zurückgegeben."""
c, _ = client_configured
data = await (await c.get("/api/settings")).json()
assert data["printer_ip"] == "192.168.1.100"
assert data["device_id"] == "abc123deadbeef"
@pytest.mark.asyncio
async def test_settings_post_writes_env(client):
"""POST /api/settings schreibt Werte in .env-Datei."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("")
bridge._find_env_path = lambda: env_path
resp = await c.post("/api/settings", json={
"printer_ip": "10.0.0.5",
"mqtt_port": 9883,
"username": "userABCD",
"password": "secret123",
"device_id": "deadbeef01234567",
"mode_id": "20030",
})
assert resp.status == 200
content = env_path.read_text()
assert "PRINTER_IP=10.0.0.5" in content
assert "MQTT_USERNAME=userABCD" in content
assert "DEVICE_ID=deadbeef01234567" in content
@pytest.mark.asyncio
async def test_settings_post_preserves_existing_keys(client):
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
bridge._find_env_path = lambda: env_path
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
content = env_path.read_text()
assert "GITEA_TOKEN=mytoken" in content
assert "PRINTER_IP=10.0.0.99" in content

View File

@@ -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_<id>]`` 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}

View File

@@ -54,6 +54,13 @@ function _loadSpoolmanStatus(){
_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(){
@@ -78,12 +85,8 @@ function _buildSpoolmanSection(){
if(loading)loading.style.display='';
var usedSlots={};
document.querySelectorAll('#fd-slots select').forEach(function(sel){
var idx=parseInt(sel.value);
if(idx>=0){
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
}
(_amsSlots||[]).forEach(function(slot){
usedSlots[slot.slot_index]=slot;
});
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
@@ -97,7 +100,7 @@ function _buildSpoolmanSection(){
var currentSpool=_slotSpoolMap[String(idx)]||'';
var opts='<option value=""></option>'+_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+' ':'';
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
@@ -289,7 +292,7 @@ function renderPrinterDropdown(){
menu.innerHTML=_printers.map(function(p){
var active=_activePrinter&&String(p.id)===String(_activePrinter.id);
var num=p.id;
return '<a href="/printer'+num+'" style="display:block;padding:10px 14px;color:'+(active?'var(--accent)':'var(--txt)')+';text-decoration:none;font-size:13px;border-bottom:1px solid var(--border)" '+(active?'style="font-weight:600"':'')+'>'+
return '<a href="'+((p.bridge_url||'').replace(/\/+$/,''))+'/printer'+num+'" style="display:block;padding:10px 14px;color:'+(active?'var(--accent)':'var(--txt)')+';text-decoration:none;font-size:13px;border-bottom:1px solid var(--border)" '+(active?'style="font-weight:600"':'')+'>'+
(active?'▶ ':'')+p.name+'</a>';
}).join('');
}
@@ -376,12 +379,10 @@ 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);
@@ -550,6 +551,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();}
@@ -748,7 +750,7 @@ function applyState(){
frb.style.display='none';
if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
_fdAutoOpenedFile=s.file_ready;
startReadyFileWithSlots(s.file_ready,true);
startReadyFileWithSlots(s.file_ready,true,s.filament_mismatch||null);
}
} else {
frb.style.display='flex';
@@ -961,6 +963,22 @@ function applyState(){
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
}
var spoolSel='';
if(_spoolmanStatus.configured&&!empty&&_spoolmanSpools.length){
var curSpool=_slotSpoolMap[String(globalIdx)]||'';
var spoolOpts='<option value=""></option>'+_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 '<option value="'+sp.id+'"'+(String(sp.id)==String(curSpool)?' selected':'')+'>'+
escHtml(vendor+name+rem)+'</option>';
}).join('');
spoolSel='<div onclick="event.stopPropagation()" style="margin-top:6px;border-top:1px solid rgba(255,255,255,.07);padding-top:5px">'
+'<div style="font-size:8px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px">🧵 Spoolman</div>'
+'<select data-spool-slot="'+globalIdx+'" onchange="onAmsSpoolChange(this)" '
+'style="width:100%;padding:3px 5px;font-size:10px;border-radius:5px;border:1px solid var(--border);background:var(--card);color:var(--txt);cursor:pointer">'+spoolOpts+'</select>'
+'</div>';
}
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
+'<div class="slot-circle" style="background:'+col+'"></div>'
@@ -968,6 +986,7 @@ function applyState(){
+vendorBadge
+'<div class="slot-label">'+slotLabel+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
+spoolSel
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
+'</div>';
});
@@ -978,7 +997,10 @@ function applyState(){
}
html+='</div></div>';
});
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
@@ -1074,6 +1096,7 @@ function openSettings(){
pi.value=sec;
}
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);
@@ -1221,6 +1244,64 @@ 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='<span style="font-size:11px;color:var(--txt2)">Keine AMS-Slots bekannt.</span>';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='<option value=""></option>'+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 '<option value="'+sp.id+'"'+(String(sp.id)==String(current)?' selected':'')+'>'+
escHtml('#'+sp.id+' '+vendor+name+mat2+rem)+'</option>';
}).join('');
return '<div style="display:flex;align-items:center;gap:8px;font-size:12px">'+
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:'+col+';border:1px solid var(--border);flex-shrink:0"></span>'+
'<span style="color:var(--txt2);min-width:60px">Slot '+(idx+1)+' <span style="color:var(--txt2);font-size:10px">'+escHtml(mat)+'</span></span>'+
'<select data-spool-slot="'+idx+'" style="flex:1;padding:3px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+opts+'</select></div>';
}).join('');
}).catch(function(){rows.innerHTML='<span style="font-size:11px;color:var(--err)">Spoolman nicht erreichbar</span>';});
}
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'},
@@ -1332,7 +1413,14 @@ 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');
@@ -1373,10 +1461,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='<option value="">'+tr('slot_edit_profile_default')+'</option>';
// User-Profile (is_user) zuerst — eigene Optgroup '★ Eigene' an erster Stelle.
@@ -1418,6 +1517,110 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){
});
});
}
// ── 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='<div id="slot-pickr-btn"></div>';
_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 '<div title="'+c+'" onclick="slotPickSwatch(\''+c+'\')" style="width:22px;height:22px;border-radius:4px;background:'+c+
';border:2px solid rgba(255,255,255,.2);cursor:pointer;flex-shrink:0"></div>';
}).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='<option value="">'+phTxt+'</option>'+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 '<option value="'+hex+'">Slot '+(s.global_index+1)+' — '+(s.type||'?')+' '+hex+'</option>';
}).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);
@@ -1429,13 +1632,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 '<button class="mat-preset-btn" data-mat="'+m+'" onclick="selectMatPreset(\''+m+'\')" '
+'style="padding:4px 10px;border-radius:6px;border:1px solid var(--border);cursor:pointer;font-size:12px;'
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
+(active?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
}).join('');
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
// aus /kx/filament/slots holen (enthält vendor+name+id).
@@ -1553,6 +1762,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;
@@ -1654,6 +1864,7 @@ function saveSettings(){
btn.disabled=false;
setText('btn-save-settings',T.settings_save);
closeSettings();
_loadSpoolmanStatus();
poll();
},4000);
}).catch(function(e){
@@ -1789,7 +2000,15 @@ function setStep(btn,v){
currentStep=v;
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('step-display').textContent=v;
var ci=document.getElementById('step-custom');
if(ci)ci.value='';
}
function setStepCustom(inp){
var v=parseFloat(inp.value);
if(!isNaN(v)&&v>0){
currentStep=v;
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
}
}
function move(axis,dir,dist){
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
@@ -2484,7 +2703,20 @@ function confirmStoreWebVerify(){
});
}
function startReadyFileWithSlots(filename,_autoOpen){
function _showMismatchWarn(mismatches){
var el=document.getElementById('fd-mismatch-warn');
if(!el)return;
if(!mismatches||!mismatches.length){el.style.display='none';el.innerHTML='';return;}
var lines=mismatches.map(function(m){
var slot='Slot '+(m.slot_index+1);
if(m.reason==='empty')
return '⚠ '+slot+': GCode needs '+m.gcode_material+' — slot is empty';
return '⚠ '+slot+': GCode needs '+m.gcode_material+', loaded: '+(m.ams_material||'?');
});
el.innerHTML='<strong>Filament mismatch detected</strong><br>'+lines.join('<br>');
el.style.display='';
}
function startReadyFileWithSlots(filename,_autoOpen,_mismatch){
if(!_autoOpen) _fdAutoOpenedFile=null; // manueller Aufruf → Auto-Open-Sperre aufheben
var fn=filename||S.file_ready;
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
@@ -2504,9 +2736,11 @@ function startReadyFileWithSlots(filename,_autoOpen){
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
openFilamentDialog(d.result||[]);
_showMismatchWarn(_mismatch||null);
}).catch(function(){
if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;}
openFilamentDialog([]);
_showMismatchWarn(_mismatch||null);
});
}
@@ -2742,6 +2976,7 @@ function openFilamentDialog(slots){
function closeFilamentDialog(){
var dlg=document.getElementById('filament-dialog');
if(dlg)dlg.classList.remove('open');
_showMismatchWarn(null);
_fdDialogOpen=false;
if(_fdAutoOpenedFile){
_fdUserCancelled=true;
@@ -3140,7 +3375,7 @@ function loadPrinterTab(){
'<div style="font-size:12px;color:var(--txt2);display:flex;gap:12px">'+
'<span>🌡 '+nt+'°C</span><span>🛏 '+bt+'°C</span>'+
'</div>'+
(!isActive?'<a href="/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
(!isActive?'<a href="'+url+'/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
'</div>';
}).join('');
});

View File

@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KX-Bridge</title>
<link rel="stylesheet" href="/kx/ui/style.css">
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
<script src="/kx/ui/lib/pickr.min.js"></script>
<body>
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
@@ -32,17 +34,6 @@
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
<div style="display:flex;align-items:center;gap:6px">
<span aria-hidden="true" style="font-size:15px;line-height:1;opacity:.85">🌐</span>
<select class="theme-btn" id="lang-select" onchange="setLanguageFromSelect()" style="padding:6px 10px">
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="zh-cn">中文(简体)</option>
</select>
</div>
<button class="theme-btn" onclick="showPanel('settings')" id="settings-btn" title="Einstellungen"></button>
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
</header>
@@ -57,15 +48,26 @@
<span class="modal-title" id="slot-edit-title"></span>
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1"></button>
</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
<div style="flex:1">
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
<input type="color" id="slot-edit-color"
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
<div style="display:flex;align-items:flex-start;gap:16px;margin-bottom:12px">
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0;margin-top:4px"></div>
<div style="flex:1;min-width:0">
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-color"></div>
<!-- Pickr anchor — JS mounts the picker here -->
<div id="slot-pickr-anchor"></div>
<!-- hidden input keeps the hex value for saveSlotEdit() -->
<input type="hidden" id="slot-edit-color">
</div>
</div>
<!-- Recent color swatches (max 16, localStorage) -->
<div id="slot-color-swatches" style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px"></div>
<!-- Copy from slot -->
<div id="slot-copy-row" style="display:none;margin-bottom:16px">
<select id="slot-copy-select"
style="width:100%;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;box-sizing:border-box"
onchange="slotCopyColor(this)">
<option value="" id="lbl-slot-copy-from">Copy color from slot…</option>
</select>
</div>
<div style="margin-bottom:20px">
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
@@ -252,39 +254,51 @@
<!-- Achsensteuerung -->
<div class="card">
<div class="card-title"><span></span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
<div class="joypad">
<div></div>
<button class="joy" onclick="move(1,1,getStep())" title="Y+"></button>
<div></div>
<button class="joy" onclick="move(0,-1,getStep())" title="X"></button>
<button class="joy home" onclick="homeAll()" title="Home All"></button>
<button class="joy" onclick="move(0,1,getStep())" title="X+"></button>
<div></div>
<button class="joy" onclick="move(1,-1,getStep())" title="Y"></button>
<div></div>
<div class="card-title"><span></span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
<!-- XY -->
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
<div class="joypad">
<div></div>
<button class="joy" onclick="move(1,1,getStep())" title="Y+"></button>
<div></div>
<button class="joy" onclick="move(0,-1,getStep())" title="X"></button>
<div></div>
<button class="joy" onclick="move(0,1,getStep())" title="X+"></button>
<div></div>
<button class="joy" onclick="move(1,-1,getStep())" title="Y"></button>
<div></div>
</div>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt);width:100%" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
</div>
<!-- Z -->
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
<button class="joy" onclick="move(2,1,getStep())" title="Z+"></button>
<button class="joy" onclick="move(2,-1,getStep())" title="Z"></button>
</div>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt);width:100%" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
</div>
</div>
<div class="step-btns">
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
<button class="step-btn active" onclick="setStep(this,1)">1</button>
<button class="step-btn" onclick="setStep(this,5)">5</button>
<button class="step-btn" onclick="setStep(this,10)">10 mm</button>
<!-- Einheitliche Step-Size -->
<div style="display:flex;align-items:center;gap:6px;margin-top:10px;flex-wrap:wrap">
<div class="step-btns" style="margin-top:0">
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
<button class="step-btn active" onclick="setStep(this,1)">1</button>
<button class="step-btn" onclick="setStep(this,5)">5</button>
<button class="step-btn" onclick="setStep(this,10)">10</button>
</div>
<input id="step-custom" type="number" min="0.1" max="260" step="0.1"
placeholder="mm"
style="width:60px;padding:4px 6px;font-size:12px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);text-align:center"
oninput="setStepCustom(this)">
</div>
<div class="home-btns">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
<!-- Globale Befehle zentriert -->
<div style="display:flex;justify-content:center;gap:8px;margin-top:10px">
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
</div>
</div>
<div class="card">
<div class="card-title"><span></span> <span id="ptitle-motion-z">Z-Achse</span></div>
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
<button class="joy" onclick="move(2,1,getStep())" title="Z+"></button>
<button class="joy" onclick="move(2,-1,getStep())" title="Z"></button>
</div>
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
</div>
<!-- Print Speed -->
<div class="card">
@@ -570,6 +584,12 @@
<div id="visible-vendors-list" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:8px"></div>
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:8px" onclick="saveVisibleVendors()"><span id="lbl-visible-vendors-save">Auswahl speichern</span></button>
</div>
<div class="card" id="spoolman-slot-card" style="display:none">
<div class="card-title"><span>🧵</span> <span id="lbl-spoolman-slot-assign">Spoolman — Slot-Zuordnung</span></div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:10px" id="lbl-spoolman-slot-hint">Spoolman-Spool pro AMS-Slot zuweisen. Der Verbrauch wird beim Druck automatisch gemeldet.</div>
<div id="spoolman-slot-rows" style="display:flex;flex-direction:column;gap:8px"></div>
<button class="btn btn-sm" style="background:var(--accent);color:#fff;margin-top:10px" onclick="saveSpoolmanSlots()"><span id="lbl-spoolman-slot-save">Zuordnung speichern</span></button>
</div>
</div>
<!-- Integrationen -->
@@ -582,7 +602,7 @@
<input type="text" id="s-spoolman-url" placeholder="http://spoolman:7912" style="width:200px">
</div>
<div class="set-row">
<label id="lbl-spoolman-sync-rate">Sync-Rate (s, 0=aus)</label>
<label id="lbl-spoolman-sync-rate">Sync-Rate (s, 0=Druckende)</label>
<input type="number" id="s-spoolman-sync-rate" min="0" max="3600" value="30" style="width:80px">
</div>
<div id="spoolman-status-row" style="margin-top:6px;font-size:12px;color:var(--txt2)">
@@ -655,6 +675,7 @@
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<p id="fd-slots-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
<div id="fd-mismatch-warn" style="display:none;margin-bottom:10px;padding:8px 10px;background:rgba(255,160,0,.12);border:1px solid rgba(255,160,0,.4);border-radius:8px;font-size:11px;color:#ffa000"></div>
<div id="fd-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
<button type="button" id="fd-objects-toggle" onclick="toggleFdObjects()"

File diff suppressed because one or more lines are too long

3
web/themes/default/lib/pickr.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -15,10 +15,46 @@
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--accent);text-decoration:none}
/* select/option-Farben explizit setzen — OrcaSlicers Device-Tab-Webview erbt
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29). */
select{background:var(--raised)!important;color:var(--txt)!important}
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29).
Einheitliches Styling für alle Dropdowns im gesamten UI. */
select{
background:var(--raised)!important;
color:var(--txt)!important;
border:1px solid var(--border)!important;
border-radius:8px!important;
padding:6px 10px!important;
font-size:13px!important;
appearance:none!important;
-webkit-appearance:none!important;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888' d='M6 8L0 0h12z'/%3E%3C/svg%3E")!important;
background-repeat:no-repeat!important;
background-position:right 10px center!important;
padding-right:28px!important;
cursor:pointer!important;
outline:none!important;
box-sizing:border-box!important;
}
select:focus{border-color:var(--accent)!important;box-shadow:0 0 0 2px rgba(0,200,255,0.18)!important}
select option{background:var(--card)!important;color:var(--txt)!important}
/* Einheitliches Styling für Text/Number-Inputs */
input[type=text],input[type=number],input[type=url],input[type=password],input[type=email],input[type=search]{
background:var(--raised);
color:var(--txt);
border:1px solid var(--border);
border-radius:8px;
padding:6px 10px;
font-size:13px;
outline:none;
box-sizing:border-box;
}
input[type=text]:focus,input[type=number]:focus,input[type=url]:focus,
input[type=password]:focus,input[type=email]:focus,input[type=search]:focus{
border-color:var(--accent);
box-shadow:0 0 0 2px rgba(0,200,255,0.18);
}
input::placeholder{color:var(--txt2);opacity:1}
/* ── HEADER ── */
header{background:var(--card);border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
@@ -265,6 +301,8 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
.modal-field input{background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
.modal-field input:focus{outline:none;border-color:var(--accent)}
.set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.set-row label{font-size:12px;color:var(--txt2)}
.poll-btns{display:flex;gap:8px}
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}

View File

@@ -123,7 +123,7 @@
"lbl_light": "💡 Licht",
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=aus)",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
"lbl_spoolman_url": "Server-URL",
"lbl_unload": "Ausziehen",
"lbl_zpos": "Z (mm)",
@@ -189,8 +189,7 @@
"panel_extras_camera": "Kamera",
"panel_extras_fan": "Lüfter",
"panel_extras_light": "Licht",
"panel_motion_xy": "XY-Achsen",
"panel_motion_z": "Z-Achse",
"panel_motion_xy": "Achsensteuerung",
"panel_print_btn_cancel": "✕ Abbrechen",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Fortsetzen",
@@ -325,5 +324,6 @@
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
"update_error": "Fehler",
"update_none": "Bereits aktuell",
"update_restarting": "Starte neu..."
}
"update_restarting": "Starte neu...",
"slot_copy_from": "Farbe von Slot kopieren…"
}

View File

@@ -123,7 +123,7 @@
"lbl_light": "💡 Light",
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=off)",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
"lbl_spoolman_url": "Server URL",
"lbl_unload": "Unload",
"lbl_zpos": "Z (mm)",
@@ -189,8 +189,7 @@
"panel_extras_camera": "Camera",
"panel_extras_fan": "Fan",
"panel_extras_light": "Light",
"panel_motion_xy": "XY Axes",
"panel_motion_z": "Z Axis",
"panel_motion_xy": "Axes Control",
"panel_print_btn_cancel": "✕ Cancel",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Resume",
@@ -325,5 +324,6 @@
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Already up to date",
"update_restarting": "Restarting..."
}
"update_restarting": "Restarting...",
"slot_copy_from": "Copy color from slot…"
}

View File

@@ -123,7 +123,7 @@
"lbl_light": "💡 Luz",
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=desact.)",
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
"lbl_spoolman_url": "URL del servidor",
"lbl_unload": "Descargar",
"lbl_zpos": "Z (mm)",
@@ -189,8 +189,7 @@
"panel_extras_camera": "Cámara",
"panel_extras_fan": "Ventilador",
"panel_extras_light": "Luz",
"panel_motion_xy": "Ejes XY",
"panel_motion_z": "Eje Z",
"panel_motion_xy": "Control de Ejes",
"panel_print_btn_cancel": "✕ Cancelar",
"panel_print_btn_pause": "⏸ Pausa",
"panel_print_btn_resume": "▶ Reanudar",
@@ -325,5 +324,6 @@
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Ya actualizado",
"update_restarting": "Reiniciando..."
}
"update_restarting": "Reiniciando...",
"slot_copy_from": "Copiar color del slot…"
}

View File

@@ -123,7 +123,7 @@
"lbl_light": "💡 Lumière",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=désact.)",
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
"lbl_spoolman_url": "URL du serveur",
"lbl_unload": "Décharger",
"lbl_zpos": "Z (mm)",
@@ -189,8 +189,7 @@
"panel_extras_camera": "Caméra",
"panel_extras_fan": "Ventilateur",
"panel_extras_light": "Lumière",
"panel_motion_xy": "Axes XY",
"panel_motion_z": "Axe Z",
"panel_motion_xy": "Contrôle des Axes",
"panel_print_btn_cancel": "✕ Annuler",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Reprendre",
@@ -325,5 +324,6 @@
"update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d",
"update_error": "Erreur",
"update_none": "Déjà à jour",
"update_restarting": "Redémarrage…"
}
"update_restarting": "Redémarrage…",
"slot_copy_from": "Copier la couleur du slot…"
}

View File

@@ -123,7 +123,7 @@
"lbl_light": "💡 Luce",
"lbl_remaining": "Rimanente:",
"lbl_slicer_time": "Stima slicer:",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=disatt.)",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
"lbl_spoolman_url": "URL server",
"lbl_unload": "Rimuovi",
"lbl_zpos": "Z (mm)",
@@ -189,8 +189,7 @@
"panel_extras_camera": "Camera",
"panel_extras_fan": "Ventola",
"panel_extras_light": "Luce",
"panel_motion_xy": "Assi XY",
"panel_motion_z": "Asse Z",
"panel_motion_xy": "Controllo Assi",
"panel_print_btn_cancel": "✕ Annulla",
"panel_print_btn_pause": "⏸ Pausa",
"panel_print_btn_resume": "▶ Riprendi",
@@ -325,5 +324,6 @@
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
"update_error": "Errore",
"update_none": "Già aggiornato",
"update_restarting": "Riavvio in corso..."
}
"update_restarting": "Riavvio in corso...",
"slot_copy_from": "Copia colore dallo slot…"
}

View File

@@ -123,7 +123,7 @@
"lbl_light": "💡 灯光",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_spoolman_sync_rate": "同步频率0=关闭",
"lbl_spoolman_sync_rate": "同步频率0=打印结束",
"lbl_spoolman_url": "服务器地址",
"lbl_unload": "退料",
"lbl_zpos": "Z (mm)",
@@ -189,8 +189,7 @@
"panel_extras_camera": "相机",
"panel_extras_fan": "风扇",
"panel_extras_light": "灯光",
"panel_motion_xy": "XY 轴",
"panel_motion_z": "Z 轴",
"panel_motion_xy": "轴控制",
"panel_print_btn_cancel": "✕ 取消",
"panel_print_btn_pause": "⏸ 暂停",
"panel_print_btn_resume": "▶ 继续",
@@ -325,5 +324,6 @@
"update_docker_copied": "已复制执行docker compose pull && docker compose up -d",
"update_error": "错误",
"update_none": "已是最新版本",
"update_restarting": "重启中..."
}
"update_restarting": "重启中...",
"slot_copy_from": "从插槽复制颜色…"
}