Compare commits

..

2 Commits

61 changed files with 2320 additions and 5985 deletions

View File

@@ -1,31 +0,0 @@
---
name: KX-Bridge Changelog
description: Generiert einen CHANGELOG.md Eintrag aus Git-Commits seit dem letzten Tag.
tools:
- run_command
- read_file
- write_file
---
Du generierst CHANGELOG.md Einträge für KX-Bridge.
Vorgehen:
1. Führe aus: `git log $(git describe --tags --abbrev=0)..HEAD --oneline`
2. Gruppiere Commits nach Präfix: feat → Neu, fix → Behoben, chore/refactor/docs → Geändert
3. Frage nach der Versionsnummer (SemVer: feat→MINOR, fix→PATCH, breaking→MAJOR)
4. Schreibe den Abschnitt im Format:
```
## [VERSION] - DATUM
### Neu
- ...
### Behoben
- ...
### Geändert
- ...
```
5. Füge den Abschnitt am Anfang der bestehenden CHANGELOG.md ein, ohne vorhandene Einträge zu ändern.

View File

@@ -1,32 +0,0 @@
---
name: KX-Bridge Docker Check
description: Prüft Dockerfile, docker-compose und das gebaute Image auf häufige Probleme.
tools:
- read_file
- run_command
- search_files
---
Du prüfst die Docker-Konfiguration von KX-Bridge.
**Dockerfile:**
- Base-Image aktuell? (`python:3.11-slim` oder neuer)
- `.dockerignore` vorhanden und vollständig?
- Keine Secrets oder Zertifikate im Image (`anycubic_slicer.crt/.key` darf NICHT eingebettet sein)
- Healthcheck vorhanden?
- Kein `COPY . .` ohne `.dockerignore`
**docker-compose.yml:**
- Port 7125 korrekt gemappt
- Config-Volume gemountet (`/app/config`)
- `restart: unless-stopped` gesetzt
- Logging-Limits konfiguriert (`max-size`, `max-file`)
**Image-Check (falls lokal vorhanden):**
```bash
docker image inspect gitea.it-drui.de/viewit/kx-bridge:nightly
```
- Image-Größe sinnvoll (< 500MB)?
- Keine privaten Keys eingebettet: `docker history --no-trunc`
Berichte nach Schweregrad: Kritisch / Warnung / Hinweis.

View File

@@ -1,23 +0,0 @@
---
name: KX-Bridge Moonraker Debug
description: Analysiert Moonraker/Klipper Logs und KX-Bridge Ausgaben auf Fehlerursachen.
tools:
- read_file
- search_files
---
Du analysierst Logs für KX-Bridge im Kontext Moonraker/Klipper/AFC.
**Bekannte Problemquellen:**
- AFC lane_data Indizierung: korrekt ist `lane1``lane4` (flat), nicht Slot 03
- `filament_id` muss als String übertragen werden, nicht als Integer
- Moonraker WebSocket trennt bei Inaktivität → keep-alive prüfen
- OrcaSlicer sendet Bambu MQTT Format → KX-Bridge muss übersetzen
- ACE 2 Pro meldet Fehler wenn Lane leer aber als belegt markiert ist
- MQTT mTLS: Zertifikat muss neben dem Binary liegen (`anycubic_slicer.crt/.key`)
**Bei einem Log:**
1. Identifiziere den **ersten** Fehler (nicht den letzten Symptom)
2. Zeige den relevanten Log-Kontext (±10 Zeilen um den Fehler)
3. Nenne die wahrscheinliche Ursache
4. Schlage einen konkreten Fix vor (Datei + Funktion wenn möglich)

View File

@@ -1,25 +0,0 @@
---
name: KX-Bridge Nightly Prep
description: Bereitet den PR von nightly nach main vor. Prüft ob alle Voraussetzungen für ein Stable Release erfüllt sind.
tools:
- run_command
- read_file
---
Du bereitest einen nightly → main Merge für KX-Bridge vor.
Führe folgende Checks aus und berichte:
1. `git log main..nightly --oneline` → alle Commits die noch nicht in main sind
2. `git diff main..nightly -- CHANGELOG.md` → ist CHANGELOG.md für alle Änderungen aktualisiert?
3. Prüfe ob `tests/` alle geänderten Module abdeckt
4. Prüfe ob Dockerfile ein aktuelles Base-Image verwendet
5. Schlage eine SemVer-Versionsnummer vor:
- `feat:` Commits → MINOR erhöhen
- `fix:` Commits → PATCH erhöhen
- Breaking Change im Commit-Body → MAJOR erhöhen
Abschlussbericht:
- ✅ Bereit für Release
- ⚠️ Offen: [Liste]
- ❌ Blockiert durch: [Grund]

View File

@@ -1,24 +0,0 @@
---
name: KX-Bridge Reviewer
description: Reviewt geänderte Dateien vor einem PR auf nightly. Prüft Logik, Fehlerbehandlung, Moonraker-Kompatibilität und Stil.
tools:
- read_file
- list_directory
- search_files
---
Du bist Code-Reviewer für KX-Bridge — eine Python-Bridge zwischen OrcaSlicer und Moonraker/Klipper für den Anycubic Kobra X.
Beim Review prüfst du:
- Korrekte Fehlerbehandlung bei Moonraker HTTP/MQTT Calls (keine unbehandelten Exceptions)
- Keine hardcodierten IPs oder Ports (müssen aus config.ini kommen)
- Thread-Sicherheit bei parallelen Moonraker-Abfragen (asyncio korrekt verwendet)
- AFC lane_data Struktur: flache Indizierung lane1lane4, kein Slot-Mapping 03
- Kein `print()` statt `logging` (außer in CLI-Hilfsfunktionen)
- Typ-Annotationen vorhanden, Python 3.8+ kompatibel (kein `X | Y` Syntax)
- Tests für neue öffentliche Funktionen vorhanden
Ausgabeformat:
1. **Kritische Fehler** — blockieren den Merge
2. **Warnungen** — sollten vor Merge behoben werden
3. **Hinweise** — optional, für zukünftige Verbesserungen

View File

@@ -1,26 +0,0 @@
---
name: KX-Bridge Test Writer
description: Leitet pytest-Tests aus geänderten oder neuen Python-Dateien ab.
tools:
- read_file
- write_file
- list_directory
- search_files
---
Du schreibst pytest-Tests für KX-Bridge.
Kontext:
- Moonraker API läuft auf Port 7125 (HTTP + WebSocket)
- AFC lane_data: flache Indizierung lane1lane4
- Externe HTTP-Calls zu Moonraker werden mit `unittest.mock` gemockt
- Python 3.8+ Kompatibilität (kein `X | Y` Union-Syntax)
Für jede zu testende Funktion schreibst du:
1. Happy Path (Normalfall mit validen Eingaben)
2. Fehlerfall (Moonraker nicht erreichbar, Timeout, falsche Antwort)
3. Grenzwerte (leere lane_data, ungültige filament_id, None-Werte)
Dateiname: `tests/test_<modulname>.py`
Verwende pytest-Fixtures für Moonraker-Mock-Responses.
Keine echten Netzwerkaufrufe in Tests.

View File

@@ -1,12 +0,0 @@
{
"project": "KX-Bridge",
"language": "de",
"defaultAgent": "reviewer",
"context": {
"repoBase": "gitea.it-drui.de/viewit/KX-Bridge-Release",
"defaultBranch": "nightly",
"stableBranch": "main",
"registry": "gitea.it-drui.de/viewit/kx-bridge",
"moonrakerPort": 7125
}
}

View File

@@ -1,29 +0,0 @@
---
name: Bug Report
about: Report a bug in KX-Bridge
labels: bug
---
## Description
<!-- What is happening? -->
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
## Actual Behavior
## Environment
- KX-Bridge Version:
- OrcaSlicer Version:
- Moonraker/Klipper Version:
- Operating System:
- Installation: Docker / Binary
## Logs
```
<!-- docker logs kx-bridge --tail 50 -->
```

View File

@@ -1,14 +0,0 @@
---
name: Feature Request
about: Suggest a new feature or improvement
labels: enhancement
---
## Description
<!-- What should be added or improved? -->
## Motivation
<!-- Why is this useful? What problem does it solve? -->
## Proposed Implementation
<!-- Optional: How could this be implemented technically? -->

View File

@@ -1,12 +0,0 @@
#!/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

@@ -1,21 +0,0 @@
## Description
<!-- What does this PR change? -->
## Related Issue
Closes #
## Type
- [ ] Bug fix
- [ ] Feature
- [ ] Documentation
- [ ] Refactoring
## Tested with
- OrcaSlicer Version:
- Printer:
- Moonraker/Klipper Version:
## Checklist
- [ ] Tests added/updated
- [ ] CHANGELOG.md updated
- [ ] No debug code included

View File

@@ -1,176 +0,0 @@
name: Nightly Build
on:
push:
branches:
- nightly
paths:
- '**.py'
- 'Dockerfile'
- 'requirements.txt'
- 'web/**'
- 'data/**'
- '.gitea/workflows/nightly.yml'
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
jobs:
build:
runs-on: server-runner
steps:
- name: Checkout
run: |
if [ -d .git ]; then
git fetch --tags origin nightly
git reset --hard origin/nightly
git clean -fd
else
git clone --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
fi
- name: Install Docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
DARCH="x86_64"
BARCH="amd64"
else
DARCH="aarch64"
BARCH="arm64"
fi
wget -qO- "https://download.docker.com/linux/static/stable/${DARCH}/docker-27.5.1.tgz" \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
chmod +x /usr/local/bin/docker
mkdir -p /usr/local/lib/docker/cli-plugins
wget -qO /usr/local/lib/docker/cli-plugins/docker-buildx \
"https://github.com/docker/buildx/releases/download/v0.23.0/buildx-v0.23.0.linux-${BARCH}"
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
fi
docker version --format '{{.Client.Version}}'
- name: Set up QEMU
run: |
docker run --rm --privileged tonistiigi/binfmt:latest --install all
- name: Set up buildx
run: |
docker buildx inspect kxbuilder 2>/dev/null || \
docker buildx create --name kxbuilder --use
docker buildx use kxbuilder
- name: Login to Gitea registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Compute nightly version
run: |
# Letzten Stable-Tag ermitteln (v0.9.27 → minor=27)
LAST_STABLE=$(git tag --list 'v*' --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | { read -r line; echo "$line"; cat >/dev/null; } || true)
if [ -z "$LAST_STABLE" ]; then
echo "ERROR: kein Stable-Tag gefunden" >&2; exit 1
fi
# Nächste Minor-Version: v0.9.27 → 0.9.28
MAJOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f1)
MINOR=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f2)
PATCH=$(echo "$LAST_STABLE" | sed 's/^v//' | cut -d. -f3)
NEXT_PATCH=$((PATCH + 1))
BASE="${MAJOR}.${MINOR}.${NEXT_PATCH}"
# Laufende Nummer: Anzahl vorhandener nightly-<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: |
. /tmp/nightly_version.env
# VERSION-Datei im Arbeitsverzeichnis für den Docker-Build setzen (kein Commit)
echo "$VERSION" > VERSION
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push \
--provenance=false \
--no-cache \
-t "gitea.it-drui.de/viewit/kx-bridge:nightly" \
-t "gitea.it-drui.de/viewit/kx-bridge:nightly-${VERSION}" \
.
- name: Create Gitea Nightly Release
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
. /tmp/nightly_version.env
TAG="nightly-${VERSION}"
# Letzten Stable-Tag als Changelog-Basis (nur echte vX.Y.Z-Tags)
PREV_TAG=$(git tag --list 'v*' --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| { read -r line; echo "$line"; cat >/dev/null; } || true)
[ -z "$PREV_TAG" ] && PREV_TAG=$(git rev-list --max-parents=0 HEAD)
# Changelog: NIGHTLY_CHANGELOG.md hat Vorrang (manuell gepflegt),
# sonst auto-generiert aus feat/fix-Commits seit letztem Stable-Tag
BODY_FILE=$(mktemp)
printf '## KX-Bridge %s — Nightly Build\n\n' "$VERSION" > "$BODY_FILE"
printf '[experimental] Untested features, for testers only.\n\n' >> "$BODY_FILE"
if [ -s NIGHTLY_CHANGELOG.md ]; then
cat NIGHTLY_CHANGELOG.md >> "$BODY_FILE"
else
printf '### Changes since `%s`\n\n' "$PREV_TAG" >> "$BODY_FILE"
git log "${PREV_TAG}..HEAD" --pretty=format:'%s' --no-merges \
| grep -E '^(feat|fix)[:(]' \
| grep -Ev '^(feat|fix)\((ci|release|build|workflow)\)' \
| sed 's/^/- /' \
>> "$BODY_FILE" || true
if ! grep -q '^- ' "$BODY_FILE"; then
printf '- No user-facing changes in this build\n' >> "$BODY_FILE"
fi
fi
printf '\n\n---\n\n### Update Docker image\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n' >> "$BODY_FILE"
printf 'Image tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`\n' >> "$BODY_FILE"
# Tag setzen
git tag "$TAG"
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git "$TAG"
# curl + jq installieren falls nötig
if ! command -v curl >/dev/null 2>&1; then
apk add --no-cache curl 2>/dev/null || \
{ wget -qO /usr/local/bin/curl \
"https://github.com/moparisthebest/static-curl/releases/download/v8.6.0/curl-amd64" \
&& chmod +x /usr/local/bin/curl; }
fi
if ! command -v jq >/dev/null 2>&1; then
ARCH=$(uname -m)
JQ_ARCH="amd64"; [ "$ARCH" = "aarch64" ] && JQ_ARCH="arm64"
wget -qO /usr/local/bin/jq \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-${JQ_ARCH}"
chmod +x /usr/local/bin/jq
fi
# Altes Release löschen falls vorhanden
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases/tags/${TAG}" \
2>/dev/null || true
# Release erstellen — JSON sicher via jq bauen
jq -n \
--arg tag "$TAG" \
--arg name "KX-Bridge ${VERSION} Nightly" \
--rawfile body "$BODY_FILE" \
'{"tag_name":$tag,"name":$name,"body":$body,"draft":false,"prerelease":true}' \
> /tmp/release_body.json
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary @/tmp/release_body.json \
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases"
rm -f "$BODY_FILE" /tmp/release_body.json

View File

@@ -1,34 +0,0 @@
name: PR Check
on:
pull_request:
branches:
- nightly
jobs:
lint-and-test:
runs-on: server-runner
steps:
- name: Checkout
run: |
if [ -d .git ]; then
git fetch origin
git reset --hard origin/nightly
git clean -fd
else
git clone --depth=1 --branch nightly https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
fi
- name: Dependencies installieren
run: pip3 install -r requirements.txt
- name: Lint
run: |
pip3 install flake8
flake8 *.py --max-line-length=120 --extend-ignore=E501
- name: Tests
run: |
pip3 install pytest
pytest tests/ -v
if: ${{ hashFiles('tests/') != '' }}

View File

@@ -1,74 +0,0 @@
name: Stable Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: server-runner
steps:
- name: Checkout
run: |
TAG="${GITHUB_REF#refs/tags/}"
if [ -d .git ]; then
git fetch --tags origin
git checkout "$TAG"
git clean -fd
else
git clone --depth=1 --branch "$TAG" https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
fi
- name: Install Docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
DARCH="x86_64"
BARCH="amd64"
else
DARCH="aarch64"
BARCH="arm64"
fi
wget -qO- "https://download.docker.com/linux/static/stable/${DARCH}/docker-27.5.1.tgz" \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
chmod +x /usr/local/bin/docker
mkdir -p /usr/local/lib/docker/cli-plugins
wget -qO /usr/local/lib/docker/cli-plugins/docker-buildx \
"https://github.com/docker/buildx/releases/download/v0.23.0/buildx-v0.23.0.linux-${BARCH}"
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
fi
docker version --format '{{.Client.Version}}'
- name: Set up QEMU
run: |
docker run --rm --privileged tonistiigi/binfmt:latest --install all
- name: Set up buildx
run: |
docker buildx inspect kxbuilder 2>/dev/null || \
docker buildx create --name kxbuilder --use
docker buildx use kxbuilder
- name: Login to Gitea registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# Strip fuehrendes 'v' fuer den Image-Tag (VERSION-Datei hat kein 'v').
- name: Build & push (amd64 + arm64)
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push \
--provenance=false \
--no-cache \
-t "gitea.it-drui.de/viewit/kx-bridge:latest" \
-t "gitea.it-drui.de/viewit/kx-bridge:${VERSION}" \
.
# Hinweis: Das Gitea-Release (inkl. englischem Auto-Changelog + Binaries als
# Assets) erstellt release.sh synchron, da es die lokal via CodeBuilder
# gebauten Binaries direkt hochlaedt. Dieser Workflow baut nur das Docker-Image.

9
.gitignore vendored
View File

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

View File

@@ -1,21 +1,5 @@
# Changelog
## [0.9.26] 2026-06-21
### Neu
- **Italienische Sprachunterstützung** (PR #66, @Alex_M). Die Bridge-UI ist jetzt vollständig auf Italienisch verfügbar.
### Behoben
- **Kamera startete immer beim Druckbeginn** (Issue #50). `camera_on_print` fehlte in der `/api/state`-Antwort — JavaScript las `undefined` und startete die Kamera unabhängig vom Setting. Jetzt korrekt im State enthalten.
- **Auto-Leveling-Setting wurde im Moonraker-Druckpfad ignoriert** (Issue #57). `handle_print_start` las den Wert nur aus den Bridge-Args, nicht aus dem Request-Body — Dialog-Checkbox und Per-Print-Override hatten keine Wirkung. Verhält sich jetzt identisch zum direkten Druckpfad.
- **Filament-Mapping: Freitext-Felder durch Dropdowns ersetzt** (Issue #57). Falsch getippte Vendor/Name-Kombination brach das Profil-Matching ohne Fehlermeldung; Felder sind jetzt Dropdowns (Vendor → Profil, vendor-gefiltert), sodass nur gültige Kombinationen gespeichert werden können.
- **Dashboard zeigte generischen Materialtyp statt Profilname** (Issue #57). AMS-Slot-Karten zeigen jetzt den gemappten Profilnamen (z.B. „eSUN PLA-Basic") statt nur „PLA". Fallback auf generischen Typ wenn kein Profil gemappt ist.
- **Ghost-Profil auf leerem Slot** (Issue #57). Verwaiste Mappings für leere Slots wurden weiterhin angezeigt; leere Slots zeigen jetzt korrekt „–".
- **Skip-Objects-Panel fehlte im Orca-Upload-Flow** (Issue #57). Panel erscheint jetzt in allen Druckflows; bei frischem Upload fragt die Bridge `fileDetails` beim Drucker nach und pollt die Objektliste bis zu 6 Sekunden nach.
- **Banner und Dialog erschienen gleichzeitig** (Issue #57). Settings-Save setzt jetzt den Dialog-Cancel-State zurück, sodass der Slot-Mapper nach Wechsel des Start-Print-Verhaltens zuverlässig öffnet.
- **„Leeren" lud idle-Datei beim nächsten Poll nach** (Issue #57). Leeren setzt jetzt den lokalen State sofort zurück (`file_ready`, `filename`, `thumbnail`) und löscht alle Dialog-Sperren — Vorschaubild und Aktions-Buttons verschwinden sofort und kommen nicht zurück.
- **Material-Matching für „PLA Silk", „Matte PLA" etc.** (PR #64, @p2l). Modifier+Basis-Muster in beliebiger Wortreihenfolge werden jetzt auf den Basis-Typ normalisiert; Dash-Varianten (PLA-CF) bleiben weiterhin korrekt inkompatibel mit ihrem Basis-Typ.
## [0.9.25] 2026-06-17
### Behoben

View File

@@ -1,56 +1,5 @@
# 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
- **Italian language support** (PR #66, @Alex_M). The bridge UI is now fully
available in Italian.
### Fixed
- **Camera always started at print begin** (issue #50). `camera_on_print` was
missing from the `/api/state` response — JavaScript read `undefined` and started
the camera regardless of the setting. Now correctly exposed in state.
- **Auto-leveling setting ignored in Moonraker print path** (issue #57).
`handle_print_start` read the value only from bridge args, not from the request
body, so the dialog checkbox and the per-print override had no effect. Now
behaves identically to the direct print path.
- **Filament mapping free-text fields replaced by dropdowns** (issue #57). A
mistyped vendor/name broke profile matching silently; fields are now dropdowns
(vendor → profile, vendor-filtered) so only valid combinations can be saved.
- **Dashboard showed generic material type instead of profile name** (issue #57).
AMS slot cards now display the mapped profile name (e.g. "eSUN PLA-Basic")
instead of just "PLA". Falls back to the generic type when no profile is mapped.
- **Ghost profile shown on empty slot** (issue #57). Stale mappings for empty
slots were still rendered; empty slots now correctly show "".
- **Skip-Objects panel missing in Orca upload flow** (issue #57). Panel now
appears in all print flows; on fresh upload the bridge requests `fileDetails`
from the printer and retries the object list for up to 6 s.
- **Banner and dialog appeared simultaneously** (issue #57). Settings save now
resets the dialog cancel state so the slot mapper reliably opens after toggling
Start Print Behavior.
- **"Clear" reloaded idle file on next poll** (issue #57). Clear now immediately
resets local state (`file_ready`, `filename`, `thumbnail`) and clears all dialog
locks — the preview and action buttons disappear instantly and do not return.
- **Material matching for "PLA Silk", "Matte PLA" etc.** (PR #64, @p2l).
Modifier+base patterns in any word order are now normalised to the base type;
dash-suffix variants (PLA-CF) remain correctly incompatible with their base.
## [0.9.25] 2026-06-17
### Fixed

View File

@@ -1,102 +0,0 @@
# Contributing to KX-Bridge
Thanks for taking the time to contribute! Here's everything you need to know.
---
## How to report a bug or request a feature
Use the issue tracker:
- **Bug:** [New Bug Report](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues/new?template=bug_report.md)
- **Feature:** [New Feature Request](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues/new?template=feature_request.md)
Please fill in the template — especially the **KX-Bridge version** and **logs**.
Issues without version info are hard to debug.
---
## How to submit a Pull Request
### 1. Fork the repository
Click **Fork** at the top of this page.
You now have your own copy at `gitea.it-drui.de/your-username/KX-Bridge-Release`.
### 2. Clone your fork
```bash
git clone https://gitea.it-drui.de/your-username/KX-Bridge-Release.git
cd KX-Bridge-Release
```
### 3. Create a branch
Always branch off `nightly`:
```bash
git checkout nightly
git checkout -b feature/my-feature # or fix/my-fix
```
### 4. Make your changes
- Test your changes locally with Docker:
```bash
docker build -t kx-bridge:dev .
docker run -p 7125:7125 -v ./config:/app/config kx-bridge:dev
```
- No debug `print()` statements — use `logging`
- Keep commits focused; one thing per commit
### 5. Push and open a PR
```bash
git push origin feature/my-feature
```
Gitea will show a banner — click **"Create Pull Request"**.
The PR template will be pre-filled. Set the target branch to **`nightly`**.
---
## Branch model
```
master ← stable releases only (merged by maintainer)
nightly ← integration branch — PRs go here
feature/* ← your feature branch (in your fork)
fix/* ← your bugfix branch (in your fork)
```
Your PR always targets `nightly`. The maintainer periodically merges `nightly → master` for a new stable release.
---
## Commit style
Use conventional commit prefixes:
| Prefix | When |
|---|---|
| `feat:` | new feature |
| `fix:` | bug fix |
| `docs:` | documentation only |
| `chore:` | maintenance, dependencies |
| `refactor:` | code change without new feature or fix |
Example: `fix: prevent crash when printer is offline during startup`
---
## Language
- **Code and comments:** English
- **Issue comments:** match the language of the issue (if someone writes in German, reply in German)
- **Commit messages:** English
---
## Questions?
Open a [Discussion](https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues) or leave a comment on the relevant issue.

View File

@@ -2,11 +2,10 @@ FROM python:3.11-slim-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg gcc python3-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY 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/*
RUN pip install --no-cache-dir -r requirements.txt
COPY kobrax_moonraker_bridge.py .
COPY web/ ./web/

View File

@@ -1,8 +0,0 @@
## Changes in this build
- Fix: the poll interval setting was saved to config.ini but never actually applied — the poll loop always used a hardcoded 3s wait
- Fix: Spoolman status showed a green "connected" dot even when the server was unreachable — reachability is now rechecked periodically and shown accurately (green/red)
- Fix: **settings could silently revert after saving** — the bridge restart that applies settings didn't clean up all the relevant environment variables, so the old value could win over the one just saved (affected poll interval, resonance compensation, Docker host IP, and the new HTTP log toggle below). Fixed at the root so this class of bug can't recur for future settings.
- Feat: new "Log every HTTP request (verbose)" toggle in Settings — off by default, since aiohttp's per-request access log was drowning out the bridge's own logs with the frontend's 2s polling
**A stable release is coming soon** with the fixes and features from the last several nightly builds. As usual it will be published as ready-to-run binaries (Linux amd64/arm64, Windows) alongside the Docker image.

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-3.1k%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-800%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,11 +29,6 @@ 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?
@@ -50,7 +45,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 / FR / IT / 中文, Browser-Sprache automatisch erkannt |
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / 中文, 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 |
@@ -189,24 +184,6 @@ 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>

View File

@@ -1,147 +0,0 @@
<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

@@ -20,7 +20,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
&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-3.1k%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-800%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,11 +28,6 @@ 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
@@ -49,7 +44,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 / FR / IT / 中文, detecta automáticamente el idioma del navegador |
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / 中文, 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 |
@@ -71,17 +66,43 @@ docker compose up -d
**Binario Linux (sin Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64
```
**EXE Windows (sin Docker):**
```
kx-bridge.exe
```
> `config\` y `data\` se crean junto al EXE — instalación portátil.
> 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.
> ⚠️ **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.
**Python directamente:**
```bash
@@ -187,24 +208,6 @@ 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-3.1k%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-800%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,11 +28,6 @@ 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
@@ -49,7 +44,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 / FR / IT / 中文, auto-detect browser locale |
| 🌐 | **Multi-language UI** — DE / EN / ES / 中文, 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 |
@@ -187,24 +182,6 @@ 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
0.9.25

View File

@@ -1,31 +0,0 @@
# KX-Bridge Claude Agents
## Available Agents
| Agent | File | When to use |
|---|---|---|
| Reviewer | `.claude/agents/reviewer.md` | Before every PR — checks logic, error handling, Moonraker compatibility |
| Changelog | `.claude/agents/changelog.md` | After merge to nightly — generates CHANGELOG.md entry from commits |
| Test Writer | `.claude/agents/test-writer.md` | When adding new functions — derives pytest tests |
| Nightly Prep | `.claude/agents/nightly-prep.md` | Before a release — checks readiness of nightly → main merge |
| Docker Check | `.claude/agents/docker-check.md` | Before image push — validates Dockerfile and compose config |
| Moonraker Debug | `.claude/agents/moonraker-debug.md` | On runtime errors — analyzes Moonraker/Klipper logs |
## Usage
In VS Code with Claude Code extension:
```
@reviewer → code review of current changes
@changelog → generate CHANGELOG entry
@test-writer → write tests for changed files
@nightly-prep → check release readiness
@docker-check → validate Docker config
@moonraker-debug → analyze logs
```
## Context
- Moonraker API: Port 7125
- AFC lane_data: flat indexing lane1lane4
- Registry: `gitea.it-drui.de/viewit/kx-bridge`
- Default PR target: `nightly`

View File

@@ -41,21 +41,6 @@ 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

@@ -31,88 +31,6 @@ default_ams_slot = auto
# Auto-Leveling vor jedem Druck (1 = an, 0 = aus)
auto_leveling = 1
# Kamera-Stream bei Druckstart automatisch einschalten (1 = an, 0 = aus)
camera_on_print = 0
# Warnung vor Druck von Web-Uploads (1 = an, 0 = aus)
web_upload_warning = 1
# Nach Upload: Filament/Color-Selector automatisch öffnen (1 = an, 0 = aus)
print_start_dialog = 1
# ─── Filament-Profile pro AMS-Slot (optional) ────────────────────────────────
# Beim Slicer-Sync nimmt OrcaSlicer per Default immer "Generic PLA/PETG/...".
# Mit diesen Mappings sendet die Bridge die konkrete Orca-Filament-ID +
# Vendor mit (Anzeige im Slicer dann z.B. "PolyTerra PLA — Polymaker" statt
# nur "Generic PLA"). Mapping wird über die Web-UI gepflegt.
# Beispiel:
# [filament_profiles]
# slot_0_id = OGFL01
# slot_0_vendor = Polymaker
# slot_1_id = OGFG23
# slot_1_vendor = Polymaker
[bridge]
# Poll-Intervall in Sekunden
poll_interval = 3
# ─── Multi-Printer (optional) ──────────────────────────────────────────────────
# Mehrere Drucker können als [printer_1], [printer_2], … definiert werden.
# Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port).
# bridge_url zeigt auf die jeweilige Bridge-Instanz (für den /kx/printers-Endpunkt).
# Die [connection]-Sektion wird weiterhin als Fallback für diese Instanz verwendet.
#
# Beispiel:
# [printer_1]
# name = Kobra X Links
# bridge_url = http://192.168.178.95:7125
# printer_ip = 192.168.178.95
# mqtt_port = 9883
# username = userXXXXXXXXXX
# password = XXXXXXXXXXXXXXX
# device_id = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# mode_id = 20030
#
# [printer_2]
# name = Kobra X Rechts
# bridge_url = http://192.168.178.96:7125
# printer_ip = 192.168.178.96
# mqtt_port = 9883
# username = userYYYYYYYYYY
# password = YYYYYYYYYYYYYYY
# device_id = yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
# mode_id = 20030
[ace_dry_presets]
# Vordefinierte Dry-Set Presets (Temp in °C, Dauer in Sekunden)
pla_temp = 45
pla_duration_sec = 14400
pla_plus_temp = 45
pla_plus_duration_sec = 14400
petg_temp = 50
petg_duration_sec = 14400
tpu_temp = 55
tpu_duration_sec = 14400
abs_asa_temp = 45
abs_asa_duration_sec = 28800
pa_pc_temp = 55
pa_pc_duration_sec = 43200
# Custom Presets (Name + Temp + Dauer)
custom_1_name = Custom 1
custom_1_temp = 45
custom_1_duration_sec = 14400
custom_2_name = Custom 2
custom_2_temp = 45
custom_2_duration_sec = 14400
custom_3_name = Custom 3
custom_3_temp = 45
custom_3_duration_sec = 14400
[spoolman]
# URL der Spoolman-Instanz (leer lassen um Spoolman zu deaktivieren)
# server = http://192.168.x.x:7912
# Wie oft (Sekunden) der Filamentverbrauch während des Drucks gemeldet wird
# (0 = nur beim Druckende)
# sync_rate = 0

View File

@@ -1,20 +1,18 @@
"""
config_loader.py - loads connection parameters from config/config.ini (primary)
or .env (fallback / migration).
Environment variables always take precedence.
config_loader.py lädt Verbindungsparameter aus config/config.ini (primär)
oder .env (Fallback / Migration).
Umgebungsvariablen haben immer Vorrang.
"""
import os
import sys
import pathlib
import configparser
from typing import Optional
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
CONFIG_SECTION_CONNECTION = "connection"
CONFIG_SECTION_PRINT = "print"
CONFIG_SECTION_BRIDGE = "bridge"
CONFIG_SECTION_SPOOLMAN = "spoolman"
def _find_config_file() -> pathlib.Path | None:
@@ -34,7 +32,7 @@ def _find_env_file() -> pathlib.Path | None:
def _load_env_file(path: pathlib.Path):
"""Loads the .env file as a fallback - only sets keys not yet in os.environ."""
"""Lädt .env-Datei als Fallback setzt nur Keys die noch nicht in os.environ sind."""
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
@@ -47,39 +45,26 @@ def _load_env_file(path: pathlib.Path):
os.environ[key] = val
# Single source of truth for env-var <-> config.ini mapping. _restart_bridge()
# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ
# before restarting, so a value removed here or in the UI settings save can
# never survive as a stale env var read by the new process. Add new settings
# here ONLY - no second list to keep in sync.
CONFIG_ENV_MAPPING = {
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
"POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"),
"VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"),
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
}
def _load_config_file(path: pathlib.Path):
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
"""Lädt config.ini und setzt Keys in os.environ (nur wenn nicht bereits gesetzt)."""
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
mapping = {
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
}
for env_key, (section, option) in mapping.items():
if env_key not in os.environ:
try:
val = cfg.get(section, option)
@@ -124,9 +109,8 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
}
cfg[CONFIG_SECTION_PRINT] = {
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
}
cfg[CONFIG_SECTION_BRIDGE] = {
@@ -134,12 +118,12 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
}
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n")
f.write("# Automatically migrated from .env\n\n")
f.write("# Automatisch migriert aus .env\n\n")
cfg.write(f)
def find_config_path() -> pathlib.Path:
"""Returns the path to config.ini (even if it does not exist yet)."""
"""Gibt den Pfad zur config.ini zurück (auch wenn sie noch nicht existiert)."""
for base in (_BASE, _BASE.parent):
config_dir = base / "config"
if config_dir.is_dir():
@@ -155,7 +139,7 @@ _env_path = _find_env_file()
if _config_path:
_load_config_file(_config_path)
elif _env_path:
# No config.ini present -> migrate from .env
# Kein config.ini vorhanden → aus .env migrieren
_target = find_config_path()
migrate_env_to_config(_env_path, _target)
_load_config_file(_target)
@@ -163,13 +147,13 @@ elif _env_path:
def list_printers() -> list[dict]:
"""Reads all [printer_N] sections from config.ini.
"""Liest alle [printer_N]-Sektionen aus config.ini.
Each section may contain the following keys:
Jede Sektion kann folgende Keys haben:
name, printer_ip, mqtt_port, username, password, mode_id, device_id,
bridge_url, default_ams_slot, auto_leveling
Returns an empty list when no [printer_N] sections exist
Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind
(Single-Printer-Betrieb via [connection]).
"""
path = _find_config_file()
@@ -195,43 +179,25 @@ def list_printers() -> list[dict]:
return printers
def _filament_section(printer_id: Optional[str] = None) -> str:
"""Section name holding a printer's filament-profile mapping.
def list_filament_profiles() -> dict[int, dict]:
"""Liest die [filament_profiles]-Sektion aus config.ini.
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]:
"""Reads the [filament_profiles] section from 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 per AMS slot - the primary selector is (vendor, name); the `id` is
looked up from orca_filaments.json on save and carried along
(as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing
the same filament_id like 'OGFL99', i.e. the ID is not unique):
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
[filament_profiles]
slot_0_vendor = Polymaker
slot_0_name = PolyTerra PLA
slot_0_id = OGFL01
Returns a dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}.
Empty/missing slots are NOT included - the default mapping
(per filament_type) in the bridge then stays active.
Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}
zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
(per filament_type) in der Bridge bleibt dann aktiv.
Backwards compat: old configs with only (vendor, id) stay readable; `name`
is then missing and the caller can optionally resolve it from orca_filaments.json
Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name`
fehlt dann und der Aufrufer kann optional aus der orca_filaments.json
rekonstruieren.
"""
path = _find_config_file()
@@ -239,14 +205,11 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
return {}
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
section = _filament_section(printer_id)
if not cfg.has_section(section):
section = "filament_profiles" # fallback: legacy global section
if not cfg.has_section(section):
if not cfg.has_section("filament_profiles"):
return {}
result: dict[int, dict] = {}
for key, value in cfg.items(section):
# Expects: slot_<idx>_id or slot_<idx>_vendor or slot_<idx>_name
for key, value in cfg.items("filament_profiles"):
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
if not key.startswith("slot_"):
continue
parts = key.split("_", 2)
@@ -265,173 +228,74 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
return result
def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool:
"""Writes the given slot profiles into the [filament_profiles]
section of config.ini. Existing entries are completely replaced.
def save_filament_profiles(profiles: dict[int, dict]) -> bool:
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
At least vendor+name must be set; id is optional (hint).
With ``printer_id`` set, writes the per-printer ``[filament_profiles_<id>]``
section only — other printers and the legacy global section are untouched.
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
"""
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) is not a slot mapping - preserve it when
# replacing the section, otherwise the vendor filter is lost on slot save.
# First save of a per-printer section inherits the legacy global filter.
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
preserved_vendors = None
if cfg.has_option(section, "visible_vendors"):
preserved_vendors = cfg.get(section, "visible_vendors")
elif cfg.has_option("filament_profiles", "visible_vendors"):
if cfg.has_option("filament_profiles", "visible_vendors"):
preserved_vendors = cfg.get("filament_profiles", "visible_vendors")
if cfg.has_section(section):
cfg.remove_section(section)
if cfg.has_section("filament_profiles"):
cfg.remove_section("filament_profiles")
if profiles or preserved_vendors:
cfg[section] = {}
cfg["filament_profiles"] = {}
if preserved_vendors:
cfg[section]["visible_vendors"] = preserved_vendors
cfg["filament_profiles"]["visible_vendors"] = preserved_vendors
for slot_idx in sorted(profiles.keys()):
entry = profiles[slot_idx] or {}
if entry.get("vendor"):
cfg[section][f"slot_{slot_idx}_vendor"] = entry["vendor"]
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
if entry.get("name"):
cfg[section][f"slot_{slot_idx}_name"] = entry["name"]
cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"]
if entry.get("id"):
cfg[section][f"slot_{slot_idx}_id"] = entry["id"]
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
with open(path, "w", encoding="utf-8") as f:
cfg.write(f)
return True
def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
"""Reads [filament_profiles] visible_vendors (comma-separated) from config.ini.
def list_visible_vendors() -> list[str]:
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
Vendor visibility filter for the slot profile dropdown (Issue #41 option A).
Empty list = no restriction (backwards compatible: all vendors).
With ``printer_id`` set, reads the per-printer section and falls back to the
legacy global ``[filament_profiles]`` filter.
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
"""
path = _find_config_file()
if not path:
return []
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
section = _filament_section(printer_id)
if not cfg.has_option(section, "visible_vendors"):
section = "filament_profiles" # fallback: legacy global section
if not cfg.has_option(section, "visible_vendors"):
if not cfg.has_option("filament_profiles", "visible_vendors"):
return []
raw = cfg.get(section, "visible_vendors")
raw = cfg.get("filament_profiles", "visible_vendors")
return [v.strip() for v in raw.split(",") if v.strip()]
def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool:
"""Writes visible_vendors into [filament_profiles] without touching the
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.
With ``printer_id`` set, writes the per-printer section. When that section is
created here for the first time, the slot mappings are seeded from the legacy
global section so they are not orphaned by the read-fallback in
``list_filament_profiles``."""
def save_visible_vendors(vendors: list[str]) -> bool:
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder."""
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
section = _filament_section(printer_id)
if not cfg.has_section(section):
cfg.add_section(section)
if section != "filament_profiles" and cfg.has_section("filament_profiles"):
for key, value in cfg.items("filament_profiles"):
if key.startswith("slot_"):
cfg[section][key] = value
if not cfg.has_section("filament_profiles"):
cfg.add_section("filament_profiles")
clean = [v.strip() for v in (vendors or []) if v and v.strip()]
if clean:
cfg[section]["visible_vendors"] = ", ".join(clean)
elif cfg.has_option(section, "visible_vendors"):
cfg.remove_option(section, "visible_vendors")
with open(path, "w", encoding="utf-8") as f:
cfg.write(f)
return True
def _spoolman_map_section(printer_id: Optional[str] = None) -> str:
"""Section name holding a printer's AMS-slot → Spoolman-spool map.
Multi-printer (one bridge, N printers): each printer keeps its map in its
own ``[spoolman_<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")
cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean)
elif cfg.has_option("filament_profiles", "visible_vendors"):
cfg.remove_option("filament_profiles", "visible_vendors")
with open(path, "w", encoding="utf-8") as f:
cfg.write(f)
return True
@@ -441,7 +305,7 @@ def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
# Frequently used shortcuts
# Häufig verwendete Shortcuts
PRINTER_IP = get("PRINTER_IP", "")
MQTT_PORT = int(get("MQTT_PORT", "9883"))
USERNAME = get("MQTT_USERNAME", "")
@@ -449,13 +313,7 @@ PASSWORD = get("MQTT_PASSWORD", "")
MODE_ID = get("MODE_ID", "")
DEVICE_ID = get("DEVICE_ID", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0"))

View File

@@ -1,210 +0,0 @@
# KobraX Full Stack — KX-Bridge + Obico Self-Hosted + Spoolman
#
# Für Portainer: Stack → Add Stack → Upload → diese Datei wählen
#
# Voraussetzung: Obico-Images einmalig in Gitea-Registry pushen:
# docker tag obico-server-web:latest gitea.it-drui.de/viewit/obico-web:latest
# docker tag obico-server-ml_api:latest gitea.it-drui.de/viewit/obico-ml:latest
# docker tag obico-server-tasks:latest gitea.it-drui.de/viewit/obico-tasks:latest
# docker push gitea.it-drui.de/viewit/obico-web:latest
# docker push gitea.it-drui.de/viewit/obico-ml:latest
# docker push gitea.it-drui.de/viewit/obico-tasks:latest
#
# Persistente Daten: /mnt/dockerdata/KobraXStack/<service>/
#
# Ports:
# 7125 — KX-Bridge (Moonraker-API)
# 3334 — Obico (Web-UI)
# 7912 — Spoolman (Web-UI)
#
# Obico Admin-Account nach dem ersten Start:
# docker exec obico-web python manage.py createsuperuser
x-obico-base: &obico-base
restart: unless-stopped
volumes:
- /mnt/dockerdata/KobraXStack/obico/data:/data
- /mnt/dockerdata/KobraXStack/obico/frontend:/frontend
depends_on:
- obico-redis
environment:
DEBUG: "False"
REDIS_URL: "redis://obico-redis:6379"
DATABASE_URL: "sqlite:////data/db.sqlite3"
INTERNAL_MEDIA_HOST: "http://obico-web:3334"
ML_API_HOST: "http://obico-ml:3333"
ACCOUNT_ALLOW_SIGN_UP: "False"
SITE_USES_HTTPS: "False"
SITE_IS_PUBLIC: "False"
DJANGO_SECRET_KEY: "change-me-to-a-random-secret-key-before-use"
WEBPACK_LOADER_ENABLED: "False"
networks:
- kobrax-stack
services:
# ── KX-Bridge ───────────────────────────────────────────────
kx-bridge:
image: gitea.it-drui.de/viewit/kx-bridge:latest
container_name: kx-bridge
restart: unless-stopped
ports:
- "7125:7125"
volumes:
- /mnt/dockerdata/KobraXStack/kx-bridge/config:/app/config
- /mnt/dockerdata/KobraXStack/kx-bridge/data:/app/data
networks:
- kobrax-stack
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ── Spoolman ────────────────────────────────────────────────
spoolman:
image: ghcr.io/donkie/spoolman:latest
container_name: spoolman
restart: unless-stopped
ports:
- "7912:8000"
volumes:
- /mnt/dockerdata/KobraXStack/spoolman:/home/app/.local/share/spoolman
networks:
- kobrax-stack
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ── Obico Redis ─────────────────────────────────────────────
obico-redis:
image: redis:7.2-alpine
container_name: obico-redis
restart: unless-stopped
volumes:
- /mnt/dockerdata/KobraXStack/obico/redis:/data
networks:
- kobrax-stack
healthcheck:
test: ["CMD", "redis-cli", "ping"]
start_period: 10s
interval: 15s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "5m"
max-file: "2"
# ── Obico ML API ────────────────────────────────────────────
obico-ml:
image: gitea.it-drui.de/viewit/obico-ml:latest
container_name: obico-ml
restart: unless-stopped
command: bash -c "gunicorn --bind 0.0.0.0:3333 --workers 1 wsgi"
working_dir: /app
environment:
DEBUG: "False"
FLASK_APP: "server.py"
networks:
- kobrax-stack
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3333/hc/ || exit 1"]
start_period: 30s
interval: 30s
timeout: 10s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ── Obico Web ───────────────────────────────────────────────
obico-web:
<<: *obico-base
image: gitea.it-drui.de/viewit/obico-web:latest
container_name: obico-web
ports:
- "3334:3334"
depends_on:
- obico-ml
- obico-redis
command: >
sh -c 'python manage.py migrate &&
python manage.py shell -c "from django.contrib.sites.models import Site; s=Site.objects.first(); s.domain=\"192.168.178.204:3334\"; s.name=\"Obico\"; s.save()" &&
python manage.py collectstatic --noinput &&
daphne -b 0.0.0.0 -p 3334 config.routing:application'
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3334/hc/ || exit 1"]
start_period: 60s
interval: 90s
timeout: 20s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ── Obico Tasks (Celery) ────────────────────────────────────
obico-tasks:
<<: *obico-base
image: gitea.it-drui.de/viewit/obico-tasks:latest
container_name: obico-tasks
command: sh -c "celery -A config worker --beat -l info -c 2 -Q realtime,celery"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ── moonraker-obico Plugin ──────────────────────────────────
# Verbindet KX-Bridge mit dem Obico-Server (Spaghetti-Detektion, Remote-UI)
# Voraussetzung: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
# muss existieren und einen gültigen auth_token enthalten.
#
# Token holen (nach erstem obico-web Start):
# docker exec obico-web python manage.py shell -c "
# from app.models import OneTimeVerificationCode, User
# from django.utils import timezone; from datetime import timedelta; import random
# u = User.objects.first()
# c = OneTimeVerificationCode.objects.create(user=u, code='%06d' % random.randint(100000,999999), expired_at=timezone.now()+timedelta(hours=2))
# print('CODE:', c.code)"
# curl -X POST 'http://localhost:3334/api/v1/octo/verify/?code=<CODE>'
# → printer.auth_token aus der Antwort in die cfg eintragen
moonraker-obico:
image: gitea.it-drui.de/viewit/moonraker-obico:latest
container_name: moonraker-obico
restart: unless-stopped
network_mode: host
volumes:
- /mnt/dockerdata/KobraXStack/moonraker-obico:/opt/printer_data/config
- /mnt/dockerdata/KobraXStack/moonraker-obico/logs:/opt/printer_data/logs
command: ["-c", "/opt/printer_data/config/moonraker-obico.cfg"]
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
kobrax-stack:
driver: bridge
# Verzeichnisse müssen auf dem Host existieren:
# mkdir -p /mnt/dockerdata/KobraXStack/kx-bridge/config \
# /mnt/dockerdata/KobraXStack/kx-bridge/data \
# /mnt/dockerdata/KobraXStack/spoolman \
# /mnt/dockerdata/KobraXStack/obico/data \
# /mnt/dockerdata/KobraXStack/obico/frontend \
# /mnt/dockerdata/KobraXStack/obico/redis \
# /mnt/dockerdata/KobraXStack/moonraker-obico/logs
# Spoolman benötigt UID/GID 1000:
# sudo chown -R 1000:1000 /mnt/dockerdata/KobraXStack/spoolman
#
# moonraker-obico Config anlegen (auth_token nach Obico-Setup eintragen):
# cp /path/to/moonraker-obico.cfg.example /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg

View File

@@ -1,3 +0,0 @@
services:
kx-bridge:
image: gitea.it-drui.de/viewit/kx-bridge:nightly

View File

@@ -1,25 +0,0 @@
# KX-Bridge Nightly — Portainer Stack
#
# Paste this into Portainer → Stacks → Add stack → Web editor
#
# Uses the nightly build — may be unstable, for testing new features early.
# For production use, see docker-compose.portainer.yml (:latest).
services:
kx-bridge:
image: gitea.it-drui.de/viewit/kx-bridge:nightly
volumes:
- /mnt/dockerdata/kx-nightly/config:/app/config
- /mnt/dockerdata/kx-nightly/data:/app/data
ports:
# Port 7125 = first printer. Add 7126, 7127, … for each additional printer.
- "7125:7125"
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Verzeichnisse müssen auf dem Host existieren:
# mkdir -p /mnt/dockerdata/kx-nightly/config /mnt/dockerdata/kx-nightly/data

View File

@@ -10,8 +10,6 @@ services:
- ./.env:/app/.env:ro
ports:
- "7125-7130:7125-7130"
# environment:
# - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige)
restart: unless-stopped
logging:
driver: json-file

View File

@@ -1,6 +1,6 @@
"""
env_loader.py - loads connection parameters from .env (repo root or working directory).
Environment variables take precedence over .env values.
env_loader.py lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsverzeichnis).
Umgebungsvariablen haben Vorrang vor .env-Werten.
"""
import os
import sys
@@ -39,7 +39,7 @@ def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
# Frequently used shortcuts
# Häufig verwendete Shortcuts
PRINTER_IP = get("PRINTER_IP", "")
MQTT_PORT = int(get("MQTT_PORT", "9883"))
USERNAME = get("MQTT_USERNAME", "")
@@ -47,9 +47,5 @@ PASSWORD = get("MQTT_PASSWORD", "")
MODE_ID = get("MODE_ID", "")
DEVICE_ID = get("DEVICE_ID", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")

View File

@@ -1,10 +1,10 @@
"""
kobrax_client.py Anycubic Kobra X LAN-MQTT-Client
Protocol fully reconstructed via sniffer 2026-04-17 (953 messages).
Protokoll vollständig rekonstruiert via Sniffer 2026-04-17 (953 Nachrichten).
Voraussetzungen:
- /tmp/anycubic_slicer.crt and .key (from cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
- /tmp/anycubic_slicer.crt und .key (aus cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
- Drucker im LAN-Modus erreichbar auf Port 9883
Verwendung:
@@ -121,9 +121,9 @@ class KobraXClient:
self._buf = b""
self._pid = 1
self._lock = threading.Lock()
# Generation marker: incremented on every socket swap/close so the
# reader thread notices when _reconnect/_do_connect swapped the socket
# underneath it (Issue #53). Protects against recv on a stale fd.
# Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der
# Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm
# ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd.
self._sock_gen = 0
self._running = False
@@ -162,9 +162,9 @@ class KobraXClient:
if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
raise FileNotFoundError(
f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key "
f"must sit next to the kx-bridge binary ({_SCRIPT_DIR}/). "
f"Download anycubic-certs.zip from the Gitea release and extract "
f"the files there."
f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). "
f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke "
f"die Dateien dorthin."
)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
@@ -172,9 +172,9 @@ class KobraXClient:
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
ctx.load_cert_chain(CERT_FILE, KEY_FILE)
# Build the socket as a local variable - the handshake (connect + CONNACK)
# runs WITHOUT holding the lock so a slow connect does not freeze
# senders. Only the finished socket is swapped in under the lock (#53).
# Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK)
# läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht
# einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53).
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
raw = socket.create_connection(_ai[0][4], timeout=5)
new_sock = ctx.wrap_socket(raw)
@@ -196,7 +196,7 @@ class KobraXClient:
self._sock = new_sock
self._sock_gen += 1
self._buf = b""
self._subscribe(self._sub_topic()) # takes the lock itself - do not nest
self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln
log.debug("MQTT connected to %s:%s", self.host, self.port)
def connect(self):
@@ -206,10 +206,10 @@ class KobraXClient:
time.sleep(0.3)
def _ensure_reader(self):
"""Ensures the reader thread is alive. If the reader died after a
previous disconnect/reconnect sequence or an unhandled error,
received replies would never arrive - publish()
would still send but wait for replies forever."""
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
würde dann zwar senden, aber auf Antworten ewig warten."""
if not self._running:
return # gewollter disconnect
t = getattr(self, "_reader_thread", None)
@@ -232,13 +232,13 @@ class KobraXClient:
self._sock_gen += 1
def _reconnect(self):
"""Persistent reconnect: keeps retrying forever until the printer is
responds or disconnect() was called. Backoff caps at 60 s. The
first 5 attempts log as WARNING (acute connection issue), afterwards
only DEBUG to avoid log spam during long printer outages (e.g. switched
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
ausgeschaltet) zu vermeiden."""
log.warning("Connection lost - reconnecting...")
# Close + invalidation under the lock so no sender is mid-sendall
log.warning("Verbindung verloren reconnect")
# Close + Invalidierung unter Lock, damit kein Sender mitten im sendall
# auf den gerade geschlossenen Socket trifft (Issue #53).
with self._lock:
try:
@@ -254,18 +254,18 @@ class KobraXClient:
delay = delays[min(attempt, len(delays) - 1)]
try:
self._do_connect()
log.info("Reconnect successful (after %d attempts)", attempt + 1)
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
return True
except Exception as e:
attempt += 1
lvl = log.warning if attempt <= 5 else log.debug
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
# Split sleep so disconnect() breaks the loop faster.
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
slept = 0.0
while slept < delay and self._running:
time.sleep(min(0.5, delay - slept))
slept += 0.5
return False # only when disconnect() was called
return False # nur wenn disconnect() gerufen wurde
def _subscribe(self, topic: str):
with self._lock:
@@ -290,15 +290,15 @@ class KobraXClient:
ping_ok = True
except Exception:
ping_ok = False
# Call _reconnect() OUTSIDE the lock - it takes the lock
# itself, and threading.Lock is not reentrant (deadlock otherwise).
# _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock
# selbst, und threading.Lock ist nicht reentrant (sonst Deadlock).
if not ping_ok:
if self._running and not self._reconnect():
break
last_ping = time.time()
# Grab the current socket + generation under the lock so a
# parallel _reconnect/_do_connect swap does not leave us polling
# a stale fd (Issue #53).
# Aktuellen Socket + Generation unter Lock greifen, damit ein
# paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale
# fd pollen lässt (Issue #53).
with self._lock:
sock = self._sock
gen = self._sock_gen
@@ -306,37 +306,37 @@ class KobraXClient:
time.sleep(0.05)
continue
# Idle wait WITHOUT the lock - select only probes readiness, so
# the reader never blocks the shared lock while idle.
# Idle-Wartezeit OHNE Lock select probt nur die Bereitschaft, so
# blockiert der Reader während Leerlauf nie das gemeinsame Lock.
try:
ready, _, _ = select.select([sock], [], [], 0.2)
except (OSError, ValueError):
# fd closed/invalid (reconnect or disconnect mid-select)
# fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select)
if not self._running:
break
time.sleep(0.05)
continue
if not ready:
continue # idle, no lock held
continue # Leerlauf, kein Lock gehalten
# Data pending: briefly take the lock for the single recv, serialized
# against all sendall callers. recv does not block long (select said
# ready, socket timeout is 0.2s).
# Daten liegen an: Lock kurz greifen für das eine recv, serialisiert
# gegen alle sendall-Caller. recv blockiert nicht lange (select sagte
# ready, Socket-Timeout ist 0.2s).
try:
with self._lock:
# The socket could have been swapped between select and here.
# Socket könnte zwischen select und hier ersetzt worden sein.
if self._sock_gen != gen or self._sock is not sock:
continue
data = sock.recv(65536)
if not data:
# Windows SSL can briefly return b"" without a real EOF
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
_empty_count += 1
if _empty_count >= 5:
raise ConnectionResetError("EOF")
continue
_empty_count = 0
self._buf += data
self._drain() # outside the lock - dispatch/event.set() stays prompt
self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt
except ssl.SSLWantReadError:
continue
except socket.timeout:
@@ -445,8 +445,8 @@ class KobraXClient:
# -- Publish + request/response ------------------------------------------
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
# If the reader thread is dead for historical reasons, revive it -
# otherwise replies would never arrive and event.wait() would time out.
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
self._ensure_reader()
msgid = str(uuid.uuid4())
payload = json.dumps({
@@ -471,7 +471,7 @@ class KobraXClient:
report_registered = True
topic = self._pub_topic(msg_type)
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
# Status-Poll-TX (query/getInfo) ist reines Rauschen (alle paar Sekunden) →
# auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar.
_tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO
log.log(_tx_level, "TX %-25s action=%-12s data=%s",
@@ -531,8 +531,8 @@ class KobraXClient:
self._sock.sendall(_build_publish(topic, payload))
except Exception as e:
log.error("web send error: %s, reconnecting…", e)
# Trigger a reconnect (like publish()); no retry because it is
# fire-and-forget - the next call will hit the fresh socket
# Reconnect triggern (analog zu publish()); ohne Retry weil
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
# treffen.
try:
self._reconnect()
@@ -579,13 +579,13 @@ class KobraXClient:
# -- Part-Skip ("Exclude Object") ---------------------------------------
def query_skip_objects(self) -> dict | None:
"""Asks the printer for the current object/skip list."""
"""Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste."""
return self.publish("skip", "query_obj")
def skip_objects(self, names: list[str]) -> dict | None:
"""Skips the named objects - also possible mid-print.
"""Überspringt die genannten Objekte auch mid-print möglich.
Names correspond to the EXCLUDE_OBJECT_DEFINE NAME=... entries
Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen
im GCode-Header bzw. file_details.objects_skip_parts.
"""
return self.publish("skip", "start", {"objects_skip_parts": list(names)})
@@ -653,14 +653,14 @@ class KobraXClient:
f"Connection: close\r\n\r\n"
).encode()
# Short connect timeout (LAN). During sendall() the socket may take
# as long as needed - with large files (>100 MB) and slower WiFi
# at the printer, pushing otherwise takes >30 s and would falsely
# trip the connect timeout. The read timeout afterwards is generous
# (the printer processes the file before replying).
# Connect-Timeout kurz (LAN). Während sendall() darf der Socket so
# lange brauchen wie nötig — bei großen Dateien (>100 MB) und
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
# generös (Drucker verarbeitet die Datei bevor er antwortet).
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
sock = socket.create_connection(_ai[0][4], timeout=10)
sock.settimeout(None) # blocking during send
sock.settimeout(None) # blocking während Send
sock.sendall(headers + body)
sock.settimeout(180)
response = b""
@@ -717,7 +717,7 @@ if __name__ == "__main__":
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
parser.add_argument("--monitor", action="store_true",
help="Listen continuously and print all reports")
help="Dauerhaft mithören und alle Reports ausgeben")
args = parser.parse_args()
client = KobraXClient(
@@ -741,7 +741,7 @@ if __name__ == "__main__":
client.callbacks["*"] = on_msg
client.connect()
print("[kobrax] Monitor mode active (Ctrl-C to stop)")
print("[kobrax] Monitor-Modus aktiv (Ctrl-C zum Beenden)")
try:
while True:
time.sleep(1)
@@ -755,7 +755,7 @@ if __name__ == "__main__":
info = client.query_info()
if info:
d = info.get("data", {})
print(f" Printer: {d.get('printerName')} FW {d.get('version')}")
print(f" Drucker: {d.get('printerName')} FW {d.get('version')}")
print(f" Status: {d.get('state')}")
t = d.get("temp", {})
print(f" Nozzle: {t.get('curr_nozzle_temp')}°C → {t.get('target_nozzle_temp')}°C")
@@ -764,6 +764,6 @@ if __name__ == "__main__":
print(f" Upload: {urls.get('fileUploadurl')}")
print(f" Kamera: {urls.get('rtspUrl')}")
else:
print(" No response")
print(" Keine Antwort")
client.disconnect()

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
[server]
url = http://127.0.0.1:3334
auth_token = REPLACE_ME
sentry_opt = out
[moonraker]
host = 127.0.0.1
port = 7125
[webcam]
disable_video_streaming = False
snapshot_url = http://127.0.0.1:7125/api/camera/snapshot
stream_url = http://127.0.0.1:7125/api/camera/stream
target_fps = 5
[logging]
path = /opt/printer_data/logs/moonraker-obico.log
level = INFO

View File

@@ -1,10 +1,10 @@
"""OrcaSlicer Filament-Profil Parser.
Shared between the generator (tools/gen_orca_filament_list.py) and the
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
Reads Orca filament JSON files (system or user profiles) and returns
them as a normalized list with (id, name, vendor, type, color).
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
"""
from __future__ import annotations
@@ -13,8 +13,8 @@ import re
def first_str(value, default: str = "") -> str:
"""Orca profiles store some fields as ['value']. Returns the first
element as a string."""
"""Orca-Profile speichern manche Felder als ['wert']. Liefert erstes
Element als String."""
if isinstance(value, list):
return str(value[0]) if value else default
if isinstance(value, str):
@@ -37,34 +37,34 @@ def parse_profile(data: dict, by_name: dict | None = None,
path_vendor: str | None = None,
source_path: str = "",
system_index: list | None = None) -> dict | None:
"""Parses a single Orca filament profile into the bridge schema.
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
`by_name` is optionally a {name: [profile, ...]} index for inherits resolution
from the raw source tree (generator). For single-file imports (user file
from the OrcaSlicer user dir) we pass `system_index` instead -
the finished system profile list from orca_filaments.json. This lets
us derive filament_id/vendor/type/color via the `inherits` chain from
the system parent even when the user profile does not set these
fields itself (typically: user override profiles only contain tweaks).
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
Returns {id, name, vendor, type, color} or None when the profile
has no filament_id (e.g. abstract @base templates).
Liefert {id, name, vendor, type, color} oder None wenn das Profil
keine filament_id hat (z.B. abstrakte @base-Templates).
"""
if not isinstance(data, dict):
return None
# User profiles from the OrcaSlicer user dir often set NO "type" field -
# it comes from the system parent. We accept that when either "type"
# is explicitly "filament" OR an "inherits" points to another profile.
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
if data.get("type") not in (None, "filament") and not data.get("inherits"):
return None
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
# type=filament but no parent + no ID -> worthless stub
# type=filament aber kein parent + keine ID wertloses Stub
return None
inst = data.get("instantiation", "true")
if isinstance(inst, str) and inst.lower() == "false":
return None
# Build the system name index for the fallback lookup when system_index is set.
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
sys_by_name: dict[str, dict] = {}
if system_index:
for p in system_index:
@@ -92,7 +92,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
return None
def _resolve_via_system_index(key: str):
"""Inherits chain via system_index (clean_name match)."""
"""Inherits-Kette über system_index (clean_name-Match)."""
parent_raw = data.get("inherits")
if not parent_raw or not sys_by_name:
return None
@@ -100,7 +100,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
sys_p = sys_by_name.get(parent_clean)
if not sys_p:
return None
# The system JSON already uses the normalized schema
# System-JSON benutzt schon das normalisierte Schema
mapping = {
"filament_id": "id",
"filament_vendor": "vendor",
@@ -136,10 +136,10 @@ def parse_profile(data: dict, by_name: dict | None = None,
def parse_profile_bytes(blob: bytes, source_name: str = "",
system_index: list | None = None) -> dict | None:
"""Reads a single profile from JSON bytes. For the file upload path.
`system_index` is optionally the finished list from orca_filaments.json -
used for the inherits resolution of user profiles that do not carry the full
schema from the system parent."""
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
`system_index` ist optional die fertige Liste aus orca_filaments.json
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
Schema vom System-Parent erben."""
try:
data = json.loads(blob.decode("utf-8", errors="replace"))
except Exception:

View File

@@ -1,21 +0,0 @@
## Description
<!-- What does this PR change? -->
## Related Issue
Closes #
## Type
- [ ] Bug fix
- [ ] Feature
- [ ] Documentation
- [ ] Refactoring
## Tested with
- OrcaSlicer Version:
- Printer:
- Moonraker/Klipper Version:
## Checklist
- [ ] Tests added/updated
- [ ] CHANGELOG.md updated
- [ ] No debug code included

View File

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

View File

@@ -0,0 +1,3 @@
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

View File

@@ -1,12 +1,10 @@
#!/usr/bin/env bash
# start.sh KX-Bridge starten (zieht das fertige Image aus der Registry)
# start.sh KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
set -euo pipefail
cd "$(dirname "$0")"
IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge"
# .env anlegen falls nicht vorhanden
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
@@ -32,38 +30,38 @@ if ! docker info > /dev/null 2>&1; then
exit 1
fi
# Release-Kanal abfragen
CHANNEL=""
if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then
CHANNEL="$1"
# Prüfen ob Build nötig ist
NEEDS_BUILD=0
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
echo "[start] Image nicht vorhanden baue kx-bridge:latest ..."
NEEDS_BUILD=1
else
echo ""
echo " Welchen Release-Kanal möchtest du starten?"
echo " 1) stable (empfohlen)"
echo " 2) nightly (getestete Vorabversion)"
echo -n " Auswahl [1]: "
read -r CHOICE
case "$CHOICE" in
2) CHANNEL="nightly" ;;
*) CHANNEL="stable" ;;
esac
# Image-Erstellungszeit in Unix-Sekunden
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
s=s[:26].rstrip('Z').replace('T',' '); \
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
for f in Dockerfile \
bridge/kobrax_moonraker_bridge.py \
bridge/kobrax_client.py \
bridge/env_loader.py \
bridge/requirements.txt \
bridge/anycubic_slicer.crt \
bridge/anycubic_slicer.key; do
if [[ -f "$f" ]]; then
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
if [[ $FILE_TS -gt $IMAGE_TS ]]; then
echo "[start] '$f' ist neuer als das Image baue neu ..."
NEEDS_BUILD=1
break
fi
fi
done
fi
if [[ "$CHANNEL" == "nightly" ]]; then
IMAGE_TAG="nightly"
else
IMAGE_TAG="latest"
fi
IMAGE="$IMAGE_BASE:$IMAGE_TAG"
echo "[start] Kanal: $CHANNEL → Image: $IMAGE"
echo "[start] Ziehe aktuelles Image ..."
docker pull "$IMAGE"
# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile)
if [[ -f docker-compose.yml ]]; then
sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml
rm -f docker-compose.yml.bak
if [[ $NEEDS_BUILD -eq 1 ]]; then
docker build -t kx-bridge:latest .
fi
# Container starten
@@ -72,7 +70,7 @@ docker-compose down 2>/dev/null || true
docker-compose up -d
echo ""
echo " ✓ KX-Bridge läuft ($CHANNEL)"
echo " ✓ KX-Bridge läuft"
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
echo " Logs : docker-compose logs -f"
echo " Stop : docker-compose down"

View File

@@ -1,68 +0,0 @@
"""
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

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

View File

@@ -1,113 +0,0 @@
"""
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

@@ -1,67 +0,0 @@
"""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+"

View File

@@ -1,109 +0,0 @@
#!/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

View File

@@ -1,77 +0,0 @@
"""
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)

View File

@@ -1,82 +0,0 @@
"""
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

@@ -1,100 +0,0 @@
"""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

@@ -9,7 +9,6 @@ var camOn=false;
var camUserStopped=false; // user stopped camera manually — suppress auto-restart for this print
var _camPollInterval=null; // snapshot-polling interval for Android (no MJPEG support)
var _lastLoadedFile=null; // zuletzt geladene/gedruckte Datei für Progress-Karten-Aktionen (Issue #55)
var _idleCleared=false; // User hat idle-Datei explizit „geleert" → kein Nachladen von s.filename (Issue #57)
var _fdDialogOpen=false; // Dialog ist gerade offen
var _fdAutoOpenedFile=sessionStorage.getItem('fdAutoOpenedFile')||null;
var _fdUserCancelled=sessionStorage.getItem('fdUserCancelled')==='1';
@@ -44,78 +43,6 @@ var ACE_DRY_PRESETS={
custom_3:{name:'Custom 3',temp:45,duration_sec:4*3600}
};
// Spoolman state
var _spoolmanStatus={configured:false,reachable:false,server:'',sync_rate:0,slot_spools:{}};
var _spoolmanSpools=[];
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
function _loadSpoolmanStatus(){
fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){
_spoolmanStatus=d;
_slotSpoolMap=d.slot_spools||{};
_updateSpoolmanStatusDot();
_buildSpoolmanSection();
renderSpoolmanSlotCard();
if(d.configured){
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(sd){
_spoolmanSpools=sd.spools||[];
});
}
}).catch(function(){});
}
function _updateSpoolmanStatusDot(){
var dot=document.getElementById('spoolman-status-dot');
var lbl=document.getElementById('spoolman-status-lbl');
if(!dot||!lbl)return;
if(!_spoolmanStatus.configured){
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
} else if(_spoolmanStatus.reachable){
dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden';
} else {
dot.style.color='var(--err)';lbl.textContent=(_spoolmanStatus.server||'')+' (nicht erreichbar)';
}
}
function _buildSpoolmanSection(){
var sec=document.getElementById('fd-spoolman-section');
var rows=document.getElementById('fd-spoolman-rows');
var loading=document.getElementById('fd-spoolman-loading');
if(!sec||!rows)return;
if(!_spoolmanStatus.configured){sec.style.display='none';return;}
sec.style.display='';
rows.innerHTML='';
if(loading)loading.style.display='';
var usedSlots={};
(_amsSlots||[]).forEach(function(slot){
usedSlots[slot.slot_index]=slot;
});
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
if(loading)loading.style.display='none';
_spoolmanSpools=d.spools||[];
var slotKeys=Object.keys(usedSlots).map(Number).sort(function(a,b){return a-b;});
if(!slotKeys.length){rows.innerHTML='<span style="font-size:11px;color:var(--txt2)"></span>';return;}
rows.innerHTML=slotKeys.map(function(idx){
var slot=usedSlots[idx];
var col=(slot.color_hex||'#888');
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.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>';
}).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:46px">Slot '+(idx+1)+'</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(){if(loading)loading.style.display='none';});
}
function _aceAutoRefillGet(aceId){return !!aceAutoRefillPrefs[String(aceId)];}
function _aceAutoRefillSet(aceId,on){
aceAutoRefillPrefs[String(aceId)]=!!on;
@@ -181,7 +108,6 @@ function _langToggleLabel(lang){
if(lang==='de')return 'Deutsch';
if(lang==='en')return 'English';
if(lang==='fr')return 'Français';
if(lang==='it')return 'Italiano';
if(lang==='zh-cn')return '简体中文';
return 'Espanol';
}
@@ -189,10 +115,10 @@ function _langToggleLabel(lang){
function _mapSupportedLang(lang){
if(!lang)return '';
var l=String(lang).toLowerCase().replace(/_/g,'-').trim();
if(l==='de'||l==='en'||l==='es'||l==='fr'||l==='it'||l==='zh-cn')return l;
if(l==='de'||l==='en'||l==='es'||l==='fr'||l==='zh-cn')return l;
var base=l.split('-')[0];
if(base==='de'||base==='en'||base==='es'||base==='fr'||base==='it')return base;
if(base==='de'||base==='en'||base==='es'||base==='fr')return base;
if(base==='zh'){
if(l.indexOf('cn')>=0||l.indexOf('hans')>=0||l==='zh')return 'zh-cn';
@@ -294,7 +220,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="'+((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"':'')+'>'+
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"':'')+'>'+
(active?'▶ ':'')+p.name+'</a>';
}).join('');
}
@@ -381,10 +307,12 @@ 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);
@@ -399,16 +327,10 @@ function applyLang(){
setText('setcat-lbl-display',T.settings_cat_display||'Darstellung');
setText('setcat-lbl-display2',T.settings_cat_display||'Darstellung');
setText('setcat-lbl-filament',T.settings_cat_filament||'Filament');
setText('setcat-lbl-integrations',T.settings_integrations||'Integrationen');
setText('modal-sec-spoolman',T.modal_sec_spoolman||'Spoolman');
setText('lbl-spoolman-url',T.lbl_spoolman_url||'Server-URL');
setText('lbl-spoolman-sync-rate',T.lbl_spoolman_sync_rate||'Sync-Rate (s, 0=aus)');
setText('modal-sec-obico',T.modal_sec_obico||'Obico');
setText('setcat-lbl-system',T.settings_version||'System');
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)');
setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)');
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
@@ -436,7 +358,6 @@ function applyLang(){
setText('lbl-default-slot',T.settings_default_slot);
setText('opt-slot-auto',T.settings_slot_auto);
setText('lbl-auto-leveling',T.settings_auto_leveling);
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
setText('lbl-file-ready-mode',T.settings_file_ready_mode);
setText('opt-file-ready-dialog',T.settings_file_ready_dialog);
setText('opt-file-ready-banner',T.settings_file_ready_banner);
@@ -555,7 +476,6 @@ 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();}
@@ -750,11 +670,11 @@ function applyState(){
sessionStorage.removeItem('fdAutoOpenedFile');
}
if(shouldAutoOpen){
// Dialog-Modus: Banner niemals anzeigen.
// Dialog-Modus: nur Dialog verwenden, den Banner niemals anzeigen.
frb.style.display='none';
if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
_fdAutoOpenedFile=s.file_ready;
startReadyFileWithSlots(s.file_ready,true,s.filament_mismatch||null);
startReadyFileWithSlots(s.file_ready,true);
}
} else {
frb.style.display='flex';
@@ -774,11 +694,8 @@ function applyState(){
// Zuletzt geladene Datei merken (Issue #55): solange sie über den State
// sichtbar ist. Beim Druckende/Abbruch leert die Bridge file_ready+filename
// (Issue #29) — die gemerkte Referenz bleibt für die Karten-Aktionen.
// Echte ready-Datei oder laufender Druck hebt einen vorherigen „Clear" auf.
if(s.file_ready||printing) _idleCleared=false;
if(s.file_ready) _lastLoadedFile=s.file_ready;
else if(s.filename && !_idleCleared) _lastLoadedFile=s.filename;
else if(_idleCleared) _lastLoadedFile=null;
else if(s.filename) _lastLoadedFile=s.filename;
// Idle-Aktionen (Drucken/Slots/Leeren) nur wenn nicht gedruckt wird, eine
// Datei bekannt ist und der grüne Banner nicht ohnehin schon dieselbe Aktion
// anbietet.
@@ -955,42 +872,19 @@ function applyState(){
var activity=(slot.activity||'');
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'');
var slotLabel=T.label_slot+' '+(globalIdx+1);
// Gemapptes Profil nur für belegte Slots verwenden — sonst zeigt ein
// verwaistes Mapping (Slot wurde geleert) ein „Geister"-Profil (Issue #57).
var profile=empty?null:(window._slotProfileMap||{})[globalIdx];
var genericType=(slot.type||slot.material_type||'');
// Material-Label: bei belegtem Slot mit Mapping den konkreten Profilnamen
// (z.B. „eSUN PLA+") statt nur des generischen Typs zeigen (Issue #57 Punkt 4).
var materialLabel=empty?'':((profile&&profile.name)?profile.name:genericType);
var profile=(window._slotProfileMap||{})[globalIdx];
var vendorBadge='';
if(!empty && profile && profile.vendor){
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>'
+'<div class="slot-material" title="'+(empty?'':genericType)+'">'+materialLabel+'</div>'
+'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>'
+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>';
});
@@ -1001,10 +895,7 @@ function applyState(){
}
html+='</div></div>';
});
// 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;
document.getElementById('ams-slots').innerHTML=html;
}
// camera overlay
@@ -1012,7 +903,7 @@ function applyState(){
if(co)co.style.display=(s.print_state==='printing'&&camOn)?'block':'none';
// auto-start camera during print (unless user explicitly stopped it)
if(s.print_state==='printing'&&!camOn&&s.camera_url&&!camUserStopped&&s.camera_on_print){
if(s.print_state==='printing'&&!camOn&&s.camera_url&&!camUserStopped){
camStart();
}
// reset user-stopped flag when print ends so next print auto-starts again
@@ -1090,7 +981,6 @@ function openSettings(){
document.getElementById('s-mode-id').value=d.mode_id||'';
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
@@ -1100,13 +990,7 @@ function openSettings(){
var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3;
pi.value=sec;
}
var vhl=document.getElementById('s-verbose-http-log');if(vhl)vhl.checked=!!d.verbose_http_log;
renderFilamentMapping(d.filament_profiles||{});
renderSpoolmanSlotCard();
// Spoolman
var su=document.getElementById('s-spoolman-url');if(su)su.value=d.spoolman_server||'';
var sr=document.getElementById('s-spoolman-sync-rate');if(sr)sr.value=(d.spoolman_sync_rate!==undefined?d.spoolman_sync_rate:30);
_updateSpoolmanStatusDot();
});
// Sprach-Select im Settings-Panel mit aktueller Sprache spiegeln
var ls=document.getElementById('s-lang-select');
@@ -1135,11 +1019,6 @@ function onPollIntervalInput(){
}
// ── Filament-Profil-Mapping pro Slot ([filament_profiles]) ──
// Pro Slot ein einzelnes Profil-Dropdown (vendor+name gemeinsam, gekeyt per
// _profileKey). Kein Freitext mehr → das (vendor,name)→id-Matching kann nicht
// mehr durch manuelle Eingabe brechen (Issue #57 Punkt 1). Optionen werden aus
// /kx/filament/profiles geladen, nach Vendor gruppiert, User-Profile zuerst,
// mit demselben Vendor-Sichtbarkeitsfilter wie das Slot-Edit-Dropdown.
function renderFilamentMapping(map){
var el=document.getElementById('filament-mapping-list');
if(!el)return;
@@ -1149,61 +1028,21 @@ function renderFilamentMapping(map){
var idHint=m.id?' <span style="color:var(--txt2);font-size:11px">('+m.id+')</span>':'';
rows+='<div class="modal-field" style="margin-bottom:8px">'
+'<label>Slot '+(i+1)+idHint+'</label>'
+'<select id="fmap-'+i+'" data-vendor="'+(m.vendor||'')+'" data-name="'+(m.name||'')+'" style="width:100%"></select>'
+'</div>';
+'<div style="display:flex;gap:6px;flex-wrap:wrap">'
+'<input type="text" id="fmap-'+i+'-vendor" placeholder="Vendor (z.B. Polymaker)" value="'+(m.vendor||'')+'" style="flex:1;min-width:120px">'
+'<input type="text" id="fmap-'+i+'-name" placeholder="Name (z.B. PolyTerra PLA)" value="'+(m.name||'')+'" style="flex:1;min-width:140px">'
+'</div></div>';
}
el.innerHTML=rows;
// Dropdowns befüllen (async, geteilter Profil-Cache + Vendor-Filter)
for(var j=0;j<4;j++){ _fillMappingDropdown(j); }
}
function _fillMappingDropdown(slot){
var sel=document.getElementById('fmap-'+slot);
if(!sel) return;
var wantKey=_profileKey(sel.dataset.vendor, sel.dataset.name);
_loadOrcaFilaments(function(profiles){
sel.innerHTML='<option value="">'+(tr('slot_edit_profile_default')||'Generic (Standard)')+'</option>';
var userProfs=profiles.filter(function(p){return p.is_user;});
var systemProfs=profiles.filter(function(p){return !p.is_user;});
function _opt(g,p){
var o=document.createElement('option');
o.value=_profileKey(p.vendor,p.name);
o.dataset.vendor=p.vendor; o.dataset.name=p.name; o.dataset.id=p.id||'';
o.textContent=(p.is_user?'★ ':'')+p.name+(p.vendor?' — '+p.vendor:'');
if(o.value===wantKey)o.selected=true;
g.appendChild(o);
}
if(userProfs.length){
var gUser=document.createElement('optgroup');
gUser.label='★ '+(tr('orca_profile_user_label')||'Eigene Profile');
userProfs.forEach(function(p){_opt(gUser,p);});
sel.appendChild(gUser);
}
_loadVisibleVendors(function(vis){
var filtered=systemProfs;
if(vis&&vis.length){
var allow={};vis.forEach(function(v){allow[v]=1;});allow['Generic']=1;
filtered=systemProfs.filter(function(p){return allow[p.vendor];});
}
var byVendor={};
filtered.forEach(function(p){(byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p);});
Object.keys(byVendor).sort().forEach(function(v){
var g=document.createElement('optgroup');g.label=v;
byVendor[v].forEach(function(p){_opt(g,p);});
sel.appendChild(g);
});
});
});
}
function saveFilamentMapping(){
// Nutzt den per-Slot-Endpoint (vendor,name → ID-Lookup im Backend).
// Leere Auswahl ("") = Mapping entfernen.
// Leere Felder = Mapping entfernen.
var chain=Promise.resolve();
for(var i=0;i<4;i++){
(function(slot){
var sel=document.getElementById('fmap-'+slot);
var opt=sel?sel.options[sel.selectedIndex]:null;
var vendor=(opt&&opt.dataset.vendor)||'';
var name=(opt&&opt.dataset.name)||'';
var vendor=((document.getElementById('fmap-'+slot+'-vendor')||{}).value||'').trim();
var name=((document.getElementById('fmap-'+slot+'-name')||{}).value||'').trim();
chain=chain.then(function(){
return fetch(_apiUrl('/kx/filament/slots/'+slot+'/profile'),
{method:'POST',headers:{'Content-Type':'application/json'},
@@ -1250,64 +1089,6 @@ 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'},
@@ -1419,15 +1200,7 @@ function doProfileImportUpload(files){
// ── AMS Slot Edit ──
var _slotEditIndex=-1;
var _slotEditLoaded=false;
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'];
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
function updateSlotEditFeedButton(){
var btn=document.getElementById('btn-slot-edit-feed');
if(!btn)return;
@@ -1467,21 +1240,10 @@ 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();
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;
// PLA-CF, PLA-SILK etc. zählen auch zu PLA
return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' ');
});
sel.innerHTML='<option value="">'+tr('slot_edit_profile_default')+'</option>';
// User-Profile (is_user) zuerst — eigene Optgroup '★ Eigene' an erster Stelle.
@@ -1523,110 +1285,6 @@ 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);
@@ -1638,19 +1296,13 @@ 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;'
+(active?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
+(m===mat?'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).
@@ -1690,7 +1342,7 @@ function startReadyFile(filename){
if(btn){btn.disabled=true;btn.textContent='…';}
post('/printer/print/start',{filename:fn})
.then(function(r){return r.json();})
.then(function(){
.then(function(r){
document.getElementById('file-ready-banner').style.display='none';
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
})
@@ -1711,6 +1363,7 @@ function startReadyFile(filename){
_gateAndStart(currentFile);
return;
}
// Fresh page loads may not have storeFiles populated yet.
fetch(_apiUrl('/kx/files')).then(function(r){return r.json();}).then(function(d){
storeFiles=d.result||[];
var refreshed=(storeFiles||[]).find(function(f){return f.filename===fn;})||null;
@@ -1733,17 +1386,8 @@ function startIdleFileWithSlots(){
}
function clearIdleFile(){
_lastLoadedFile=null;
_idleCleared=true; // verhindert Nachladen von s.filename im nächsten poll() (Issue #57)
_fdAutoOpenedFile=null; // nächster Upload derselben Datei soll Dialog wieder öffnen
_fdUserCancelled=false;
_fdDialogOpen=false;
sessionStorage.removeItem('fdAutoOpenedFile');
sessionStorage.removeItem('fdUserCancelled');
sessionStorage.removeItem('webVerifyCancelledFileId');
S.file_ready=''; S.filename=''; S.thumbnail=''; // sofort lokal leeren, kein Warten auf nächsten Poll
var ib=document.getElementById('d-idle-btns');if(ib)ib.style.display='none';
var fn=document.getElementById('d-fname');if(fn){fn.textContent='';fn.title='';}
var thumb=document.getElementById('d-thumbnail');if(thumb){thumb.style.display='none';thumb.src='';}
post('/api/file_ready/clear',{}).catch(function(){});
}
function selectMatPreset(m){
@@ -1768,7 +1412,6 @@ 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;
@@ -1843,11 +1486,6 @@ function saveSettings(){
btn.disabled=true;btn.textContent='…';
var webUploadWarning=(document.getElementById('s-web-upload-warning')||{}).checked?1:0;
S.web_upload_warning=webUploadWarning;
// Start-Print-Behavior-Wechsel könnte den Auto-Open sonst dauerhaft blockieren
// (alter _fdUserCancelled bei gleicher file_ready) → Dialog-State zurücksetzen (Issue #57).
_fdUserCancelled=false;_fdAutoOpenedFile=null;
sessionStorage.removeItem('fdUserCancelled');sessionStorage.removeItem('fdAutoOpenedFile');
sessionStorage.removeItem('webVerifyCancelledFileId');
post('/api/settings',{
printer_name: document.getElementById('s-printer-name').value,
printer_ip: document.getElementById('s-printer-ip').value,
@@ -1857,22 +1495,17 @@ function saveSettings(){
device_id: document.getElementById('s-device-id').value,
mode_id: document.getElementById('s-mode-id').value,
default_ams_slot: document.getElementById('s-default-slot').value,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
web_upload_warning:webUploadWarning,
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0,
spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'',
spoolman_sync_rate: Math.max(0,parseInt((document.getElementById('s-spoolman-sync-rate')||{}).value||'30',10)),
}).then(function(){
btn.textContent=T.update_restarting;
setTimeout(function(){
btn.disabled=false;
setText('btn-save-settings',T.settings_save);
closeSettings();
_loadSpoolmanStatus();
poll();
},4000);
}).catch(function(e){
@@ -1880,33 +1513,21 @@ function saveSettings(){
clog('Settings-Fehler: '+e,'msg-err');
});
}
var _updateDockerOnly=false;
function checkUpdate(){
var sb=document.getElementById('update-status');
sb.textContent=T.update_checking;
document.getElementById('btn-update-apply').style.display='none';
_updateTag='';_updateUrl='';_updateDockerOnly=false;
_updateTag='';_updateUrl='';
fetch(_apiUrl('/api/update/check')).then(function(r){return r.json()}).then(function(d){
if(d.error){sb.textContent=T.update_error+': '+d.error;return;}
var cl=document.getElementById('update-changelog');
if(d.changelog&&d.changelog.trim()){
// Changelog als Markdown-Text (pre-formatiert) anzeigen
cl.textContent=d.changelog;cl.style.display='block';
} else {cl.style.display='none';}
_updateDockerOnly=!!d.docker_only;
if(d.changelog&&d.changelog.trim()){cl.textContent=d.changelog;cl.style.display='block';}
else{cl.style.display='none';}
if(d.update_available){
sb.textContent=d.latest+' '+T.update_available;
sb.textContent='v'+d.latest+' '+T.update_available;
sb.style.color='var(--ok)';
_updateTag=d.tag;_updateUrl=d.download_url||'';
var btn=document.getElementById('btn-update-apply');
if(_updateDockerOnly){
btn.textContent=T.update_docker||'docker compose pull';
btn.title='docker compose pull && docker compose up -d';
} else {
btn.textContent=T.update_apply;
btn.title='';
}
btn.style.display='inline-block';
_updateTag=d.tag;_updateUrl=d.download_url;
document.getElementById('btn-update-apply').style.display='inline-block';
} else {
sb.textContent=T.update_none;
sb.style.color='var(--txt2)';
@@ -1914,21 +1535,9 @@ function checkUpdate(){
}).catch(function(e){sb.textContent=T.update_error+': '+e;});
}
function applyUpdate(){
if(!_updateUrl)return;
var sb=document.getElementById('update-status');
var btn=document.getElementById('btn-update-apply');
if(_updateDockerOnly){
// Nightly: kein Self-Update, Docker-Befehl in Zwischenablage kopieren
var cmd='docker compose pull && docker compose up -d';
if(navigator.clipboard){
navigator.clipboard.writeText(cmd).then(function(){
sb.textContent=T.update_docker_copied||'Befehl kopiert: '+cmd;
});
} else {
sb.textContent=cmd;
}
return;
}
if(!_updateUrl)return;
btn.disabled=true;sb.textContent=T.update_applying;
post('/api/update/apply',{download_url:_updateUrl,tag:_updateTag}).then(function(){
sb.textContent=T.update_restarting;
@@ -1970,7 +1579,6 @@ var pollTimer;
});
}).catch(function(){});
poll();pollTimer=setInterval(poll,ms);
setInterval(_loadSpoolmanStatus,30000);
})();
// ── Print actions ──
@@ -2009,15 +1617,7 @@ function setStep(btn,v){
currentStep=v;
document.querySelectorAll('.step-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
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'));
}
document.getElementById('step-display').textContent=v;
}
function move(axis,dir,dist){
// axis: 0=X,1=Y,2=Z → printer axis codes: 1=X,2=Y,3=Z
@@ -2123,7 +1723,6 @@ function camStart(){
post('/api/camera/start',{}).then(function(){
camOn=true;
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_stop');
var rb=document.getElementById('cam-reset-btn');if(rb)rb.style.display='';
clog(tr('log_cam_start'),'msg-ok');
setTimeout(function(){ sp.style.display='none'; img.style.display='block'; },1200);
if(/Android/i.test(navigator.userAgent)){
@@ -2158,7 +1757,6 @@ function camStop(){
camOn=false;
camUserStopped=true; // suppress auto-restart for remainder of this print
document.getElementById('cam-toggle-btn').textContent=tr('btn_cam_start');
var rb=document.getElementById('cam-reset-btn');if(rb)rb.style.display='none';
clog(tr('log_cam_stop'),'msg-ok');
}
@@ -2409,13 +2007,6 @@ function aceDryToggle(aceId,on){
function toggleCam(){if(camOn)camStop();else camStart()}
function resetCamera(){
post('/api/camera/reset',{}).then(function(){
var btn=document.getElementById('cam-reset-btn');
if(btn){btn.textContent='↻';setTimeout(function(){btn.textContent='↺';},1000);}
}).catch(function(){});
}
function aceDryStop(aceId){
aceId=(typeof aceId==='number'&&aceId>=0)?aceId:0;
return post('/api/ace/dry',{action:'stop',ace_id:aceId})
@@ -2712,20 +2303,7 @@ function confirmStoreWebVerify(){
});
}
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){
function startReadyFileWithSlots(filename,_autoOpen){
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;});
@@ -2745,17 +2323,15 @@ function startReadyFileWithSlots(filename,_autoOpen,_mismatch){
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);
});
}
function _proceedWithFileObj(fileObj){
if(fileObj && fileObj.web_unverified && webUploadWarningEnabled()){
// Verify-Gate war beim ersten Lookup noch nicht aktiv (storeFiles leer) — jetzt prüfen.
// Verify gate was bypassed (storeFiles was empty on page load) — re-gate now.
if(_autoOpen){_fdDialogOpen=false;}
maybeGateWebUpload(fileObj, function(){ startReadyFileWithSlots(fn,_autoOpen); }, {autoOpen:!!_autoOpen});
return;
@@ -2802,17 +2378,6 @@ function _normalizeMaterialKey(material){
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
// Orca often uses PLA for PLA+, while AMS may report PLA+.
if(key==='PLA+'||key==='PLAPLUS') return 'PLA';
// Handle modifier+base patterns in either order: "Matte PLA", "Silk PETG",
// "PLA Silk", "PLA Matte". OrcaSlicer always writes the base type in GCode
// (filament_type = PLA), but users label slots with the full product-style name.
var trimmed=(material||'').trim();
if(trimmed.indexOf(' ')>=0){
var words=trimmed.toUpperCase().split(/\s+/);
for(var i=0;i<words.length;i++){
var w=words[i].replace(/[^A-Z0-9+]/g,'');
if(_BASE_MATERIAL_TYPES.indexOf(w)>=0) return w;
}
}
return key;
}
function _materialsCompatible(a,b){
@@ -2848,7 +2413,6 @@ function openFilamentDialog(slots){
_amsSlots=slots
.filter(function(s){return s.status==='loaded';})
.sort(function(a,b){return (a.slot_index||0)-(b.slot_index||0);});
_loadSpoolmanStatus();
var dlg=document.getElementById('filament-dialog');
var title=document.getElementById('fd-title');
var body=document.getElementById('fd-slots');
@@ -2869,30 +2433,19 @@ function openFilamentDialog(slots){
if(objBody)objBody.style.display='none'; // immer eingeklappt starten
if(objArrow)objArrow.style.transform='';
if(_storeFileId){
// Bei frischem Orca-/Web-Upload liefert der Drucker die Objektliste
// (objects_skip_parts) erst per fileDetails nach → ist im Store kurz leer.
// Daher mehrfach nachfragen, bis Objekte da sind (Issue #57 Skip-Parität).
var _objFid=_storeFileId;
var _objTries=0;
(function _loadObjects(){
if(_objFid!==_storeFileId) return; // Dialog wechselte Datei → abbrechen
fetch(_apiUrl('/kx/files/'+encodeURIComponent(_objFid)+'/objects'))
.then(function(r){return r.json()})
.then(function(d){
var names=(d.result&&d.result.names)||[];
var svg=(d.result&&d.result.svg_b64)||'';
if(names.length>=2){
_printObjectsSvg=svg;
_printObjects=names.map(function(n){return {name:n,skip:false};});
renderObjectChecklist(); renderObjectSvg();
var cnt=document.getElementById('fd-objects-count');
if(cnt)cnt.textContent='('+names.length+')';
if(objSection)objSection.style.display='block';
} else if(_objTries++ < 6){
setTimeout(_loadObjects, 1000); // bis ~6s auf fileDetails warten
}
}).catch(function(){});
})();
fetch(_apiUrl('/kx/files/'+encodeURIComponent(_storeFileId)+'/objects'))
.then(function(r){return r.json()})
.then(function(d){
var names=(d.result&&d.result.names)||[];
_printObjectsSvg=(d.result&&d.result.svg_b64)||'';
if(names.length>=2){
_printObjects=names.map(function(n){return {name:n,skip:false};});
renderObjectChecklist(); renderObjectSvg();
var cnt=document.getElementById('fd-objects-count');
if(cnt)cnt.textContent='('+names.length+')';
if(objSection)objSection.style.display='block';
}
}).catch(function(){});
}
// GCode-Kanäle: bevorzugt aus _gcodeFilaments, sonst aus belegten AMS-Slots ableiten
@@ -2978,14 +2531,11 @@ function openFilamentDialog(slots){
}).join('');
}
if(dlg)dlg.classList.add('open');
// Spoolman-Section nach Slot-Render aufbauen (braucht die selects im DOM)
setTimeout(_buildSpoolmanSection, 0);
}
function closeFilamentDialog(){
var dlg=document.getElementById('filament-dialog');
if(dlg)dlg.classList.remove('open');
_showMismatchWarn(null);
_fdDialogOpen=false;
if(_fdAutoOpenedFile){
_fdUserCancelled=true;
@@ -3033,20 +2583,9 @@ function confirmFilamentPrint(){
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
var fdAlEl=document.getElementById('fd-auto-leveling');
var fdAutoLeveling=fdAlEl?( fdAlEl.checked?1:0):(S.auto_leveling===undefined?1:S.auto_leveling?1:0);
// Spoolman: Slot→Spool-Mapping aus Dialog sammeln und senden
if(_spoolmanStatus.configured){
var slotMap={};
document.querySelectorAll('[data-spool-slot]').forEach(function(sel){
var idx=sel.dataset.spoolSlot;
var val=parseInt(sel.value);
if(val>0)slotMap[idx]=val;
});
_slotSpoolMap=slotMap;
post('/kx/spoolman/active-spool',{slot_map:slotMap}).catch(function(){});
}
closeFilamentDialog();
if(_filamentDialogMode==='banner'){
// Banner-Modus: /kx/print bevorzugen wenn _storeFileId bekannt (gleicher Pfad wie File-Browser).
// Banner-Modus: identisches Start-Verhalten wie im File-Browser bevorzugen.
var btn=document.getElementById('file-ready-btn');
if(btn){btn.disabled=true;btn.textContent='…';}
var startPromise;
@@ -3070,17 +2609,21 @@ function confirmFilamentPrint(){
});
}
startPromise.then(function(r){
if(!r.ok){return r.text().then(function(t){throw new Error(t||('HTTP '+r.status));});}
if(!r.ok){
return r.text().then(function(t){throw new Error(t||('HTTP '+r.status));});
}
return r.json();
}).then(function(d){
if(d&&d.error) throw new Error(d.error);
if(d&&d.result&&d.result!=='ok') throw new Error(String(d.result));
document.getElementById('file-ready-banner').style.display='none';
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
}).catch(function(e){
clog(tr('log_error')+' '+e,'msg-err');
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
});
})
.then(function(d){
if(d&&d.error) throw new Error(d.error);
if(d&&d.result&&d.result!=='ok') throw new Error(String(d.result));
document.getElementById('file-ready-banner').style.display='none';
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
})
.catch(function(e){
clog(tr('log_error')+' '+e,'msg-err');
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
});
} else {
// Store-Modus: POST /kx/print
fetch(_apiUrl('/kx/print'),{
@@ -3156,14 +2699,21 @@ function _applySkipDialogState(s){
s=s||{};
_skipSvg=s.svg_b64||'';
var skipped=s.skipped||[];
// Pending-Auswahl (willSkip) beim Refresh erhalten.
// Keep local pending selections while live state refreshes in background.
var prevWillSkip={};
(_skipObjects||[]).forEach(function(o){if(o&&o.name)prevWillSkip[o.name]=!!o.willSkip;});
(_skipObjects||[]).forEach(function(o){
if(o&&o.name)prevWillSkip[o.name]=!!o.willSkip;
});
_skipObjects=(s.objects||[]).map(function(n){
var isSkipped=(skipped.indexOf(n)>=0);
return {name:n, skipped:isSkipped, willSkip:isSkipped?false:!!prevWillSkip[n]};
return {
name:n,
skipped:isSkipped,
willSkip:isSkipped?false:!!prevWillSkip[n]
};
});
renderSkipList(); renderSkipSvg();
renderSkipList();
renderSkipSvg();
}
function openSkipDialog(){
document.getElementById('skip-status').textContent='';
@@ -3178,12 +2728,14 @@ function openSkipDialog(){
document.getElementById('skip-dialog').classList.add('open');
}
function _refreshSkipDialog(){
// query-Endpoint wartet intern auf frischen skip/report (bis 1.5s).
// Query-Endpoint liefert bereits den frischen, zusammengeführten Skip-State.
fetch(_apiUrl('/kx/skip/query'),{method:'POST'})
.then(function(r){return r.json();})
.then(function(d){_applySkipDialogState(d.result||{});})
.then(function(r){return r.json()})
.then(function(d){
_applySkipDialogState(d.result||{});
})
.catch(function(){
fetch(_apiUrl('/kx/skip/state')).then(function(r){return r.json();}).then(function(d){
fetch(_apiUrl('/kx/skip/state')).then(function(r){return r.json()}).then(function(d){
_applySkipDialogState(d.result||{});
}).catch(function(){});
});
@@ -3384,7 +2936,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="'+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>')+
(!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>')+
'</div>';
}).join('');
});

View File

@@ -5,8 +5,6 @@
<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>
@@ -34,6 +32,16 @@
<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="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>
@@ -48,26 +56,15 @@
<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: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 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>
</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">
@@ -159,7 +156,6 @@
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
</div>
<button class="cam-toggle" onclick="toggleCam()" id="cam-toggle-btn">▶ Kamera</button>
<button class="cam-toggle" onclick="resetCamera()" id="cam-reset-btn" style="display:none;margin-left:4px" title="Kamera-Stream neu verbinden (nach 429-Sperre)"></button>
</div>
</div>
@@ -254,51 +250,39 @@
<!-- Achsensteuerung -->
<div class="card">
<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 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>
<!-- 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 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>
</div>
<!-- Globale Befehle zentriert -->
<div style="display:flex;justify-content:center;gap:8px;margin-top:10px">
<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>
<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">
@@ -450,7 +434,6 @@
<button class="set-cat" id="setcat-printer" onclick="showSettingsCat('printer')"><span>🖨</span> <span id="setcat-lbl-printer">Drucker</span></button>
<button class="set-cat" id="setcat-display" onclick="showSettingsCat('display')"><span>🎨</span> <span id="setcat-lbl-display">Darstellung</span></button>
<button class="set-cat" id="setcat-filament" onclick="showSettingsCat('filament')"><span>🧵</span> <span id="setcat-lbl-filament">Filament</span></button>
<button class="set-cat" id="setcat-integrations" onclick="showSettingsCat('integrations')"><span></span> <span id="setcat-lbl-integrations">Integrationen</span></button>
<button class="set-cat" id="setcat-system" onclick="showSettingsCat('system')"><span></span> <span id="setcat-lbl-system">System</span></button>
</div>
@@ -509,10 +492,6 @@
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance compensation before print</label>
</div>
<div class="modal-field">
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
<select id="s-file-ready-mode">
@@ -542,7 +521,6 @@
<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>
@@ -554,10 +532,6 @@
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-verbose-http-log" style="width:auto;margin:0">
<label id="lbl-verbose-http-log" style="margin:0;cursor:pointer" for="s-verbose-http-log">Log every HTTP request (verbose)</label>
</div>
</div>
</div>
@@ -592,39 +566,6 @@
<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 -->
<div class="set-group" id="setgrp-integrations">
<!-- Spoolman -->
<div class="card">
<div class="card-title"><span>🧵</span> <span id="modal-sec-spoolman">Spoolman</span></div>
<div class="set-row">
<label id="lbl-spoolman-url">Server-URL</label>
<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=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)">
<span id="spoolman-status-dot"></span> <span id="spoolman-status-lbl"></span>
</div>
</div>
<!-- Obico -->
<div class="card" style="margin-top:10px">
<div class="card-title"><span>🕵</span> <span id="modal-sec-obico">Obico</span></div>
<div style="font-size:12px;color:var(--txt2);line-height:1.6" id="obico-info-box">
Obico wird über den <code>moonraker-obico</code>-Container konfiguriert.<br>
Config-Datei: <code id="obico-cfg-path">/mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg</code>
</div>
</div>
</div>
<!-- System -->
@@ -683,7 +624,6 @@
<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()"
@@ -704,13 +644,6 @@
<label for="fd-auto-leveling" style="margin:0;cursor:pointer;font-size:13px" id="fd-lbl-auto-leveling">Auto-Leveling</label>
</div>
</div>
<div id="fd-spoolman-section" style="display:none;margin-bottom:16px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:12px;color:var(--txt2);margin-bottom:8px;display:flex;align-items:center;gap:6px">
<span id="fd-spoolman-lbl">🧵 Spoolman</span>
<span id="fd-spoolman-loading" style="display:none;font-size:10px"></span>
</p>
<div id="fd-spoolman-rows" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="fd-cancel" onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="fd-print" onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -15,46 +15,10 @@
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).
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}
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29). */
select{background:var(--raised)!important;color:var(--txt)!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;
@@ -301,8 +265,6 @@ 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

@@ -1,331 +1,291 @@
{
"ace_dry_auto_refill": "Auto-Nachschub",
"ace_dry_chart": "Verlauf (Temp/Feuchte)",
"ace_dry_current_temp": "Temperatur",
"ace_dry_dialog_cancel": "Abbrechen",
"ace_dry_dialog_confirm": "Bestätigen",
"ace_dry_dialog_custom_name": "Eigener Name",
"ace_dry_dialog_reset_default": "Auf Standard zurücksetzen",
"ace_dry_dialog_save_restart": "Speichern & Neustart",
"ace_dry_dialog_temp": "Temperatur (30-80°C)",
"ace_dry_dialog_time": "Restzeit (h:m:s)",
"ace_dry_dialog_title": "Trockner Temp/Zeit-Einstellungen",
"header_status_standby": "Bereit",
"header_status_printing": "Druckt",
"header_status_complete": "Fertig",
"header_status_error": "Fehler",
"kobra_free": "Bereit",
"kobra_busy": "Beschäftigt",
"kobra_printing": "Druckt",
"kobra_preheating": "Aufheizen",
"kobra_auto_leveling": "Nivellierung",
"kobra_checking": "Prüfung",
"kobra_updated": "Aktualisierung",
"kobra_init": "Initialisierung",
"kobra_pausing": "Pausiert...",
"kobra_paused": "Pausiert",
"kobra_resuming": "Fortsetzen...",
"kobra_resumed": "Fortgesetzt",
"kobra_stopping": "Stoppt...",
"kobra_stoped": "Gestoppt",
"kobra_finished": "Abgeschlossen",
"kobra_failed": "Fehler",
"kobra_canceled": "Abgebrochen",
"kobra_offline": "Offline",
"nav_dashboard": "Dashboard",
"nav_print": "Druck",
"nav_temps": "Temperaturen",
"nav_motion": "Achsen",
"nav_ams": "AMS",
"nav_extras": "Licht / Lüfter",
"nav_console": "Konsole",
"card_progress": "Fortschritt",
"card_temps": "Temperaturen",
"card_light_fan": "Lüfter",
"card_speed": "Druckgeschwindigkeit",
"card_cam": "Kamera",
"lbl_elapsed": "Verstrichen:",
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_layers": "Layer",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Leise",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
"lbl_light": "💡 Licht",
"lbl_feed": "Einziehen",
"lbl_unload": "Ausziehen",
"card_ace_dry": "ACE Trocknung",
"ace_dry_dryer": "Trockner",
"ace_dry_duration": "Dauer (Min)",
"ace_dry_enable": "Trocknung aktivieren",
"ace_dry_humidity": "Luftfeuchte",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_custom": "Custom",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_tpu": "TPU",
"ace_dry_start": "▶ Start",
"ace_dry_status_off": "Status: Aus",
"ace_dry_status_on": "Status: Aktiv",
"ace_dry_status_remaining": "Rest",
"ace_dry_stop": "■ Stop",
"ace_dry_humidity": "Luftfeuchte",
"ace_dry_current_temp": "Temperatur",
"ace_dry_chart": "Verlauf (Temp/Feuchte)",
"ace_dry_temp": "Temperatur (°C)",
"ace_dry_duration": "Dauer (Min)",
"ace_dry_start": "▶ Start",
"ace_dry_stop": "■ Stop",
"ace_dry_auto_refill": "Auto-Nachschub",
"ace_dry_enable": "Trocknung aktivieren",
"ace_dry_temp_line": "Trocknungstemperatur",
"ace_dry_time_line": "Trocknungszeit",
"ace_dry_ui_pending": "(nur UI, Backend folgt)",
"add_printer": "Drucker hinzufügen",
"ams_empty": "Leer",
"ams_no_data": "Keine AMS-Daten empfangen",
"apd_cancel": "Abbrechen",
"apd_confirm": "Hinzufügen",
"apd_err_ip": "Bitte IP-Adresse eingeben",
"apd_fetching": "Hole Daten vom Drucker…",
"apd_lbl_ip": "Drucker-IP",
"apd_lbl_name": "Name (optional)",
"apd_placeholder_name": "z.B. Kobra X Wohnzimmer",
"apd_success": "Drucker hinzugefügt, Bridge startet neu…",
"apd_title": "Drucker hinzufügen",
"btn_cam_start": "▶ Kamera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Kamera",
"btn_cam_stop2": "◼ Stop",
"btn_cancel": "✕ Stopp",
"btn_cancel_generic": "Abbrechen",
"btn_confirm_generic": "Bestätigen",
"btn_connect": "⚡ Verbinden",
"btn_delete": "Löschen",
"btn_disable_motors": "Motoren aus",
"btn_disconnect": "✕ Trennen",
"btn_home_all": "Home All",
"btn_home_xy": "Home XY",
"btn_home_z": "Home Z",
"btn_pause": "⏸ Pause",
"btn_resume": "▶ Weiter",
"ace_dry_dialog_title": "Trockner Temp/Zeit-Einstellungen",
"ace_dry_dialog_temp": "Temperatur (30-80°C)",
"ace_dry_dialog_time": "Restzeit (h:m:s)",
"ace_dry_dialog_confirm": "Bestätigen",
"ace_dry_dialog_cancel": "Abbrechen",
"ace_dry_dialog_save_restart": "Speichern & Neustart",
"ace_dry_dialog_custom_name": "Eigener Name",
"ace_dry_dialog_reset_default": "Auf Standard zurücksetzen",
"cam_placeholder": "📷 Kamera nicht gestartet",
"cam_stream_unavailable": "Stream nicht verfügbar",
"card_ace_dry": "ACE Trocknung",
"card_ams": "Filament",
"card_cam": "Kamera",
"card_light_fan": "Lüfter",
"card_progress": "Fortschritt",
"card_speed": "Druckgeschwindigkeit",
"card_temps": "Temperaturen",
"confirm_cancel": "Druck wirklich abbrechen?",
"fd_cancel": "Abbrechen",
"fd_no_matching_material": "Kein passendes Material",
"fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?",
"fd_objects_hint": "Objekte überspringen (optional):",
"fd_objects_toggle": "Objekte überspringen",
"fd_options_title": "Optionen",
"fd_print": "▶ Drucken",
"fd_slot": "Slot",
"fd_slots_hint": "GCode-Kanal → AMS-Slot zuweisen:",
"fd_title": "Slot-Zuweisung",
"fd_used": "BELEGT",
"file_cancel_btn": "✕ Abbrechen",
"file_ready_btn": "▶ Druck starten",
"file_slots_btn": "🎨 Slots wählen",
"header_status_complete": "Fertig",
"header_status_error": "Fehler",
"header_status_printing": "Druckt",
"header_status_standby": "Bereit",
"hint_ip_no_port": "Nur IP-Adresse, kein Port (z.B. 192.168.1.102)",
"kobra_auto_leveling": "Nivellierung",
"kobra_busy": "Beschäftigt",
"kobra_canceled": "Abgebrochen",
"kobra_checking": "Prüfung",
"kobra_failed": "Fehler",
"kobra_finished": "Abgeschlossen",
"kobra_free": "Bereit",
"kobra_init": "Initialisierung",
"kobra_offline": "Offline",
"kobra_paused": "Pausiert",
"kobra_pausing": "Pausiert...",
"kobra_preheating": "Aufheizen",
"kobra_printing": "Druckt",
"kobra_resumed": "Fortgesetzt",
"kobra_resuming": "Fortsetzen...",
"kobra_stoped": "Gestoppt",
"kobra_stopping": "Stoppt...",
"kobra_updated": "Aktualisierung",
"btn_cam_start": "▶ Kamera",
"btn_cam_stop": "◼ Kamera",
"btn_pause": "⏸ Pause",
"btn_resume": "▶ Weiter",
"btn_cancel": "✕ Stopp",
"label_nozzle": "Düse",
"label_bed": "Bett",
"label_fan": "🌀 Lüfter",
"label_light": "💡 Licht",
"label_nozzle": "Düse",
"label_off": "Aus",
"label_on_off": "Ein / Aus",
"label_set": "Setzen",
"label_slot": "Slot",
"label_speed": "Geschwindigkeit",
"label_step": "Schrittweite:",
"panel_print_title": "Drucksteuerung",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Fortsetzen",
"panel_print_btn_cancel": "✕ Abbrechen",
"panel_print_temps_live": "Temperaturen (Live)",
"label_set": "Setzen",
"label_off": "Aus",
"panel_temps_nozzle": "Düse",
"panel_temps_bed": "Heizbett",
"panel_temps_chart": "Verlauf (letzte 60 Messungen)",
"label_target_c": "Ziel:",
"lbl_conn_error": "Verbindungsfehler:",
"lbl_elapsed": "Verstrichen:",
"lbl_feed": "Einziehen",
"lbl_layers": "Layer",
"lbl_light": "💡 Licht",
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
"lbl_spoolman_url": "Server-URL",
"lbl_unload": "Ausziehen",
"lbl_zpos": "Z (mm)",
"log_auto": "⬇ Auto",
"log_axis": "Achse",
"log_bed": "Bett →",
"log_cam_start": "Kamera gestartet:",
"log_cam_stop": "Kamera gestoppt",
"log_clear": "✕ Leeren",
"log_delete_failed": "Löschung fehlgeschlagen",
"log_dir_all": "Alle",
"log_dir_label": "Richtung:",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_download": "⬇ Download",
"log_error": "Fehler:",
"panel_motion_xy": "XY-Achsen",
"panel_motion_z": "Z-Achse",
"label_step": "Schrittweite:",
"btn_home_z": "Home Z",
"btn_home_xy": "Home XY",
"btn_home_all": "Home All",
"btn_disable_motors": "Motoren aus",
"panel_ams_title": "Filament",
"card_ams": "Filament",
"ams_no_data": "Keine AMS-Daten empfangen",
"label_slot": "Slot",
"ams_empty": "Leer",
"panel_extras_light": "Licht",
"panel_extras_fan": "Lüfter",
"panel_extras_camera": "Kamera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop2": "◼ Stop",
"panel_console_title": "Ereignis-Log",
"log_light_on": "Licht an",
"log_light_off": "Licht aus",
"log_fan": "Lüfter →",
"log_filter_placeholder": "Filtern…",
"log_nozzle": "Düse →",
"log_bed": "Bett →",
"log_axis": "Achse",
"log_home": "Home",
"log_home_all": "Home All",
"log_light_off": "Licht aus",
"log_light_on": "Licht an",
"log_lvl_err": "⛔ Fehler",
"log_lvl_label": "Level:",
"log_lvl_warn": "⚠ Warnung",
"log_nozzle": "Düse →",
"log_cam_start": "Kamera gestartet:",
"log_cam_stop": "Kamera gestoppt",
"log_poll_error": "Poll-Fehler:",
"log_print_action": "Druck:",
"log_print_start": "Druckstart:",
"log_topic_ams": "AMS",
"log_topic_info": "Info",
"log_topic_label": "Thema:",
"log_topic_print": "Druck",
"log_topic_status": "Status",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
"nav_browser": "Browser",
"nav_console": "Konsole",
"nav_dashboard": "Dashboard",
"nav_extras": "Licht / Lüfter",
"nav_motion": "Achsen",
"nav_print": "Druck",
"nav_printers": "Drucker",
"log_error": "Fehler:",
"confirm_cancel": "Druck wirklich abbrechen?",
"settings_title": "Einstellungen",
"settings_connection": "Verbindung",
"settings_print": "Druckeinstellungen",
"settings_poll": "Poll-Intervall (Sekunden)",
"settings_version": "Version",
"nav_settings": "Einstellungen",
"nav_temps": "Temperaturen",
"orca_profile_done": "Importiert",
"orca_profile_dropmsg": "Hierher ziehen oder klicken",
"orca_profile_help_html": "Lade ein <b>ZIP</b> deines OrcaSlicer-Filament-Ordners oder einzelne <b>.json</b>-Files hoch.<br>In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"settings_cat_display": "Darstellung",
"settings_cat_filament": "Filament",
"settings_cat_language": "Sprache",
"settings_cat_theme": "Hell / Dunkel umschalten",
"settings_filament_mapping": "Filament-Profil-Mapping (pro Slot)",
"settings_filament_mapping_save": "Mapping speichern",
"settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
"settings_visible_vendors_save": "Auswahl speichern",
"progress_action_print": "Drucken",
"progress_action_slots": "Slots zuordnen",
"progress_action_clear": "Leeren",
"settings_save": "Speichern & Neustart",
"settings_printer_name": "Drucker-Name",
"settings_printer_ip": "Drucker-IP",
"settings_mqtt_port": "MQTT-Port",
"settings_username": "MQTT-Benutzername",
"settings_password": "MQTT-Passwort",
"settings_device_id": "Device-ID",
"settings_mode_id": "Mode-ID",
"hint_ip_no_port": "Nur IP-Adresse, kein Port (z.B. 192.168.1.102)",
"settings_default_slot": "Standard-Slot (Einfarbdruck)",
"settings_slot_auto": "Auto (alle belegten Slots)",
"settings_auto_leveling": "Auto-Leveling vor Druck",
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"update_check": "Auf Updates prüfen",
"update_checking": "Prüfe...",
"update_available": "verfügbar",
"update_none": "Bereits aktuell",
"update_apply": "Jetzt installieren",
"update_applying": "Lade herunter...",
"update_restarting": "Starte neu...",
"update_error": "Fehler",
"btn_connect": "⚡ Verbinden",
"btn_disconnect": "✕ Trennen",
"lbl_conn_error": "Verbindungsfehler:",
"slot_edit_title": "Slot bearbeiten",
"slot_edit_color": "Farbe",
"slot_edit_material": "Material",
"slot_edit_load": "⬇ Einziehen",
"slot_edit_unload": "⬆ Ausziehen",
"slot_edit_save": "💾 Speichern",
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
"slot_edit_ok": "AMS Slot",
"slot_edit_profile": "OrcaSlicer-Profil",
"slot_edit_profile_hint": "Sendet beim OrcaSlicer-Sync die konkrete Marke statt nur „Generic\"",
"slot_edit_profile_default": "— Generic (Default) —",
"orca_profile_section": "OrcaSlicer-Profile",
"orca_profile_hint": "Eigene Profile aus OrcaSlicer importieren (User-Dir öffnen via Help → Show Configuration Folder)",
"orca_profile_import_btn": "Profile importieren",
"orca_profile_import_link": "★ Eigene Profile importieren…",
"orca_profile_import_title": "Eigene OrcaSlicer-Profile importieren",
"orca_profile_help_html": "Lade ein <b>ZIP</b> deines OrcaSlicer-Filament-Ordners oder einzelne <b>.json</b>-Files hoch.<br>In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Hierher ziehen oder klicken",
"orca_profile_list_label": "Aktuell importiert",
"orca_profile_section": "OrcaSlicer-Profile",
"orca_profile_skipped": "übersprungen",
"orca_profile_uploading": "Lade hoch…",
"orca_profile_user_empty": " keine ",
"orca_profile_user_label": "Eigene Profile",
"panel_ams_title": "Filament",
"panel_browser_title": "Datei-Browser",
"panel_console_title": "Ereignis-Log",
"panel_extras_camera": "Kamera",
"panel_extras_fan": "Lüfter",
"panel_extras_light": "Licht",
"panel_motion_xy": "Achsensteuerung",
"panel_print_btn_cancel": "✕ Abbrechen",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Fortsetzen",
"panel_print_temps_live": "Temperaturen (Live)",
"panel_print_title": "Drucksteuerung",
"panel_temps_bed": "Heizbett",
"panel_temps_chart": "Verlauf (letzte 60 Messungen)",
"panel_temps_nozzle": "Düse",
"print_auto_leveling": "Auto-Leveling für diesen Druck",
"printers_active": "● aktiv",
"printers_current": "Aktueller Drucker",
"printers_empty_hint": "Noch kein Drucker eingerichtet.",
"printers_loading": "Lade…",
"printers_none": "Keine Drucker konfiguriert.",
"printers_remove": "Drucker entfernen",
"printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
"printers_switch": "Wechseln →",
"progress_action_clear": "Leeren",
"progress_action_print": "Drucken",
"progress_action_slots": "Slots zuordnen",
"settings_auto_leveling": "Auto-Leveling vor Druck",
"settings_auto_leveling_label": "Auto-Leveling vor dem Druck",
"settings_vibration_compensation": "Resonanzkompensation",
"settings_btn_tooltip": "Einstellungen",
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
"settings_cat_connection": "Verbindung",
"settings_cat_display": "Darstellung",
"settings_cat_filament": "Filament",
"settings_cat_language": "Sprache",
"settings_cat_printer": "Drucker",
"settings_cat_system": "System",
"settings_cat_theme": "Hell / Dunkel umschalten",
"settings_connection": "Verbindung",
"settings_default_slot": "Standard-Slot (Einfarbdruck)",
"settings_device_id": "Device-ID",
"settings_device_id_hint": "32 Hexzeichen",
"settings_device_id_placeholder": "32 Hexzeichen",
"settings_filament_mapping": "Filament-Profil-Mapping (pro Slot)",
"settings_filament_mapping_hint": "Festes Orca-Profil pro AMS-Slot. Bei der Slicer-Synchronisierung sendet die Bridge dieses Profil statt \"Generic\".",
"settings_filament_mapping_label": "Filament-Profil-Mapping (pro Slot)",
"settings_filament_mapping_save": "Mapping speichern",
"settings_filament_mapping_save_label": "Mapping speichern",
"settings_file_ready_banner": "Druckleiste",
"settings_file_ready_dialog": "Druckdialog",
"settings_file_ready_mode": "Nach Upload: Druckstart-Verhalten",
"settings_integrations": "Integrationen",
"settings_language": "Sprache",
"settings_mode_id": "Mode-ID",
"settings_mode_id_placeholder": "20030",
"settings_mqtt_port": "MQTT-Port",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Profile importieren",
"settings_orca_profiles_label": "OrcaSlicer-Profile",
"settings_password": "MQTT-Passwort",
"settings_poll": "Poll-Intervall (Sekunden)",
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
"settings_print": "Druckeinstellungen",
"settings_printer_ip": "Drucker-IP",
"settings_printer_name": "Drucker-Name",
"settings_printer_name_placeholder": "z.B. Kobra X Links",
"settings_save": "Speichern & Neustart",
"settings_slot_auto": "Auto (alle belegten Slots)",
"settings_theme_toggle": "Wechsel Hell / Dunkel",
"settings_title": "Einstellungen",
"settings_username": "MQTT-Benutzername",
"settings_vendor_filter_placeholder": "Hersteller suchen…",
"settings_version": "Version",
"settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
"settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_save": "Auswahl speichern",
"settings_visible_vendors_save_label": "Auswahl speichern",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"sf_all": "Alle",
"sf_err": "✗ Fehler",
"sf_new": "Neu",
"sf_ok": "✓ Erfolgreich",
"skip_already": "übersprungen",
"skip_btn_label": "Objekte",
"skip_cancel": "Abbrechen",
"skip_confirm": "Überspringen",
"skip_confirm_btn": "Überspringen",
"orca_profile_user_empty": " keine ",
"orca_profile_uploading": "Lade hoch…",
"orca_profile_done": "Importiert",
"orca_profile_skipped": "übersprungen",
"log_dir_all": "Alle",
"log_lvl_label": "Level:",
"file_ready_btn": "▶ Druck starten",
"file_slots_btn": "🎨 Slots wählen",
"file_cancel_btn": "✕ Abbrechen",
"nav_printers": "Drucker",
"skip_title": "✂ Objekte überspringen",
"skip_hint": "Objekte abwählen, die nicht weiter gedruckt werden sollen:",
"skip_btn_label": "Objekte",
"skip_no_objects": "Keine Objekte in diesem Druck.",
"skip_already": "übersprungen",
"skip_select_at_least_one": "Bitte mindestens ein Objekt wählen.",
"skip_sending": "Sende …",
"skip_success": "Objekte werden übersprungen.",
"skip_title": "Objekte überspringen",
"slot_edit_color": "Farbe",
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Einziehen",
"slot_edit_material": "Material",
"slot_edit_ok": "AMS Slot",
"slot_edit_profile": "OrcaSlicer-Profil",
"slot_edit_profile_default": "— Generic (Default) —",
"slot_edit_profile_hint": "Sendet beim OrcaSlicer-Sync die konkrete Marke statt nur „Generic\"",
"slot_edit_save": "💾 Speichern",
"slot_edit_title": "Slot bearbeiten",
"slot_edit_unload": "⬆ Ausziehen",
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Leise",
"speed_sport": "🚀 Sport",
"ss_date": "↓ Datum",
"ss_dur": "Druckzeit",
"ss_name": "AZ Name",
"store_delete_confirm": "Datei löschen?",
"store_download": "⬇ Download",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_estimate": "Schätzung",
"store_never": "noch nicht gedruckt",
"store_no_results": "Keine Dateien gefunden.",
"store_print": " Drucken",
"store_print_confirm": "Datei drucken?",
"store_refresh": "↻ Aktualisieren",
"fd_objects_hint": "Objekte überspringen (optional):",
"fd_objects_toggle": "Objekte überspringen",
"fd_slots_hint": "GCode-Kanal → AMS-Slot zuweisen:",
"fd_cancel": "Abbrechen",
"fd_print": "▶ Drucken",
"fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?",
"fd_slot": "Slot",
"fd_no_matching_material": "Kein passendes Material",
"fd_used": "BELEGT",
"add_printer": "Drucker hinzufügen",
"apd_lbl_ip": "Drucker-IP",
"apd_lbl_name": "Name (optional)",
"apd_placeholder_name": "z.B. Kobra X Wohnzimmer",
"apd_cancel": "Abbrechen",
"apd_confirm": "Hinzufügen",
"apd_fetching": "Hole Daten vom Drucker…",
"apd_success": "Drucker hinzugefügt, Bridge startet neu…",
"apd_err_ip": "Bitte IP-Adresse eingeben",
"printers_remove": "Drucker entfernen",
"printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
"printers_active": "● aktiv",
"printers_switch": "Wechseln →",
"printers_current": "Aktueller Drucker",
"printers_loading": "Lade…",
"printers_none": "Keine Drucker konfiguriert.",
"printers_empty_hint": "Noch kein Drucker eingerichtet.",
"nav_browser": "Browser",
"panel_browser_title": "Datei-Browser",
"store_search_placeholder": "🔍 Suche…",
"store_upload_busy": "⏳ Hochladen",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "durchsuchen",
"store_upload_label_prefix": "GCode hierher ziehen oder ",
"store_upload_only_gcode": "✗ Nur GCode-Dateien erlaubt (.gcode, .3mf, .bgcode)",
"store_upload_success": "✓ {file}",
"store_web_verify_abort": "Abbrechen",
"store_web_verify_confirm": "Bestätigen",
"store_web_verify_msg": "Bitte bestätige, dass diese Datei für den Anycubic Kobra X erstellt wurde.",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_refresh": "↻ Aktualisieren",
"store_print": "▶ Drucken",
"store_download": "⬇ Download",
"store_delete_confirm": "Datei löschen?",
"store_print_confirm": "Datei drucken?",
"store_web_verify_title": "Datei verifizieren",
"update_apply": "Jetzt installieren",
"update_applying": "Lade herunter...",
"update_available": "verfügbar",
"update_check": "Auf Updates prüfen",
"update_checking": "Prüfe...",
"update_docker": "Befehl kopieren",
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
"update_error": "Fehler",
"update_none": "Bereits aktuell",
"update_restarting": "Starte neu...",
"slot_copy_from": "Farbe von Slot kopieren…"
}
"store_web_verify_msg": "Bitte bestätige, dass diese Datei für den Anycubic Kobra X erstellt wurde.",
"store_web_verify_confirm": "Bestätigen",
"store_web_verify_abort": "Abbrechen",
"store_no_results": "Keine Dateien gefunden.",
"store_never": "noch nicht gedruckt",
"store_estimate": "Schätzung",
"store_upload_label_prefix": "GCode hierher ziehen oder ",
"store_upload_label_browse": "durchsuchen",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Nur GCode-Dateien erlaubt (.gcode, .3mf, .bgcode)",
"sf_all": "Alle",
"sf_ok": "✓ Erfolgreich",
"sf_err": "✗ Fehler",
"sf_new": "Neu",
"ss_date": "↓ Datum",
"ss_name": "AZ Name",
"ss_dur": "⏱ Druckzeit",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Custom",
"fd_options_title": "Optionen",
"print_auto_leveling": "Auto-Leveling für diesen Druck",
"settings_file_ready_mode": "Druckdialog starten",
"settings_file_ready_banner": "Druckleiste",
"settings_file_ready_dialog": "Druckdialog",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Richtung:",
"log_lvl_err": "⛔ Fehler",
"log_lvl_warn": "⚠ Warnung",
"log_topic_label": "Thema:",
"log_topic_ams": "AMS",
"log_topic_print": "Druck",
"log_topic_info": "Info",
"log_topic_status": "Status",
"log_download": "⬇ Download",
"log_auto": "⬇ Auto",
"log_clear": "✕ Leeren",
"log_filter_placeholder": "Filtern…",
"skip_cancel": "Abbrechen",
"skip_confirm": "Überspringen"
}

View File

@@ -1,331 +1,291 @@
{
"ace_dry_auto_refill": "Auto Refill",
"ace_dry_chart": "History (Temp/Humidity)",
"ace_dry_current_temp": "Temperature",
"ace_dry_dialog_cancel": "Cancel",
"ace_dry_dialog_confirm": "Confirm",
"ace_dry_dialog_custom_name": "Custom Name",
"ace_dry_dialog_reset_default": "Reset to Default",
"ace_dry_dialog_save_restart": "Save & Restart",
"ace_dry_dialog_temp": "Temperature (30-80°C)",
"ace_dry_dialog_time": "Rem. Time (h:m:s)",
"ace_dry_dialog_title": "Dryer Temp/Time Settings",
"header_status_standby": "Ready",
"header_status_printing": "Printing",
"header_status_complete": "Complete",
"header_status_error": "Error",
"kobra_free": "Ready",
"kobra_busy": "Busy",
"kobra_printing": "Printing",
"kobra_preheating": "Preheating",
"kobra_auto_leveling": "Auto Leveling",
"kobra_checking": "Checking",
"kobra_updated": "Updating",
"kobra_init": "Initializing",
"kobra_pausing": "Pausing...",
"kobra_paused": "Paused",
"kobra_resuming": "Resuming...",
"kobra_resumed": "Resumed",
"kobra_stopping": "Stopping...",
"kobra_stoped": "Stopped",
"kobra_finished": "Finished",
"kobra_failed": "Error",
"kobra_canceled": "Cancelled",
"kobra_offline": "Offline",
"nav_dashboard": "Dashboard",
"nav_print": "Print",
"nav_temps": "Temperatures",
"nav_motion": "Motion",
"nav_ams": "AMS",
"nav_extras": "Light / Fan",
"nav_console": "Console",
"card_progress": "Progress",
"card_temps": "Temperatures",
"card_light_fan": "Fan",
"card_speed": "Print Speed",
"card_cam": "Camera",
"lbl_elapsed": "Elapsed:",
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_layers": "Layer",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silent",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
"lbl_light": "💡 Light",
"lbl_feed": "Load",
"lbl_unload": "Unload",
"card_ace_dry": "ACE Drying",
"ace_dry_dryer": "Dryer",
"ace_dry_duration": "Duration (min)",
"ace_dry_enable": "Enable Drying",
"ace_dry_humidity": "Humidity",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_custom": "Custom",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_tpu": "TPU",
"ace_dry_start": "▶ Start",
"ace_dry_status_off": "Status: Off",
"ace_dry_status_on": "Status: Active",
"ace_dry_status_remaining": "Remaining",
"ace_dry_stop": "■ Stop",
"ace_dry_humidity": "Humidity",
"ace_dry_current_temp": "Temperature",
"ace_dry_chart": "History (Temp/Humidity)",
"ace_dry_temp": "Temperature (°C)",
"ace_dry_duration": "Duration (min)",
"ace_dry_start": "▶ Start",
"ace_dry_stop": "■ Stop",
"ace_dry_auto_refill": "Auto Refill",
"ace_dry_enable": "Enable Drying",
"ace_dry_temp_line": "Drying Temperature",
"ace_dry_time_line": "Drying Time",
"ace_dry_ui_pending": "(UI only, backend next)",
"add_printer": "Add printer",
"ams_empty": "Empty",
"ams_no_data": "No AMS data received",
"apd_cancel": "Cancel",
"apd_confirm": "Add",
"apd_err_ip": "Please enter an IP address",
"apd_fetching": "Fetching data from printer…",
"apd_lbl_ip": "Printer IP",
"apd_lbl_name": "Name (optional)",
"apd_placeholder_name": "e.g. Kobra X Living Room",
"apd_success": "Printer added, bridge restarting…",
"apd_title": "Add printer",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Camera",
"btn_cam_stop2": "◼ Stop",
"btn_cancel": "✕ Stop",
"btn_cancel_generic": "Cancel",
"btn_confirm_generic": "Confirm",
"btn_connect": "⚡ Connect",
"btn_delete": "Delete",
"btn_disable_motors": "Motors Off",
"btn_disconnect": "✕ Disconnect",
"btn_home_all": "Home All",
"btn_home_xy": "Home XY",
"btn_home_z": "Home Z",
"btn_pause": "⏸ Pause",
"btn_resume": "▶ Resume",
"ace_dry_dialog_title": "Dryer Temp/Time Settings",
"ace_dry_dialog_temp": "Temperature (30-80°C)",
"ace_dry_dialog_time": "Rem. Time (h:m:s)",
"ace_dry_dialog_confirm": "Confirm",
"ace_dry_dialog_cancel": "Cancel",
"ace_dry_dialog_save_restart": "Save & Restart",
"ace_dry_dialog_custom_name": "Custom Name",
"ace_dry_dialog_reset_default": "Reset to Default",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Custom",
"cam_placeholder": "📷 Camera not started",
"cam_stream_unavailable": "Stream unavailable",
"card_ace_dry": "ACE Drying",
"card_ams": "Filament",
"card_cam": "Camera",
"card_light_fan": "Fan",
"card_progress": "Progress",
"card_speed": "Print Speed",
"card_temps": "Temperatures",
"confirm_cancel": "Really cancel the print?",
"fd_cancel": "Cancel",
"fd_no_matching_material": "No matching material",
"fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?",
"fd_objects_hint": "Skip objects (optional):",
"fd_objects_toggle": "Skip objects",
"fd_options_title": "Print Options",
"fd_print": "▶ Print",
"fd_slot": "Slot",
"fd_slots_hint": "Assign GCode channel to AMS slot:",
"fd_title": "Slot Assignment",
"fd_used": "USED",
"file_cancel_btn": "✕ Cancel",
"file_ready_btn": "▶ Start Print",
"file_slots_btn": "🎨 Select Slots",
"header_status_complete": "Complete",
"header_status_error": "Error",
"header_status_printing": "Printing",
"header_status_standby": "Ready",
"hint_ip_no_port": "IP address only, no port (e.g. 192.168.1.102)",
"kobra_auto_leveling": "Auto Leveling",
"kobra_busy": "Busy",
"kobra_canceled": "Cancelled",
"kobra_checking": "Checking",
"kobra_failed": "Error",
"kobra_finished": "Finished",
"kobra_free": "Ready",
"kobra_init": "Initializing",
"kobra_offline": "Offline",
"kobra_paused": "Paused",
"kobra_pausing": "Pausing...",
"kobra_preheating": "Preheating",
"kobra_printing": "Printing",
"kobra_resumed": "Resumed",
"kobra_resuming": "Resuming...",
"kobra_stoped": "Stopped",
"kobra_stopping": "Stopping...",
"kobra_updated": "Updating",
"btn_cam_start": "▶ Camera",
"btn_cam_stop": "◼ Camera",
"btn_pause": "⏸ Pause",
"btn_resume": "▶ Resume",
"btn_cancel": "✕ Stop",
"label_nozzle": "Nozzle",
"label_bed": "Bed",
"label_fan": "🌀 Fan",
"label_light": "💡 Light",
"label_nozzle": "Nozzle",
"label_off": "Off",
"label_on_off": "On / Off",
"label_set": "Set",
"label_slot": "Slot",
"label_speed": "Speed",
"label_step": "Step size:",
"panel_print_title": "Print Control",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Resume",
"panel_print_btn_cancel": "✕ Cancel",
"panel_print_temps_live": "Temperatures (Live)",
"label_set": "Set",
"label_off": "Off",
"panel_temps_nozzle": "Nozzle",
"panel_temps_bed": "Heated Bed",
"panel_temps_chart": "History (last 60 readings)",
"label_target_c": "Target:",
"lbl_conn_error": "Connection error:",
"lbl_elapsed": "Elapsed:",
"lbl_feed": "Load",
"lbl_layers": "Layer",
"lbl_light": "💡 Light",
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
"lbl_spoolman_url": "Server URL",
"lbl_unload": "Unload",
"lbl_zpos": "Z (mm)",
"log_auto": "⬇ Auto",
"log_axis": "Axis",
"log_bed": "Bed →",
"log_cam_start": "Camera started:",
"log_cam_stop": "Camera stopped",
"log_clear": "✕ Clear",
"log_delete_failed": "Delete failed",
"log_dir_all": "All",
"log_dir_label": "Dir:",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_download": "⬇ Download",
"log_error": "Error:",
"panel_motion_xy": "XY Axes",
"panel_motion_z": "Z Axis",
"label_step": "Step size:",
"btn_home_z": "Home Z",
"btn_home_xy": "Home XY",
"btn_home_all": "Home All",
"btn_disable_motors": "Motors Off",
"panel_ams_title": "Filament",
"card_ams": "Filament",
"ams_no_data": "No AMS data received",
"label_slot": "Slot",
"ams_empty": "Empty",
"panel_extras_light": "Light",
"panel_extras_fan": "Fan",
"panel_extras_camera": "Camera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop2": "◼ Stop",
"panel_console_title": "Event Log",
"log_light_on": "Light on",
"log_light_off": "Light off",
"log_fan": "Fan →",
"log_filter_placeholder": "Filter…",
"log_nozzle": "Nozzle →",
"log_bed": "Bed →",
"log_axis": "Axis",
"log_home": "Home",
"log_home_all": "Home All",
"log_light_off": "Light off",
"log_light_on": "Light on",
"log_lvl_err": "⛔ Errors",
"log_lvl_label": "Level:",
"log_lvl_warn": "⚠ Warn",
"log_nozzle": "Nozzle →",
"log_cam_start": "Camera started:",
"log_cam_stop": "Camera stopped",
"log_poll_error": "Poll error:",
"log_print_action": "Print:",
"log_print_start": "Print start:",
"log_topic_ams": "AMS",
"log_topic_info": "Info",
"log_topic_label": "Topic:",
"log_topic_print": "Print",
"log_topic_status": "Status",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
"nav_browser": "Browser",
"nav_console": "Console",
"nav_dashboard": "Dashboard",
"nav_extras": "Light / Fan",
"nav_motion": "Motion",
"nav_print": "Print",
"nav_printers": "Printers",
"log_error": "Error:",
"confirm_cancel": "Really cancel the print?",
"settings_title": "Settings",
"settings_connection": "Connection",
"settings_print": "Print Settings",
"settings_poll": "Poll Interval (seconds)",
"nav_settings": "Settings",
"nav_temps": "Temperatures",
"orca_profile_done": "Imported",
"orca_profile_dropmsg": "Drop here or click",
"orca_profile_help_html": "Upload a <b>ZIP</b> of your OrcaSlicer filament folder or single <b>.json</b> files.<br>In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"settings_cat_display": "Appearance",
"settings_cat_filament": "Filament",
"settings_cat_language": "Language",
"settings_cat_theme": "Toggle light / dark",
"settings_filament_mapping": "Filament profile mapping (per slot)",
"settings_filament_mapping_save": "Save mapping",
"settings_visible_vendors": "Visible vendors (profile dropdown)",
"settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.",
"settings_visible_vendors_save": "Save selection",
"progress_action_print": "Print",
"progress_action_slots": "Map slots",
"progress_action_clear": "Clear",
"settings_version": "Version",
"settings_save": "Save & Restart",
"settings_printer_name": "Printer Name",
"settings_printer_ip": "Printer IP",
"settings_mqtt_port": "MQTT Port",
"settings_username": "MQTT Username",
"settings_password": "MQTT Password",
"settings_device_id": "Device ID",
"settings_mode_id": "Mode ID",
"hint_ip_no_port": "IP address only, no port (e.g. 192.168.1.102)",
"settings_default_slot": "Default Slot (single color)",
"settings_slot_auto": "Auto (all loaded slots)",
"settings_auto_leveling": "Auto-Leveling Default",
"fd_options_title": "Print Options",
"print_auto_leveling": "Auto-Leveling",
"settings_file_ready_mode": "Start Print Behavior",
"settings_file_ready_banner": "Print Bar",
"settings_file_ready_dialog": "Print Dialog",
"settings_camera_on_print": "Turn camera on at print start",
"settings_web_upload_warning": "Show warning when printing web uploads",
"update_check": "Check for Updates",
"update_checking": "Checking...",
"update_available": "available",
"update_none": "Already up to date",
"update_apply": "Install Now",
"update_applying": "Downloading...",
"update_restarting": "Restarting...",
"update_error": "Error",
"btn_connect": "⚡ Connect",
"btn_disconnect": "✕ Disconnect",
"lbl_conn_error": "Connection error:",
"slot_edit_title": "Edit Slot",
"slot_edit_color": "Color",
"slot_edit_material": "Material",
"slot_edit_load": "⬇ Load",
"slot_edit_unload": "⬆ Unload",
"slot_edit_save": "💾 Save",
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
"slot_edit_ok": "AMS Slot",
"slot_edit_profile": "OrcaSlicer profile",
"slot_edit_profile_hint": "Sent on OrcaSlicer sync as the specific brand instead of just \"Generic\"",
"slot_edit_profile_default": "— Generic (default) —",
"orca_profile_section": "OrcaSlicer Profiles",
"orca_profile_hint": "Import your own OrcaSlicer filament profiles (open the user dir via Help → Show Configuration Folder)",
"orca_profile_import_btn": "Import profiles",
"orca_profile_import_link": "★ Import own profiles…",
"orca_profile_import_title": "Import your OrcaSlicer profiles",
"orca_profile_help_html": "Upload a <b>ZIP</b> of your OrcaSlicer filament folder or single <b>.json</b> files.<br>In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Drop here or click",
"orca_profile_list_label": "Currently imported",
"orca_profile_section": "OrcaSlicer Profiles",
"orca_profile_skipped": "skipped",
"orca_profile_uploading": "Uploading…",
"orca_profile_user_empty": " none ",
"orca_profile_user_label": "Own profiles",
"panel_ams_title": "Filament",
"panel_browser_title": "File Browser",
"panel_console_title": "Event Log",
"panel_extras_camera": "Camera",
"panel_extras_fan": "Fan",
"panel_extras_light": "Light",
"panel_motion_xy": "Axes Control",
"panel_print_btn_cancel": "✕ Cancel",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Resume",
"panel_print_temps_live": "Temperatures (Live)",
"panel_print_title": "Print Control",
"panel_temps_bed": "Heated Bed",
"panel_temps_chart": "History (last 60 readings)",
"panel_temps_nozzle": "Nozzle",
"print_auto_leveling": "Auto-Leveling",
"printers_active": "● active",
"printers_current": "Current printer",
"printers_empty_hint": "No printer set up yet.",
"printers_loading": "Loading…",
"printers_none": "No printers configured.",
"printers_remove": "Remove printer",
"printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
"printers_switch": "Switch →",
"progress_action_clear": "Clear",
"progress_action_print": "Print",
"progress_action_slots": "Map slots",
"settings_auto_leveling": "Auto-Leveling Default",
"settings_auto_leveling_label": "Auto-Leveling before print",
"settings_vibration_compensation": "Resonance Compensation",
"settings_btn_tooltip": "Settings",
"settings_camera_on_print": "Turn camera on at print start",
"settings_cat_connection": "Connection",
"settings_cat_display": "Appearance",
"settings_cat_filament": "Filament",
"settings_cat_language": "Language",
"settings_cat_printer": "Printer",
"settings_cat_system": "System",
"settings_cat_theme": "Toggle light / dark",
"settings_connection": "Connection",
"settings_default_slot": "Default Slot (single color)",
"settings_device_id": "Device ID",
"settings_device_id_hint": "32 hex characters",
"settings_device_id_placeholder": "32 hex characters",
"settings_filament_mapping": "Filament profile mapping (per slot)",
"settings_filament_mapping_hint": "Fixed Orca profile per AMS slot. On slicer sync, the bridge sends this profile instead of \"Generic\".",
"settings_filament_mapping_label": "Filament profile mapping (per slot)",
"settings_filament_mapping_save": "Save mapping",
"settings_filament_mapping_save_label": "Save mapping",
"settings_file_ready_banner": "Print bar",
"settings_file_ready_dialog": "Print dialog",
"settings_file_ready_mode": "After upload: Start print behavior",
"settings_integrations": "Integrations",
"settings_language": "Language",
"settings_mode_id": "Mode ID",
"settings_mode_id_placeholder": "20030",
"settings_mqtt_port": "MQTT Port",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Import profiles",
"settings_orca_profiles_label": "OrcaSlicer Profiles",
"settings_password": "MQTT Password",
"settings_poll": "Poll Interval (seconds)",
"settings_poll_interval_hint": "How often the bridge queries printer status",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"settings_poll_interval_label": "Poll Interval (seconds)",
"settings_print": "Print Settings",
"settings_printer_ip": "Printer IP",
"settings_printer_name": "Printer Name",
"settings_printer_name_placeholder": "e.g. Kobra X Left",
"settings_save": "Save & Restart",
"settings_slot_auto": "Auto (all loaded slots)",
"settings_theme_toggle": "Toggle light / dark",
"settings_title": "Settings",
"settings_username": "MQTT Username",
"settings_vendor_filter_placeholder": "Search vendors…",
"settings_version": "Version",
"settings_visible_vendors": "Visible vendors (profile dropdown)",
"settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.",
"settings_visible_vendors_label": "Visible vendors (profile dropdown)",
"settings_visible_vendors_save": "Save selection",
"settings_visible_vendors_save_label": "Save selection",
"settings_web_upload_warning": "Show warning when printing web uploads",
"sf_all": "All",
"sf_err": "✗ Failed",
"sf_new": "New",
"sf_ok": "✓ Completed",
"skip_already": "skipped",
"orca_profile_user_empty": " none ",
"orca_profile_uploading": "Uploading…",
"orca_profile_done": "Imported",
"orca_profile_skipped": "skipped",
"log_dir_all": "All",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Dir:",
"log_lvl_label": "Level:",
"log_lvl_err": "⛔ Errors",
"log_lvl_warn": "⚠ Warn",
"log_topic_label": "Topic:",
"log_topic_ams": "AMS",
"log_topic_print": "Print",
"log_topic_info": "Info",
"log_topic_status": "Status",
"log_download": "⬇ Download",
"log_auto": "⬇ Auto",
"log_clear": "✕ Clear",
"log_filter_placeholder": "Filter…",
"file_ready_btn": "▶ Start Print",
"file_slots_btn": "🎨 Select Slots",
"file_cancel_btn": "✕ Cancel",
"nav_printers": "Printers",
"skip_title": "✂ Skip objects",
"skip_hint": "Uncheck objects you no longer want to print:",
"skip_btn_label": "Objects",
"skip_no_objects": "No objects in this print.",
"skip_already": "skipped",
"skip_cancel": "Cancel",
"skip_confirm": "Skip",
"skip_confirm_btn": "Skip",
"skip_hint": "Uncheck objects you no longer want to print:",
"skip_no_objects": "No objects in this print.",
"skip_select_at_least_one": "Please pick at least one object.",
"skip_sending": "Sending …",
"skip_success": "Objects will be skipped.",
"skip_title": "Skip objects",
"slot_edit_color": "Color",
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Load",
"slot_edit_material": "Material",
"slot_edit_ok": "AMS Slot",
"slot_edit_profile": "OrcaSlicer profile",
"slot_edit_profile_default": "— Generic (default) —",
"slot_edit_profile_hint": "Sent on OrcaSlicer sync as the specific brand instead of just \"Generic\"",
"slot_edit_save": "💾 Save",
"slot_edit_title": "Edit Slot",
"slot_edit_unload": "⬆ Unload",
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Silent",
"speed_sport": "🚀 Sport",
"ss_date": "↓ Date",
"ss_dur": "Print time",
"ss_name": "AZ Name",
"store_delete_confirm": "Delete file?",
"store_download": "⬇ Download",
"store_empty": "No files uploaded yet.",
"store_estimate": "Estimate",
"store_never": "never printed",
"store_no_results": "No files found.",
"store_print": "▶ Print",
"store_print_confirm": "Print file?",
"store_refresh": "↻ Refresh",
"fd_objects_hint": "Skip objects (optional):",
"fd_objects_toggle": "Skip objects",
"fd_slots_hint": "Assign GCode channel to AMS slot:",
"fd_cancel": "Cancel",
"fd_print": "▶ Print",
"fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?",
"fd_slot": "Slot",
"fd_no_matching_material": "No matching material",
"fd_used": "USED",
"add_printer": "Add printer",
"apd_lbl_ip": "Printer IP",
"apd_lbl_name": "Name (optional)",
"apd_placeholder_name": "e.g. Kobra X Living Room",
"apd_cancel": "Cancel",
"apd_confirm": "Add",
"apd_fetching": "Fetching data from printer…",
"apd_success": "Printer added, bridge restarting…",
"apd_err_ip": "Please enter an IP address",
"printers_remove": "Remove printer",
"printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
"printers_active": "● active",
"printers_switch": "Switch →",
"printers_current": "Current printer",
"printers_loading": "Loading…",
"printers_none": "No printers configured.",
"printers_empty_hint": "No printer set up yet.",
"nav_browser": "Browser",
"panel_browser_title": "File Browser",
"store_search_placeholder": "🔍 Search…",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "browse",
"store_upload_label_prefix": "Drag GCode here or ",
"store_upload_only_gcode": "✗ Only GCode files allowed (.gcode, .3mf, .bgcode)",
"store_upload_success": "✓ {file}",
"store_web_verify_abort": "Cancel",
"store_web_verify_confirm": "Confirm",
"store_web_verify_msg": "Please verify this file was made for Anycubic Kobra X.",
"store_empty": "No files uploaded yet.",
"store_refresh": "↻ Refresh",
"store_print": "▶ Print",
"store_download": "⬇ Download",
"store_delete_confirm": "Delete file?",
"store_print_confirm": "Print file?",
"store_web_verify_title": "Verify file",
"update_apply": "Install Now",
"update_applying": "Downloading...",
"update_available": "available",
"update_check": "Check for Updates",
"update_checking": "Checking...",
"update_docker": "Copy Command",
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Already up to date",
"update_restarting": "Restarting...",
"slot_copy_from": "Copy color from slot…"
}
"store_web_verify_msg": "Please verify this file was made for Anycubic Kobra X.",
"store_web_verify_confirm": "Confirm",
"store_web_verify_abort": "Abort",
"store_no_results": "No files found.",
"store_never": "never printed",
"store_estimate": "Estimate",
"store_upload_label_prefix": "Drag GCode here or ",
"store_upload_label_browse": "browse",
"store_upload_busy": "⏳ Uploading…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Only GCode files allowed (.gcode, .3mf, .bgcode)",
"sf_all": "All",
"sf_ok": "✓ Completed",
"sf_err": "✗ Failed",
"sf_new": "New",
"ss_date": "↓ Date",
"ss_name": "AZ Name",
"ss_dur": "⏱ Print time"
}

View File

@@ -1,331 +1,291 @@
{
"ace_dry_auto_refill": "Relleno automático",
"ace_dry_chart": "Historial (Temp/Humedad)",
"ace_dry_current_temp": "Temperatura",
"ace_dry_dialog_cancel": "Cancelar",
"ace_dry_dialog_confirm": "Confirmar",
"ace_dry_dialog_custom_name": "Nombre personalizado",
"ace_dry_dialog_reset_default": "Restablecer valores predeterminados",
"ace_dry_dialog_save_restart": "Guardar y reiniciar",
"ace_dry_dialog_temp": "Temperatura (30-80°C)",
"ace_dry_dialog_time": "Tiempo restante (h:m:s)",
"ace_dry_dialog_title": "Ajustes de temp/tiempo del secador",
"header_status_standby": "Listo",
"header_status_printing": "Imprimiendo",
"header_status_complete": "Completado",
"header_status_error": "Error",
"kobra_free": "Listo",
"kobra_busy": "Ocupado",
"kobra_printing": "Imprimiendo",
"kobra_preheating": "Precalentando",
"kobra_auto_leveling": "Autonivelado",
"kobra_checking": "Comprobando",
"kobra_updated": "Actualizando",
"kobra_init": "Inicializando",
"kobra_pausing": "Pausando...",
"kobra_paused": "Pausado",
"kobra_resuming": "Reanudando...",
"kobra_resumed": "Reanudado",
"kobra_stopping": "Deteniendo...",
"kobra_stoped": "Detenido",
"kobra_finished": "Finalizado",
"kobra_failed": "Error",
"kobra_canceled": "Cancelado",
"kobra_offline": "Desconectada",
"nav_dashboard": "Panel",
"nav_print": "Impresión",
"nav_temps": "Temperaturas",
"nav_motion": "Movimiento",
"nav_ams": "AMS",
"nav_extras": "Luz / Ventilador",
"nav_console": "Consola",
"card_progress": "Progreso",
"card_temps": "Temperaturas",
"card_light_fan": "Ventilador",
"card_speed": "Velocidad de impresión",
"card_cam": "Cámara",
"lbl_elapsed": "Transcurrido:",
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"lbl_layers": "Capa",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silencioso",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
"lbl_light": "💡 Luz",
"lbl_feed": "Cargar",
"lbl_unload": "Descargar",
"card_ace_dry": "Secado ACE",
"ace_dry_dryer": "Secador",
"ace_dry_duration": "Duración (min)",
"ace_dry_enable": "Activar secado",
"ace_dry_humidity": "Humedad",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_custom": "Personalizado",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_tpu": "TPU",
"ace_dry_start": "▶ Iniciar",
"ace_dry_status_off": "Estado: Apagado",
"ace_dry_status_on": "Estado: Activo",
"ace_dry_status_remaining": "Restante",
"ace_dry_stop": "■ Parar",
"ace_dry_humidity": "Humedad",
"ace_dry_current_temp": "Temperatura",
"ace_dry_chart": "Historial (Temp/Humedad)",
"ace_dry_temp": "Temperatura (°C)",
"ace_dry_duration": "Duración (min)",
"ace_dry_start": "▶ Iniciar",
"ace_dry_stop": "■ Parar",
"ace_dry_auto_refill": "Relleno automático",
"ace_dry_enable": "Activar secado",
"ace_dry_temp_line": "Temperatura de secado",
"ace_dry_time_line": "Tiempo de secado",
"ace_dry_ui_pending": "(solo UI, backend después)",
"add_printer": "Añadir impresora",
"ams_empty": "Vacío",
"ams_no_data": "No se recibieron datos de AMS",
"apd_cancel": "Cancelar",
"apd_confirm": "Agregar",
"apd_err_ip": "Introduce una dirección IP",
"apd_fetching": "Obteniendo datos de la impresora…",
"apd_lbl_ip": "IP de impresora",
"apd_lbl_name": "Nombre (opcional)",
"apd_placeholder_name": "p. ej. Kobra X Sala",
"apd_success": "Impresora añadida, reiniciando bridge…",
"apd_title": "Agregar impresora",
"btn_cam_start": "▶ Cámara",
"btn_cam_start2": "▶ Iniciar",
"btn_cam_stop": "◼ Cámara",
"btn_cam_stop2": "◼ Detener",
"btn_cancel": "✕ Detener",
"btn_cancel_generic": "Cancelar",
"btn_confirm_generic": "Confirmar",
"btn_connect": "⚡ Conectar",
"btn_delete": "Eliminar",
"btn_disable_motors": "Motores apagados",
"btn_disconnect": "✕ Desconectar",
"btn_home_all": "Home All",
"btn_home_xy": "Home XY",
"btn_home_z": "Home Z",
"btn_pause": "⏸ Pausa",
"btn_resume": "▶ Reanudar",
"ace_dry_dialog_title": "Ajustes de temp/tiempo del secador",
"ace_dry_dialog_temp": "Temperatura (30-80°C)",
"ace_dry_dialog_time": "Tiempo restante (h:m:s)",
"ace_dry_dialog_confirm": "Confirmar",
"ace_dry_dialog_cancel": "Cancelar",
"ace_dry_dialog_save_restart": "Guardar y reiniciar",
"ace_dry_dialog_custom_name": "Nombre personalizado",
"ace_dry_dialog_reset_default": "Restablecer valores predeterminados",
"cam_placeholder": "📷 Cámara no iniciada",
"cam_stream_unavailable": "Stream no disponible",
"card_ace_dry": "Secado ACE",
"card_ams": "Filamento",
"card_cam": "Cámara",
"card_light_fan": "Ventilador",
"card_progress": "Progreso",
"card_speed": "Velocidad de impresión",
"card_temps": "Temperaturas",
"confirm_cancel": "¿Realmente cancelar la impresión?",
"fd_cancel": "Cancelar",
"fd_no_matching_material": "No hay material compatible",
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
"fd_objects_hint": "Omitir objetos (opcional):",
"fd_objects_toggle": "Omitir objetos",
"fd_options_title": "Opciones",
"fd_print": "▶ Imprimir",
"fd_slot": "Ranura",
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
"fd_title": "Asignación de ranura",
"fd_used": "USADO",
"file_cancel_btn": "✕ Cancelar",
"file_ready_btn": "▶ Iniciar impresión",
"file_slots_btn": "🎨 Seleccionar ranuras",
"header_status_complete": "Completado",
"header_status_error": "Error",
"header_status_printing": "Imprimiendo",
"header_status_standby": "Listo",
"hint_ip_no_port": "Solo dirección IP, sin puerto (p. ej. 192.168.1.102)",
"kobra_auto_leveling": "Autonivelado",
"kobra_busy": "Ocupado",
"kobra_canceled": "Cancelado",
"kobra_checking": "Comprobando",
"kobra_failed": "Error",
"kobra_finished": "Finalizado",
"kobra_free": "Listo",
"kobra_init": "Inicializando",
"kobra_offline": "Desconectada",
"kobra_paused": "Pausado",
"kobra_pausing": "Pausando...",
"kobra_preheating": "Precalentando",
"kobra_printing": "Imprimiendo",
"kobra_resumed": "Reanudado",
"kobra_resuming": "Reanudando...",
"kobra_stoped": "Detenido",
"kobra_stopping": "Deteniendo...",
"kobra_updated": "Actualizando",
"btn_cam_start": "▶ Cámara",
"btn_cam_stop": "◼ Cámara",
"btn_pause": "⏸ Pausa",
"btn_resume": "▶ Reanudar",
"btn_cancel": "✕ Detener",
"label_nozzle": "Boquilla",
"label_bed": "Cama",
"label_fan": "🌀 Ventilador",
"label_light": "💡 Luz",
"label_nozzle": "Boquilla",
"label_off": "Apagado",
"label_on_off": "Encendido / Apagado",
"label_set": "Set",
"label_slot": "Ranura",
"label_speed": "Velocidad",
"label_step": "Tamaño del paso:",
"panel_print_title": "Control de impresión",
"panel_print_btn_pause": "⏸ Pausa",
"panel_print_btn_resume": "▶ Reanudar",
"panel_print_btn_cancel": "✕ Cancelar",
"panel_print_temps_live": "Temperaturas (en vivo)",
"label_set": "Set",
"label_off": "Apagado",
"panel_temps_nozzle": "Boquilla",
"panel_temps_bed": "Cama caliente",
"panel_temps_chart": "Historial (últimas 60 lecturas)",
"label_target_c": "Objetivo:",
"lbl_conn_error": "Error de conexión:",
"lbl_elapsed": "Transcurrido:",
"lbl_feed": "Cargar",
"lbl_layers": "Capa",
"lbl_light": "💡 Luz",
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"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)",
"log_auto": "⬇ Auto",
"log_axis": "Eje",
"log_bed": "Cama →",
"log_cam_start": "Cámara iniciada:",
"log_cam_stop": "Cámara detenida",
"log_clear": "✕ Limpiar",
"log_delete_failed": "Error al eliminar",
"log_dir_all": "Todos",
"log_dir_label": "Dirección:",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_download": "⬇ Descargar",
"log_error": "Error:",
"panel_motion_xy": "Ejes XY",
"panel_motion_z": "Eje Z",
"label_step": "Tamaño del paso:",
"btn_home_z": "Home Z",
"btn_home_xy": "Home XY",
"btn_home_all": "Home All",
"btn_disable_motors": "Motores apagados",
"panel_ams_title": "Filamento",
"card_ams": "Filamento",
"ams_no_data": "No se recibieron datos de AMS",
"label_slot": "Ranura",
"ams_empty": "Vacío",
"panel_extras_light": "Luz",
"panel_extras_fan": "Ventilador",
"panel_extras_camera": "Cámara",
"btn_cam_start2": "▶ Iniciar",
"btn_cam_stop2": "◼ Detener",
"panel_console_title": "Registro de eventos",
"log_light_on": "Luz encendida",
"log_light_off": "Luz apagada",
"log_fan": "Ventilador →",
"log_filter_placeholder": "Filtrar…",
"log_nozzle": "Boquilla →",
"log_bed": "Cama →",
"log_axis": "Eje",
"log_home": "Home",
"log_home_all": "Home All",
"log_light_off": "Luz apagada",
"log_light_on": "Luz encendida",
"log_lvl_err": "⛔ Errores",
"log_lvl_label": "Nivel:",
"log_lvl_warn": "⚠ Avisos",
"log_nozzle": "Boquilla →",
"log_cam_start": "Cámara iniciada:",
"log_cam_stop": "Cámara detenida",
"log_poll_error": "Error de sondeo:",
"log_print_action": "Impresión:",
"log_print_start": "Inicio de impresión:",
"log_topic_ams": "AMS",
"log_topic_info": "Info",
"log_topic_label": "Tema:",
"log_topic_print": "Impresión",
"log_topic_status": "Estado",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
"nav_browser": "Explorador",
"nav_console": "Consola",
"nav_dashboard": "Panel",
"nav_extras": "Luz / Ventilador",
"nav_motion": "Movimiento",
"nav_print": "Impresión",
"nav_printers": "Impresoras",
"log_error": "Error:",
"confirm_cancel": "¿Realmente cancelar la impresión?",
"settings_title": "Configuración",
"settings_connection": "Conexión",
"settings_print": "Ajustes de impresión",
"settings_poll": "Intervalo de sondeo (segundos)",
"nav_settings": "Ajustes",
"nav_temps": "Temperaturas",
"orca_profile_done": "Importado",
"orca_profile_dropmsg": "Suelta aquí o haz clic",
"orca_profile_help_html": "Sube un <b>ZIP</b> de tu carpeta de filamentos de OrcaSlicer o archivos <b>.json</b> sueltos.<br>En OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"settings_cat_display": "Apariencia",
"settings_cat_filament": "Filamento",
"settings_cat_language": "Idioma",
"settings_cat_theme": "Alternar claro / oscuro",
"settings_filament_mapping": "Asignación de perfil de filamento (por ranura)",
"settings_filament_mapping_save": "Guardar asignación",
"settings_visible_vendors": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
"settings_visible_vendors_save": "Guardar selección",
"progress_action_print": "Imprimir",
"progress_action_slots": "Asignar ranuras",
"progress_action_clear": "Vaciar",
"settings_version": "Versión",
"settings_save": "Guardar y reiniciar",
"settings_printer_name": "Nombre de impresora",
"settings_printer_ip": "IP de impresora",
"settings_mqtt_port": "MQTT Port",
"settings_username": "Usuario MQTT",
"settings_password": "Contraseña MQTT",
"settings_device_id": "ID del dispositivo",
"settings_mode_id": "ID de modo",
"hint_ip_no_port": "Solo dirección IP, sin puerto (p. ej. 192.168.1.102)",
"settings_default_slot": "Ranura predeterminada (un color)",
"settings_slot_auto": "Auto (todos los slots cargados)",
"settings_auto_leveling": "Autonivelado antes de imprimir",
"settings_camera_on_print": "Encender cámara al iniciar impresión",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"update_check": "Buscar actualizaciones",
"update_checking": "Comprobando...",
"update_available": "disponible",
"update_none": "Ya actualizado",
"update_apply": "Instalar ahora",
"update_applying": "Descargando...",
"update_restarting": "Reiniciando...",
"update_error": "Error",
"btn_connect": "⚡ Conectar",
"btn_disconnect": "✕ Desconectar",
"lbl_conn_error": "Error de conexión:",
"slot_edit_title": "Editar slot",
"slot_edit_color": "Color",
"slot_edit_material": "Material",
"slot_edit_load": "⬇ Cargar",
"slot_edit_unload": "⬆ Descargar",
"slot_edit_save": "💾 Guardar",
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
"slot_edit_ok": "Ranura AMS",
"slot_edit_profile": "Perfil de OrcaSlicer",
"slot_edit_profile_hint": "Envía al sincronizar con OrcaSlicer la marca concreta en lugar de solo \"Generic\"",
"slot_edit_profile_default": "— Genérico (Predeterminado) —",
"orca_profile_section": "Perfiles de OrcaSlicer",
"orca_profile_hint": "Importa tus propios perfiles de filamento de OrcaSlicer (abre el directorio del usuario vía Help → Show Configuration Folder)",
"orca_profile_import_btn": "Importar perfiles",
"orca_profile_import_link": "★ Importar perfiles propios…",
"orca_profile_import_title": "Importar tus perfiles de OrcaSlicer",
"orca_profile_help_html": "Sube un <b>ZIP</b> de tu carpeta de filamentos de OrcaSlicer o archivos <b>.json</b> sueltos.<br>En OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Suelta aquí o haz clic",
"orca_profile_list_label": "Actualmente importados",
"orca_profile_section": "Perfiles de OrcaSlicer",
"orca_profile_skipped": "omitido",
"orca_profile_uploading": "Subiendo…",
"orca_profile_user_empty": " ninguno ",
"orca_profile_user_label": "Perfiles propios",
"panel_ams_title": "Filamento",
"panel_browser_title": "Explorador de archivos",
"panel_console_title": "Registro de eventos",
"panel_extras_camera": "Cámara",
"panel_extras_fan": "Ventilador",
"panel_extras_light": "Luz",
"panel_motion_xy": "Control de Ejes",
"panel_print_btn_cancel": "✕ Cancelar",
"panel_print_btn_pause": "⏸ Pausa",
"panel_print_btn_resume": "▶ Reanudar",
"panel_print_temps_live": "Temperaturas (en vivo)",
"panel_print_title": "Control de impresión",
"panel_temps_bed": "Cama caliente",
"panel_temps_chart": "Historial (últimas 60 lecturas)",
"panel_temps_nozzle": "Boquilla",
"print_auto_leveling": "Autonivelado para esta impresión",
"printers_active": "● activa",
"printers_current": "Impresora actual",
"printers_empty_hint": "Aún no hay impresora configurada.",
"printers_loading": "Cargando…",
"printers_none": "No hay impresoras configuradas.",
"printers_remove": "Eliminar impresora",
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
"printers_switch": "Cambiar →",
"progress_action_clear": "Vaciar",
"progress_action_print": "Imprimir",
"progress_action_slots": "Asignar ranuras",
"settings_auto_leveling": "Autonivelado antes de imprimir",
"settings_auto_leveling_label": "Autonivelado antes de imprimir",
"settings_vibration_compensation": "Compensación de resonancia",
"settings_btn_tooltip": "Ajustes",
"settings_camera_on_print": "Encender cámara al iniciar impresión",
"settings_cat_connection": "Conexión",
"settings_cat_display": "Apariencia",
"settings_cat_filament": "Filamento",
"settings_cat_language": "Idioma",
"settings_cat_printer": "Impresora",
"settings_cat_system": "Sistema",
"settings_cat_theme": "Alternar claro / oscuro",
"settings_connection": "Conexión",
"settings_default_slot": "Ranura predeterminada (un color)",
"settings_device_id": "ID del dispositivo",
"settings_device_id_hint": "32 caracteres hexadecimales",
"settings_device_id_placeholder": "32 caracteres hexadecimales",
"settings_filament_mapping": "Asignación de perfil de filamento (por ranura)",
"settings_filament_mapping_hint": "Perfil Orca fijo por ranura AMS. Al sincronizar con el slicer, el bridge envía este perfil en lugar de \"Generic\".",
"settings_filament_mapping_label": "Asignación de perfil de filamento (por ranura)",
"settings_filament_mapping_save": "Guardar asignación",
"settings_filament_mapping_save_label": "Guardar asignación",
"settings_file_ready_banner": "Barra de impresión",
"settings_file_ready_dialog": "Diálogo de impresión",
"settings_file_ready_mode": "Después de carga: Comportamiento de inicio de impresión",
"settings_integrations": "Integraciones",
"settings_language": "Idioma",
"settings_mode_id": "ID de modo",
"settings_mode_id_placeholder": "20030",
"settings_mqtt_port": "MQTT Port",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Importar perfiles",
"settings_orca_profiles_label": "Perfiles de OrcaSlicer",
"settings_password": "Contraseña MQTT",
"settings_poll": "Intervalo de sondeo (segundos)",
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
"settings_print": "Ajustes de impresión",
"settings_printer_ip": "IP de impresora",
"settings_printer_name": "Nombre de impresora",
"settings_printer_name_placeholder": "p. ej. Kobra X Sala",
"settings_save": "Guardar y reiniciar",
"settings_slot_auto": "Auto (todos los slots cargados)",
"settings_theme_toggle": "Alternar claro / oscuro",
"settings_title": "Configuración",
"settings_username": "Usuario MQTT",
"settings_vendor_filter_placeholder": "Buscar fabricantes…",
"settings_version": "Versión",
"settings_visible_vendors": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
"settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_save": "Guardar selección",
"settings_visible_vendors_save_label": "Guardar selección",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"sf_all": "Todos",
"sf_err": "✗ Fallido",
"sf_new": "Nuevo",
"sf_ok": "✓ Completado",
"skip_already": "omitido",
"skip_btn_label": "Objetos",
"skip_cancel": "Cancelar",
"skip_confirm": "Omitir",
"skip_confirm_btn": "Omitir",
"orca_profile_user_empty": " ninguno ",
"orca_profile_uploading": "Subiendo…",
"orca_profile_done": "Importado",
"orca_profile_skipped": "omitido",
"log_dir_all": "Todos",
"log_lvl_label": "Nivel:",
"file_ready_btn": "▶ Iniciar impresión",
"file_slots_btn": "🎨 Seleccionar ranuras",
"file_cancel_btn": "✕ Cancelar",
"nav_printers": "Impresoras",
"skip_title": "✂ Omitir objetos",
"skip_hint": "Deselecciona los objetos que ya no quieras imprimir:",
"skip_btn_label": "Objetos",
"skip_no_objects": "No hay objetos en esta impresión.",
"skip_already": "omitido",
"skip_select_at_least_one": "Selecciona al menos un objeto.",
"skip_sending": "Enviando …",
"skip_success": "Se omitirán los objetos.",
"skip_title": "Omitir objetos",
"slot_edit_color": "Color",
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Cargar",
"slot_edit_material": "Material",
"slot_edit_ok": "Ranura AMS",
"slot_edit_profile": "Perfil de OrcaSlicer",
"slot_edit_profile_default": "— Genérico (Predeterminado) —",
"slot_edit_profile_hint": "Envía al sincronizar con OrcaSlicer la marca concreta en lugar de solo \"Generic\"",
"slot_edit_save": "💾 Guardar",
"slot_edit_title": "Editar slot",
"slot_edit_unload": "⬆ Descargar",
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Silencioso",
"speed_sport": "🚀 Sport",
"ss_date": "↓ Fecha",
"ss_dur": "⏱ Tiempo de impresión",
"ss_name": "AZ Nombre",
"store_delete_confirm": "¿Eliminar archivo?",
"store_download": "⬇ Descargar",
"store_empty": "Aún no hay archivos subidos.",
"store_estimate": "Estimación",
"store_never": "nunca impreso",
"store_no_results": "No se encontraron archivos.",
"store_print": "▶ Imprimir",
"store_print_confirm": "¿Imprimir archivo?",
"store_refresh": "↻ Actualizar",
"fd_objects_hint": "Omitir objetos (opcional):",
"fd_objects_toggle": "Omitir objetos",
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
"fd_cancel": "Cancelar",
"fd_print": "▶ Imprimir",
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
"fd_slot": "Ranura",
"fd_no_matching_material": "No hay material compatible",
"fd_used": "USADO",
"add_printer": "Añadir impresora",
"apd_lbl_ip": "IP de impresora",
"apd_lbl_name": "Nombre (opcional)",
"apd_placeholder_name": "p. ej. Kobra X Sala",
"apd_cancel": "Cancelar",
"apd_confirm": "Añadir",
"apd_fetching": "Obteniendo datos de la impresora…",
"apd_success": "Impresora añadida, reiniciando bridge…",
"apd_err_ip": "Introduce una dirección IP",
"printers_remove": "Eliminar impresora",
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
"printers_active": "● activa",
"printers_switch": "Cambiar →",
"printers_current": "Impresora actual",
"printers_loading": "Cargando…",
"printers_none": "No hay impresoras configuradas.",
"printers_empty_hint": "Aún no hay impresora configurada.",
"nav_browser": "Explorador",
"panel_browser_title": "Explorador de archivos",
"store_search_placeholder": "🔍 Buscar…",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "buscar",
"store_upload_label_prefix": "Arrastra el GCode aquí o ",
"store_upload_only_gcode": "✗ Solo se permiten archivos GCode (.gcode, .3mf, .bgcode)",
"store_upload_success": "✓ {file}",
"store_web_verify_abort": "Cancelar",
"store_web_verify_confirm": "Confirmar",
"store_web_verify_msg": "Verifica que este archivo fue hecho para Anycubic Kobra X.",
"store_empty": "Aún no hay archivos subidos.",
"store_refresh": "↻ Actualizar",
"store_print": "▶ Imprimir",
"store_download": "⬇ Descargar",
"store_delete_confirm": "¿Eliminar archivo?",
"store_print_confirm": "¿Imprimir archivo?",
"store_web_verify_title": "Verificar archivo",
"update_apply": "Instalar ahora",
"update_applying": "Descargando...",
"update_available": "disponible",
"update_check": "Buscar actualizaciones",
"update_checking": "Comprobando...",
"update_docker": "Copiar comando",
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Ya actualizado",
"update_restarting": "Reiniciando...",
"slot_copy_from": "Copiar color del slot…"
}
"store_web_verify_msg": "Verifica que este archivo fue creado para Anycubic Kobra X.",
"store_web_verify_confirm": "Confirmar",
"store_web_verify_abort": "Abortar",
"store_no_results": "No se encontraron archivos.",
"store_never": "nunca impreso",
"store_estimate": "Estimación",
"store_upload_label_prefix": "Arrastra el GCode aquí o ",
"store_upload_label_browse": "buscar",
"store_upload_busy": "⏳ Subiendo",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Solo se permiten archivos GCode (.gcode, .3mf, .bgcode)",
"sf_all": "Todos",
"sf_ok": "✓ Completado",
"sf_err": "✗ Fallido",
"sf_new": "Nuevo",
"ss_date": "↓ Fecha",
"ss_name": "AZ Nombre",
"ss_dur": "⏱ Tiempo de impresión",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Personalizado",
"fd_options_title": "Opciones",
"print_auto_leveling": "Autonivelado para esta impresión",
"settings_file_ready_mode": "Iniciar diálogo de impresión",
"settings_file_ready_banner": "Barra de impresión",
"settings_file_ready_dialog": "Diálogo de impresión",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Dirección:",
"log_lvl_err": "⛔ Errores",
"log_lvl_warn": "⚠ Avisos",
"log_topic_label": "Tema:",
"log_topic_ams": "AMS",
"log_topic_print": "Impresión",
"log_topic_info": "Info",
"log_topic_status": "Estado",
"log_download": "⬇ Descargar",
"log_auto": "⬇ Auto",
"log_clear": "✕ Limpiar",
"log_filter_placeholder": "Filtrar…",
"skip_cancel": "Cancelar",
"skip_confirm": "Omitir"
}

View File

@@ -1,329 +1,291 @@
{
"ace_dry_auto_refill": "Remplissage auto",
"ace_dry_chart": "Historique (Temp/Humidité)",
"ace_dry_current_temp": "Température",
"ace_dry_dialog_cancel": "Annuler",
"ace_dry_dialog_confirm": "Confirmer",
"ace_dry_dialog_custom_name": "Nom personnalisé",
"ace_dry_dialog_reset_default": "Réinitialiser",
"ace_dry_dialog_save_restart": "Enregistrer et redémarrer",
"ace_dry_dialog_temp": "Température (30-80°C)",
"ace_dry_dialog_time": "Temps restant (h:m:s)",
"ace_dry_dialog_title": "Réglages Temp/Durée du séchoir",
"header_status_standby": "Prêt",
"header_status_printing": "Impression",
"header_status_complete": "Terminé",
"header_status_error": "Erreur",
"kobra_free": "Disponible",
"kobra_busy": "Occupé",
"kobra_printing": "Impression",
"kobra_preheating": "Préchauffage",
"kobra_auto_leveling": "Mise à niveau auto",
"kobra_checking": "Vérification",
"kobra_updated": "Mise à jour",
"kobra_init": "Initialisation",
"kobra_pausing": "Pause en cours…",
"kobra_paused": "En pause",
"kobra_resuming": "Reprise en cours…",
"kobra_resumed": "Repris",
"kobra_stopping": "Arrêt en cours…",
"kobra_stoped": "Arrêté",
"kobra_finished": "Terminé",
"kobra_failed": "Erreur",
"kobra_canceled": "Annulé",
"kobra_offline": "Hors ligne",
"nav_dashboard": "Tableau de bord",
"nav_print": "Impression",
"nav_temps": "Températures",
"nav_motion": "Mouvement",
"nav_ams": "AMS",
"nav_extras": "Lumière / Ventilateur",
"nav_console": "Console",
"card_progress": "Progression",
"card_temps": "Températures",
"card_light_fan": "Ventilateur",
"card_speed": "Vitesse d'impression",
"card_cam": "Caméra",
"lbl_elapsed": "Écoulé :",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"lbl_layers": "Couche",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silencieux",
"speed_normal": "⚡ Normal",
"speed_sport": "🚀 Sport",
"lbl_light": "💡 Lumière",
"lbl_feed": "Charger",
"lbl_unload": "Décharger",
"card_ace_dry": "Séchage ACE",
"ace_dry_dryer": "Séchoir",
"ace_dry_duration": "Durée (min)",
"ace_dry_enable": "Activer le séchage",
"ace_dry_humidity": "Humidité",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_custom": "Personnalisé",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_tpu": "TPU",
"ace_dry_start": "▶ Démarrer",
"ace_dry_status_off": "Statut : Arrêté",
"ace_dry_status_on": "Statut : Actif",
"ace_dry_status_remaining": "Restant",
"ace_dry_stop": "■ Arrêter",
"ace_dry_humidity": "Humidité",
"ace_dry_current_temp": "Température",
"ace_dry_chart": "Historique (Temp/Humidité)",
"ace_dry_temp": "Température (°C)",
"ace_dry_duration": "Durée (min)",
"ace_dry_start": "▶ Démarrer",
"ace_dry_stop": "■ Arrêter",
"ace_dry_auto_refill": "Remplissage auto",
"ace_dry_enable": "Activer le séchage",
"ace_dry_temp_line": "Température de séchage",
"ace_dry_time_line": "Durée de séchage",
"ace_dry_ui_pending": "(Interface seule, backend suivant)",
"add_printer": "Ajouter une imprimante",
"ams_empty": "Vide",
"ams_no_data": "Aucune donnée AMS reçue",
"apd_cancel": "Annuler",
"apd_confirm": "Ajouter",
"apd_err_ip": "Veuillez saisir une adresse IP",
"apd_fetching": "Récupération des données de l'imprimante…",
"apd_lbl_ip": "IP de l'imprimante",
"apd_lbl_name": "Nom (optionnel)",
"apd_placeholder_name": "ex. Kobra X Salon",
"apd_success": "Imprimante ajoutée, redémarrage du bridge…",
"apd_title": "Ajouter une imprimante",
"btn_cam_start": "▶ Caméra",
"btn_cam_start2": "▶ Démarrer",
"btn_cam_stop": "◼ Caméra",
"btn_cam_stop2": "◼ Arrêter",
"btn_cancel": "✕ Arrêter",
"btn_cancel_generic": "Annuler",
"btn_confirm_generic": "Confirmer",
"btn_connect": "⚡ Connecter",
"btn_delete": "Supprimer",
"btn_disable_motors": "Moteurs Off",
"btn_disconnect": "✕ Déconnecter",
"btn_home_all": "Origine Tout",
"btn_home_xy": "Origine XY",
"btn_home_z": "Origine Z",
"btn_pause": "⏸ Pause",
"btn_resume": "▶ Reprendre",
"ace_dry_dialog_title": "Réglages Temp/Durée du séchoir",
"ace_dry_dialog_temp": "Température (30-80°C)",
"ace_dry_dialog_time": "Temps restant (h:m:s)",
"ace_dry_dialog_confirm": "Confirmer",
"ace_dry_dialog_cancel": "Annuler",
"ace_dry_dialog_save_restart": "Enregistrer et redémarrer",
"ace_dry_dialog_custom_name": "Nom personnalisé",
"ace_dry_dialog_reset_default": "Réinitialiser",
"cam_placeholder": "📷 Caméra non démarrée",
"cam_stream_unavailable": "Flux indisponible",
"card_ace_dry": "Séchage ACE",
"card_ams": "Filament",
"card_cam": "Caméra",
"card_light_fan": "Ventilateur",
"card_progress": "Progression",
"card_speed": "Vitesse d'impression",
"card_temps": "Températures",
"confirm_cancel": "Vraiment annuler l'impression ?",
"fd_cancel": "Annuler",
"fd_no_matching_material": "Aucun matériau correspondant",
"fd_no_slots_msg": "Aucun slot AMS chargé.{br}Lancer l'impression quand même ?",
"fd_objects_hint": "Ignorer des objets (optionnel) :",
"fd_objects_toggle": "Ignorer des objets",
"fd_options_title": "Options",
"fd_print": "▶ Imprimer",
"fd_slot": "Slot",
"fd_slots_hint": "Associer le canal GCode au slot AMS :",
"fd_title": "Attribution de fente",
"fd_used": "UTILISÉ",
"file_cancel_btn": "✕ Annuler",
"file_ready_btn": "▶ Lancer l'impression",
"file_slots_btn": "🎨 Choisir les slots",
"header_status_complete": "Terminé",
"header_status_error": "Erreur",
"header_status_printing": "Impression",
"header_status_standby": "Prêt",
"hint_ip_no_port": "Adresse IP uniquement, sans port (ex. 192.168.1.102)",
"kobra_auto_leveling": "Mise à niveau auto",
"kobra_busy": "Occupé",
"kobra_canceled": "Annulé",
"kobra_checking": "Vérification",
"kobra_failed": "Erreur",
"kobra_finished": "Terminé",
"kobra_free": "Disponible",
"kobra_init": "Initialisation",
"kobra_offline": "Hors ligne",
"kobra_paused": "En pause",
"kobra_pausing": "Pause en cours…",
"kobra_preheating": "Préchauffage",
"kobra_printing": "Impression",
"kobra_resumed": "Repris",
"kobra_resuming": "Reprise en cours…",
"kobra_stoped": "Arrêté",
"kobra_stopping": "Arrêt en cours…",
"kobra_updated": "Mise à jour",
"btn_cam_start": "▶ Caméra",
"btn_cam_stop": "◼ Caméra",
"btn_pause": "⏸ Pause",
"btn_resume": "▶ Reprendre",
"btn_cancel": "✕ Arrêter",
"label_nozzle": "Buse",
"label_bed": "Plateau",
"label_fan": "🌀 Ventilateur",
"label_light": "💡 Lumière",
"label_nozzle": "Buse",
"label_off": "Éteint",
"label_on_off": "On / Off",
"label_set": "Définir",
"label_slot": "Slot",
"label_speed": "Vitesse",
"label_step": "Pas :",
"panel_print_title": "Contrôle impression",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Reprendre",
"panel_print_btn_cancel": "✕ Annuler",
"panel_print_temps_live": "Températures (en direct)",
"label_set": "Définir",
"label_off": "Éteint",
"panel_temps_nozzle": "Buse",
"panel_temps_bed": "Plateau chauffant",
"panel_temps_chart": "Historique (60 dernières valeurs)",
"label_target_c": "Cible :",
"lbl_conn_error": "Erreur de connexion :",
"lbl_elapsed": "Écoulé :",
"lbl_feed": "Charger",
"lbl_layers": "Couche",
"lbl_light": "💡 Lumière",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"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)",
"log_auto": "⬇ Auto",
"log_axis": "Axe",
"log_bed": "Plateau →",
"log_cam_start": "Caméra démarrée :",
"log_cam_stop": "Caméra arrêtée",
"log_clear": "✕ Effacer",
"log_delete_failed": "Échec de la suppression",
"log_dir_all": "Tout",
"log_dir_label": "Sens :",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_download": "⬇ Télécharger",
"log_error": "Erreur :",
"panel_motion_xy": "Axes XY",
"panel_motion_z": "Axe Z",
"label_step": "Pas :",
"btn_home_z": "Origine Z",
"btn_home_xy": "Origine XY",
"btn_home_all": "Origine Tout",
"btn_disable_motors": "Moteurs Off",
"panel_ams_title": "Filament",
"card_ams": "Filament",
"ams_no_data": "Aucune donnée AMS reçue",
"label_slot": "Slot",
"ams_empty": "Vide",
"panel_extras_light": "Lumière",
"panel_extras_fan": "Ventilateur",
"panel_extras_camera": "Caméra",
"btn_cam_start2": "▶ Démarrer",
"btn_cam_stop2": "◼ Arrêter",
"panel_console_title": "Journal d'événements",
"log_light_on": "Lumière allumée",
"log_light_off": "Lumière éteinte",
"log_fan": "Ventilateur →",
"log_filter_placeholder": "Filtrer…",
"log_nozzle": "Buse →",
"log_bed": "Plateau →",
"log_axis": "Axe",
"log_home": "Origine",
"log_home_all": "Origine Tout",
"log_light_off": "Lumière éteinte",
"log_light_on": "Lumière allumée",
"log_lvl_err": "⛔ Erreurs",
"log_lvl_label": "Niveau :",
"log_lvl_warn": "⚠ Avert.",
"log_nozzle": "Buse →",
"log_cam_start": "Caméra démarrée :",
"log_cam_stop": "Caméra arrêtée",
"log_poll_error": "Erreur de sondage :",
"log_print_action": "Impression :",
"log_print_start": "Début de l'impression :",
"log_topic_ams": "AMS",
"log_topic_info": "Info",
"log_topic_label": "Sujet :",
"log_topic_print": "Impression",
"log_topic_status": "Statut",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
"nav_browser": "Navigateur",
"nav_console": "Console",
"nav_dashboard": "Tableau de bord",
"nav_extras": "Lumière / Ventilateur",
"nav_motion": "Mouvement",
"nav_print": "Impression",
"nav_printers": "Imprimantes",
"log_error": "Erreur :",
"confirm_cancel": "Vraiment annuler l'impression ?",
"settings_title": "Paramètres",
"settings_connection": "Connexion",
"settings_print": "Paramètres d'impression",
"settings_poll": "Intervalle de sondage (secondes)",
"nav_settings": "Paramètres",
"nav_temps": "Températures",
"orca_profile_done": "Importé",
"orca_profile_dropmsg": "Déposez ici ou cliquez",
"orca_profile_help_html": "Déposez un <b>ZIP</b> de votre dossier filament OrcaSlicer ou des fichiers <b>.json</b> individuels.<br>Dans OrcaSlicer : <i>Aide → Afficher le dossier de configuration → user/&lt;id&gt;/filament/</i>",
"settings_cat_display": "Apparence",
"settings_cat_filament": "Filament",
"settings_cat_language": "Langue",
"settings_cat_theme": "Basculer clair / sombre",
"settings_filament_mapping": "Mappage du profil de filament (par emplacement)",
"settings_filament_mapping_save": "Enregistrer le mappage",
"settings_visible_vendors": "Fabricants visibles (liste des profils)",
"settings_visible_vendors_hint": "Seuls ces fabricants apparaissent dans la liste des profils d'emplacement. Rien de sélectionné = tout afficher. « Generic » et vos propres profils sont toujours visibles.",
"settings_visible_vendors_save": "Enregistrer la sélection",
"progress_action_print": "Imprimer",
"progress_action_slots": "Affecter les emplacements",
"progress_action_clear": "Vider",
"settings_version": "Version",
"settings_save": "Enregistrer et redémarrer",
"settings_printer_name": "Nom de l'imprimante",
"settings_printer_ip": "IP de l'imprimante",
"settings_mqtt_port": "Port MQTT",
"settings_username": "Nom d'utilisateur MQTT",
"settings_password": "Mot de passe MQTT",
"settings_device_id": "ID de l'appareil",
"settings_mode_id": "ID du mode",
"hint_ip_no_port": "Adresse IP uniquement, sans port (ex. 192.168.1.102)",
"settings_default_slot": "Slot par défaut (couleur unique)",
"settings_slot_auto": "Auto (tous les slots chargés)",
"settings_auto_leveling": "Mise à niveau auto avant impression",
"settings_camera_on_print": "Activer la caméra au démarrage de l'impression",
"settings_web_upload_warning": "Afficher un avertissement lors de l'impression de fichiers web",
"update_check": "Vérifier les mises à jour",
"update_checking": "Vérification…",
"update_available": "disponible",
"update_none": "Déjà à jour",
"update_apply": "Installer maintenant",
"update_applying": "Téléchargement…",
"update_restarting": "Redémarrage…",
"update_error": "Erreur",
"btn_connect": "⚡ Connecter",
"btn_disconnect": "✕ Déconnecter",
"lbl_conn_error": "Erreur de connexion :",
"slot_edit_title": "Modifier le slot",
"slot_edit_color": "Couleur",
"slot_edit_material": "Matériau",
"slot_edit_load": "⬇ Charger",
"slot_edit_unload": "⬆ Décharger",
"slot_edit_save": "💾 Enregistrer",
"slot_edit_custom": "ex. PLA, PETG, ABS…",
"slot_edit_ok": "Slot AMS",
"slot_edit_profile": "Profil OrcaSlicer",
"slot_edit_profile_hint": "Envoyé lors de la synchronisation OrcaSlicer comme marque spécifique au lieu de \"Générique\"",
"slot_edit_profile_default": "— Générique (défaut) —",
"orca_profile_section": "Profils OrcaSlicer",
"orca_profile_hint": "Importez vos propres profils de filament OrcaSlicer (ouvrez le dossier utilisateur via Aide → Afficher le dossier de configuration)",
"orca_profile_import_btn": "Importer des profils",
"orca_profile_import_link": "★ Importer mes profils…",
"orca_profile_import_title": "Importer vos profils OrcaSlicer",
"orca_profile_help_html": "Déposez un <b>ZIP</b> de votre dossier filament OrcaSlicer ou des fichiers <b>.json</b> individuels.<br>Dans OrcaSlicer : <i>Aide → Afficher le dossier de configuration → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Déposez ici ou cliquez",
"orca_profile_list_label": "Profils importés",
"orca_profile_section": "Profils OrcaSlicer",
"orca_profile_skipped": "ignoré",
"orca_profile_uploading": "Envoi en cours…",
"orca_profile_user_empty": " aucun ",
"orca_profile_user_label": "Mes profils",
"panel_ams_title": "Filament",
"panel_browser_title": "Explorateur de fichiers",
"panel_console_title": "Journal d'événements",
"panel_extras_camera": "Caméra",
"panel_extras_fan": "Ventilateur",
"panel_extras_light": "Lumière",
"panel_motion_xy": "Contrôle des Axes",
"panel_print_btn_cancel": "✕ Annuler",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Reprendre",
"panel_print_temps_live": "Températures (en direct)",
"panel_print_title": "Contrôle impression",
"panel_temps_bed": "Plateau chauffant",
"panel_temps_chart": "Historique (60 dernières valeurs)",
"panel_temps_nozzle": "Buse",
"print_auto_leveling": "Mise à niveau auto pour cette impression",
"printers_active": "● actif",
"printers_current": "Imprimante actuelle",
"printers_empty_hint": "Aucune imprimante configurée.",
"printers_loading": "Chargement…",
"printers_none": "Aucune imprimante configurée.",
"printers_remove": "Supprimer l'imprimante",
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
"printers_switch": "Changer →",
"progress_action_clear": "Vider",
"progress_action_print": "Imprimer",
"progress_action_slots": "Affecter les emplacements",
"settings_auto_leveling": "Mise à niveau auto avant impression",
"settings_auto_leveling_label": "Mise à niveau auto avant impression",
"settings_btn_tooltip": "Paramètres",
"settings_camera_on_print": "Activer la caméra au démarrage de l'impression",
"settings_cat_connection": "Connexion",
"settings_cat_display": "Apparence",
"settings_cat_filament": "Filament",
"settings_cat_language": "Langue",
"settings_cat_printer": "Imprimante",
"settings_cat_system": "Système",
"settings_cat_theme": "Basculer clair / sombre",
"settings_connection": "Connexion",
"settings_default_slot": "Slot par défaut (couleur unique)",
"settings_device_id": "ID de l'appareil",
"settings_device_id_hint": "32 caractères hexadécimaux",
"settings_device_id_placeholder": "32 caractères hexadécimaux",
"settings_filament_mapping": "Mappage du profil de filament (par emplacement)",
"settings_filament_mapping_hint": "Profil Orca fixe par emplacement AMS. Lors de la synchronisation du slicer, le bridge envoie ce profil au lieu de « Generic ».",
"settings_filament_mapping_label": "Mappage du profil de filament (par emplacement)",
"settings_filament_mapping_save": "Enregistrer le mappage",
"settings_filament_mapping_save_label": "Enregistrer le mappage",
"settings_file_ready_banner": "Barre d'impression",
"settings_file_ready_dialog": "Dialogue d'impression",
"settings_file_ready_mode": "Après téléchargement : Comportement de démarrage d'impression",
"settings_integrations": "Intégrations",
"settings_language": "Langue",
"settings_mode_id": "ID du mode",
"settings_mode_id_placeholder": "20030",
"settings_mqtt_port": "Port MQTT",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Importer des profils",
"settings_orca_profiles_label": "Profils OrcaSlicer",
"settings_password": "Mot de passe MQTT",
"settings_poll": "Intervalle de sondage (secondes)",
"settings_poll_interval_hint": "Fréquence à laquelle le bridge interroge l'état de l'imprimante",
"settings_poll_interval_label": "Intervalle de sondage (secondes)",
"settings_print": "Paramètres d'impression",
"settings_printer_ip": "IP de l'imprimante",
"settings_printer_name": "Nom de l'imprimante",
"settings_printer_name_placeholder": "p. ex. Kobra X Salon",
"settings_save": "Enregistrer et redémarrer",
"settings_slot_auto": "Auto (tous les slots chargés)",
"settings_theme_toggle": "Basculer clair / sombre",
"settings_title": "Paramètres",
"settings_username": "Nom d'utilisateur MQTT",
"settings_vendor_filter_placeholder": "Rechercher des fabricants…",
"settings_version": "Version",
"settings_visible_vendors": "Fabricants visibles (liste des profils)",
"settings_visible_vendors_hint": "Seuls ces fabricants apparaissent dans la liste des profils d'emplacement. Rien de sélectionné = tout afficher. « Generic » et vos propres profils sont toujours visibles.",
"settings_visible_vendors_label": "Fabricants visibles (liste des profils)",
"settings_visible_vendors_save": "Enregistrer la sélection",
"settings_visible_vendors_save_label": "Enregistrer la sélection",
"settings_web_upload_warning": "Afficher un avertissement lors de l'impression de fichiers web",
"sf_all": "Tout",
"sf_err": "✗ Échoués",
"sf_new": "Nouveau",
"sf_ok": "✓ Terminés",
"skip_already": "ignoré",
"skip_btn_label": "Objets",
"skip_cancel": "Annuler",
"skip_confirm": "Ignorer",
"skip_confirm_btn": "Ignorer",
"orca_profile_user_empty": " aucun ",
"orca_profile_uploading": "Envoi en cours",
"orca_profile_done": "Importé",
"orca_profile_skipped": "ignoré",
"log_dir_all": "Tout",
"log_lvl_label": "Niveau :",
"file_ready_btn": "▶ Lancer l'impression",
"file_slots_btn": "🎨 Choisir les slots",
"file_cancel_btn": "✕ Annuler",
"nav_printers": "Imprimantes",
"skip_title": "✂ Ignorer des objets",
"skip_hint": "Décochez les objets que vous ne souhaitez plus imprimer :",
"skip_btn_label": "Objets",
"skip_no_objects": "Aucun objet dans cette impression.",
"skip_already": "ignoré",
"skip_select_at_least_one": "Veuillez sélectionner au moins un objet.",
"skip_sending": "Envoi …",
"skip_success": "Les objets seront ignorés.",
"skip_title": "Ignorer des objets",
"slot_edit_color": "Couleur",
"slot_edit_custom": "ex. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Charger",
"slot_edit_material": "Matériau",
"slot_edit_ok": "Slot AMS",
"slot_edit_profile": "Profil OrcaSlicer",
"slot_edit_profile_default": "— Générique (défaut) —",
"slot_edit_profile_hint": "Envoyé lors de la synchronisation OrcaSlicer comme marque spécifique au lieu de \"Générique\"",
"slot_edit_save": "💾 Enregistrer",
"slot_edit_title": "Modifier le slot",
"slot_edit_unload": "⬆ Décharger",
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Silencieux",
"speed_sport": "🚀 Sport",
"ss_date": "↓ Date",
"ss_dur": "⏱ Durée d'impression",
"ss_name": "AZ Nom",
"store_delete_confirm": "Supprimer le fichier ?",
"store_download": "⬇ Télécharger",
"store_empty": "Aucun fichier uploadé.",
"store_estimate": "Estimation",
"store_never": "jamais imprimé",
"store_no_results": "Aucun fichier trouvé.",
"store_print": "▶ Imprimer",
"store_print_confirm": "Imprimer le fichier ?",
"store_refresh": "↻ Actualiser",
"fd_objects_hint": "Ignorer des objets (optionnel) :",
"fd_objects_toggle": "Ignorer des objets",
"fd_slots_hint": "Associer le canal GCode au slot AMS :",
"fd_cancel": "Annuler",
"fd_print": "▶ Imprimer",
"fd_no_slots_msg": "Aucun slot AMS chargé.{br}Lancer l'impression quand même ?",
"fd_slot": "Slot",
"fd_no_matching_material": "Aucun matériau correspondant",
"fd_used": "UTILISÉ",
"add_printer": "Ajouter une imprimante",
"apd_lbl_ip": "IP de l'imprimante",
"apd_lbl_name": "Nom (optionnel)",
"apd_placeholder_name": "ex. Kobra X Salon",
"apd_cancel": "Annuler",
"apd_confirm": "Ajouter",
"apd_fetching": "Récupération des données de l'imprimante",
"apd_success": "Imprimante ajoutée, redémarrage du bridge…",
"apd_err_ip": "Veuillez saisir une adresse IP",
"printers_remove": "Supprimer l'imprimante",
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
"printers_active": "● actif",
"printers_switch": "Changer →",
"printers_current": "Imprimante actuelle",
"printers_loading": "Chargement…",
"printers_none": "Aucune imprimante configurée.",
"printers_empty_hint": "Aucune imprimante configurée.",
"nav_browser": "Navigateur",
"panel_browser_title": "Explorateur de fichiers",
"store_search_placeholder": "🔍 Rechercher…",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "parcourir",
"store_upload_label_prefix": "Déposez un GCode ici ou ",
"store_upload_only_gcode": "✗ Seuls les fichiers GCode sont autorisés (.gcode, .3mf, .bgcode)",
"store_upload_success": "✓ {file}",
"store_web_verify_abort": "Annuler",
"store_web_verify_confirm": "Confirmer",
"store_web_verify_msg": "Veuillez vérifier que ce fichier a été créé pour Anycubic Kobra X.",
"store_empty": "Aucun fichier uploadé.",
"store_refresh": "↻ Actualiser",
"store_print": "▶ Imprimer",
"store_download": "⬇ Télécharger",
"store_delete_confirm": "Supprimer le fichier ?",
"store_print_confirm": "Imprimer le fichier ?",
"store_web_verify_title": "Vérifier le fichier",
"update_apply": "Installer maintenant",
"update_applying": "Téléchargement…",
"update_available": "disponible",
"update_check": "Vérifier les mises à jour",
"update_checking": "Vérification…",
"update_docker": "Copier la commande",
"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…",
"slot_copy_from": "Copier la couleur du slot…"
}
"store_web_verify_msg": "Veuillez vérifier que ce fichier a été créé pour l'Anycubic Kobra X.",
"store_web_verify_confirm": "Confirmer",
"store_web_verify_abort": "Annuler",
"store_no_results": "Aucun fichier trouvé.",
"store_never": "jamais imprimé",
"store_estimate": "Estimation",
"store_upload_label_prefix": "Déposez un GCode ici ou ",
"store_upload_label_browse": "parcourir",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Seuls les fichiers GCode sont autorisés (.gcode, .3mf, .bgcode)",
"sf_all": "Tout",
"sf_ok": "✓ Terminés",
"sf_err": "✗ Échoués",
"sf_new": "Nouveau",
"ss_date": "↓ Date",
"ss_name": "AZ Nom",
"ss_dur": "⏱ Durée d'impression",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Personnalisé",
"fd_options_title": "Options",
"print_auto_leveling": "Mise à niveau auto pour cette impression",
"settings_file_ready_mode": "Démarrer le dialogue d'impression",
"settings_file_ready_banner": "Barre d'impression",
"settings_file_ready_dialog": "Dialogue d'impression",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Sens :",
"log_lvl_err": "⛔ Erreurs",
"log_lvl_warn": "⚠ Avert.",
"log_topic_label": "Sujet :",
"log_topic_ams": "AMS",
"log_topic_print": "Impression",
"log_topic_info": "Info",
"log_topic_status": "Statut",
"log_download": "⬇ Télécharger",
"log_auto": "⬇ Auto",
"log_clear": "✕ Effacer",
"log_filter_placeholder": "Filtrer…",
"skip_cancel": "Annuler",
"skip_confirm": "Ignorer"
}

View File

@@ -1,329 +0,0 @@
{
"ace_dry_auto_refill": "Ricarica automatica",
"ace_dry_chart": "Cronologia (Temp/Umidità)",
"ace_dry_current_temp": "Temperatura",
"ace_dry_dialog_cancel": "Annulla",
"ace_dry_dialog_confirm": "Conferma",
"ace_dry_dialog_custom_name": "Nome personalizzato",
"ace_dry_dialog_reset_default": "Ripristina predefiniti",
"ace_dry_dialog_save_restart": "Salva e riavvia",
"ace_dry_dialog_temp": "Temperatura (30-80°C)",
"ace_dry_dialog_time": "Tempo rim. (h:m:s)",
"ace_dry_dialog_title": "Impostazioni Temp/Tempo essiccatore",
"ace_dry_dryer": "Essiccatore",
"ace_dry_duration": "Durata (min)",
"ace_dry_enable": "Abilita essiccazione",
"ace_dry_humidity": "Umidità",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_custom": "Personalizzato",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_tpu": "TPU",
"ace_dry_start": "▶ Avvia",
"ace_dry_status_off": "Stato: Spento",
"ace_dry_status_on": "Stato: Attivo",
"ace_dry_status_remaining": "Rimanente",
"ace_dry_stop": "■ Ferma",
"ace_dry_temp": "Temperatura (°C)",
"ace_dry_temp_line": "Temperatura di essiccazione",
"ace_dry_time_line": "Tempo di essiccazione",
"ace_dry_ui_pending": "(Solo interfaccia, backend a seguire)",
"add_printer": "Aggiungi stampante",
"ams_empty": "Vuoto",
"ams_no_data": "Nessun dato ricevuto dall' AMS",
"apd_cancel": "Annulla",
"apd_confirm": "Aggiungi",
"apd_err_ip": "Inserisci un indirizzo IP",
"apd_fetching": "Recupero dati dalla stampante…",
"apd_lbl_ip": "IP stampante",
"apd_lbl_name": "Nome (opzionale)",
"apd_placeholder_name": "es. Kobra X Soggiorno",
"apd_success": "Stampante aggiunta, riavvio del bridge in corso…",
"apd_title": "Aggiungi stampante",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Avvia",
"btn_cam_stop": "◼ Camera",
"btn_cam_stop2": "◼ Ferma",
"btn_cancel": "✕ Stop",
"btn_cancel_generic": "Annulla",
"btn_confirm_generic": "Conferma",
"btn_connect": "⚡ Connetti",
"btn_delete": "Elimina",
"btn_disable_motors": "Spegni motori",
"btn_disconnect": "✕ Disconnetti",
"btn_home_all": "Home generale",
"btn_home_xy": "Home XY",
"btn_home_z": "Home Z",
"btn_pause": "⏸ Pausa",
"btn_resume": "▶ Riprendi",
"cam_placeholder": "📷 Camera non avviata",
"cam_stream_unavailable": "Flusso video non disponibile",
"card_ace_dry": "Essiccazione ACE",
"card_ams": "Filamento",
"card_cam": "Camera",
"card_light_fan": "Ventola",
"card_progress": "Avanzamento",
"card_speed": "Velocità di stampa",
"card_temps": "Temperature",
"confirm_cancel": "Annullare davvero la stampa?",
"fd_cancel": "Annulla",
"fd_no_matching_material": "Nessun materiale corrispondente",
"fd_no_slots_msg": "Nessuno slot AMS caricato.{br}Avviare comunque la stampa?",
"fd_objects_hint": "Salta oggetti (opzionale):",
"fd_objects_toggle": "Salta oggetti",
"fd_options_title": "Opzioni di stampa",
"fd_print": "▶ Stampa",
"fd_slot": "Slot",
"fd_slots_hint": "Assegna il canale GCode allo slot AMS:",
"fd_title": "Assegnazione slot",
"fd_used": "USATO",
"file_cancel_btn": "✕ Annulla",
"file_ready_btn": "▶ Avvia stampa",
"file_slots_btn": "🎨 Seleziona slot",
"header_status_complete": "Completato",
"header_status_error": "Errore",
"header_status_printing": "In stampa",
"header_status_standby": "Pronto",
"hint_ip_no_port": "Solo indirizzo IP, senza porta (es. 192.168.1.102)",
"kobra_auto_leveling": "Livellamento automatico",
"kobra_busy": "Occupato",
"kobra_canceled": "Annullato",
"kobra_checking": "Verifica",
"kobra_failed": "Errore",
"kobra_finished": "Finito",
"kobra_free": "Pronto",
"kobra_init": "Inizializzazione",
"kobra_offline": "Offline",
"kobra_paused": "In pausa",
"kobra_pausing": "Pausa in corso...",
"kobra_preheating": "Preriscaldamento",
"kobra_printing": "In stampa",
"kobra_resumed": "Ripreso",
"kobra_resuming": "Ripresa...",
"kobra_stoped": "Arrestato",
"kobra_stopping": "Arresto...",
"kobra_updated": "Aggiornamento",
"label_bed": "Piatto",
"label_fan": "🌀 Ventola",
"label_light": "💡 Luce",
"label_nozzle": "Ugello",
"label_off": "Off",
"label_on_off": "On / Off",
"label_set": "Imposta",
"label_slot": "Slot",
"label_speed": "Velocità",
"label_step": "Ampiezza passo:",
"label_target_c": "Target:",
"lbl_conn_error": "Errore di connessione:",
"lbl_elapsed": "Trascorso:",
"lbl_feed": "Carica",
"lbl_layers": "Layer",
"lbl_light": "💡 Luce",
"lbl_remaining": "Rimanente:",
"lbl_slicer_time": "Stima slicer:",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
"lbl_spoolman_url": "URL server",
"lbl_unload": "Rimuovi",
"lbl_zpos": "Z (mm)",
"log_auto": "⬇ Auto",
"log_axis": "Asse",
"log_bed": "Piatto →",
"log_cam_start": "Camera avviata:",
"log_cam_stop": "Camera arrestata",
"log_clear": "✕ Cancella",
"log_delete_failed": "Eliminazione non riuscita",
"log_dir_all": "Tutti",
"log_dir_label": "Dir:",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_download": "⬇ Scarica",
"log_error": "Errore:",
"log_fan": "Ventola →",
"log_filter_placeholder": "Filtra…",
"log_home": "Home",
"log_home_all": "Home generale",
"log_light_off": "Luce spenta",
"log_light_on": "Luce accesa",
"log_lvl_err": "⛔ Errori",
"log_lvl_label": "Livello:",
"log_lvl_warn": "⚠ Avvisi",
"log_nozzle": "Ugello →",
"log_poll_error": "Errore di sincronizzazione:",
"log_print_action": "Stampa:",
"log_print_start": "Inizio stampa:",
"log_topic_ams": "AMS",
"log_topic_info": "Info",
"log_topic_label": "Argomento:",
"log_topic_print": "Stampa",
"log_topic_status": "Stato",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
"nav_browser": "Browser",
"nav_console": "Console",
"nav_dashboard": "Dashboard",
"nav_extras": "Luce / Ventola",
"nav_motion": "Movimento",
"nav_print": "Stampa",
"nav_printers": "Stampanti",
"nav_settings": "Impostazioni",
"nav_temps": "Temperature",
"orca_profile_done": "Importato",
"orca_profile_dropmsg": "Trascina qui o fai clic",
"orca_profile_help_html": "Carica un file <b>ZIP</b> della tua cartella filamenti di OrcaSlicer o file singoli <b>.json</b>.<br>In OrcaSlicer: <i>Aiuto → Mostra cartella di configurazione → user/&lt;id&gt;/filament/</i>",
"orca_profile_hint": "Importa i tuoi profili di filamento OrcaSlicer (apri la cartella utente tramite Aiuto → Mostra cartella di configurazione)",
"orca_profile_import_btn": "Importa profili",
"orca_profile_import_link": "★ Importa i tuoi profili…",
"orca_profile_import_title": "Importa i tuoi profili OrcaSlicer",
"orca_profile_list_label": "Attualmente importati",
"orca_profile_section": "Profili OrcaSlicer",
"orca_profile_skipped": "saltato",
"orca_profile_uploading": "Caricamento in corso…",
"orca_profile_user_empty": " nessuno ",
"orca_profile_user_label": "Profili personali",
"panel_ams_title": "Filamento",
"panel_browser_title": "Browser dei file",
"panel_console_title": "Registro eventi",
"panel_extras_camera": "Camera",
"panel_extras_fan": "Ventola",
"panel_extras_light": "Luce",
"panel_motion_xy": "Controllo Assi",
"panel_print_btn_cancel": "✕ Annulla",
"panel_print_btn_pause": "⏸ Pausa",
"panel_print_btn_resume": "▶ Riprendi",
"panel_print_temps_live": "Temperature (In tempo reale)",
"panel_print_title": "Controllo stampa",
"panel_temps_bed": "Piatto riscaldato",
"panel_temps_chart": "Cronologia (ultime 60 letture)",
"panel_temps_nozzle": "Ugello",
"print_auto_leveling": "Livellamento automatico",
"printers_active": "● attiva",
"printers_current": "Stampante corrente",
"printers_empty_hint": "Nessuna stampante ancora configurata.",
"printers_loading": "Caricamento in corso…",
"printers_none": "Nessuna stampante configurata.",
"printers_remove": "Rimuovi stampante",
"printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
"printers_switch": "Cambia →",
"progress_action_clear": "Cancella",
"progress_action_print": "Stampa",
"progress_action_slots": "Mappa slot",
"settings_auto_leveling": "Livellamento automatico predefinito",
"settings_auto_leveling_label": "Livellamento automatico prima della stampa",
"settings_btn_tooltip": "Impostazioni",
"settings_camera_on_print": "Attiva la camera all'avvio della stampa",
"settings_cat_connection": "Connessione",
"settings_cat_display": "Aspetto",
"settings_cat_filament": "Filamento",
"settings_cat_language": "Lingua",
"settings_cat_printer": "Stampante",
"settings_cat_system": "Sistema",
"settings_cat_theme": "Alterna chiaro / scuro",
"settings_connection": "Connessione",
"settings_default_slot": "Slot predefinito (colore singolo)",
"settings_device_id": "ID dispositivo",
"settings_device_id_hint": "32 caratteri esadecimali",
"settings_device_id_placeholder": "32 caratteri esadecimali",
"settings_filament_mapping": "Mappatura profilo filamento (per slot)",
"settings_filament_mapping_hint": "Profilo Orca fisso per slot AMS. Durante la sincronizzazione dello slicer, il bridge invia questo profilo al posto di \"Generic\".",
"settings_filament_mapping_label": "Mappatura profilo filamento (per slot)",
"settings_filament_mapping_save": "Salva mappatura",
"settings_filament_mapping_save_label": "Salva mappatura",
"settings_file_ready_banner": "Barra di stampa",
"settings_file_ready_dialog": "Finestra di dialogo stampa",
"settings_file_ready_mode": "Dopo il caricamento: Comportamento di avvio stampa",
"settings_integrations": "Integrazioni",
"settings_language": "Lingua",
"settings_mode_id": "ID modalità",
"settings_mode_id_placeholder": "20030",
"settings_mqtt_port": "Porta MQTT",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Importa profili",
"settings_orca_profiles_label": "Profili OrcaSlicer",
"settings_password": "Password MQTT",
"settings_poll": "Intervallo di sincronizzazione (secondi)",
"settings_poll_interval_hint": "Con che frequenza il bridge interroga lo stato della stampante",
"settings_poll_interval_label": "Intervallo di sincronizzazione (secondi)",
"settings_print": "Impostazioni di stampa",
"settings_printer_ip": "IP stampante",
"settings_printer_name": "Nome stampante",
"settings_printer_name_placeholder": "p. es. Kobra X Sala",
"settings_save": "Salva e riavvia",
"settings_slot_auto": "Auto (tutti gli slot caricati)",
"settings_theme_toggle": "Attiva/disattiva chiaro / scuro",
"settings_title": "Impostazioni",
"settings_username": "Nome utente MQTT",
"settings_vendor_filter_placeholder": "Cerca produttori…",
"settings_version": "Versione",
"settings_visible_vendors": "Produttori visibili (menu del profilo)",
"settings_visible_vendors_hint": "Solo questi produttori appariranno nel menu del profilo dello slot. Se non selezioni nulla = mostra tutti. I profili \"Generici\" e i tuoi personali sono sempre visibili.",
"settings_visible_vendors_label": "Produttori visibili (menu del profilo)",
"settings_visible_vendors_save": "Salva selezione",
"settings_visible_vendors_save_label": "Salva selezione",
"settings_web_upload_warning": "Mostra un avviso quando si stampano caricamenti web",
"sf_all": "Tutti",
"sf_err": "✗ Fallito",
"sf_new": "Nuovo",
"sf_ok": "✓ Completato",
"skip_already": "saltato",
"skip_btn_label": "Oggetti",
"skip_cancel": "Annulla",
"skip_confirm": "Salta",
"skip_confirm_btn": "Salta",
"skip_hint": "Deseleziona gli oggetti che non vuoi più stampare:",
"skip_no_objects": "Nessun oggetto in questa stampa.",
"skip_select_at_least_one": "Seleziona almeno un oggetto.",
"skip_sending": "Invio in corso …",
"skip_success": "Gli oggetti verranno saltati.",
"skip_title": "✂ Salta oggetti",
"slot_edit_color": "Colore",
"slot_edit_custom": "es. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Carica",
"slot_edit_material": "Materiale",
"slot_edit_ok": "Slot AMS",
"slot_edit_profile": "Profilo OrcaSlicer",
"slot_edit_profile_default": "— Generico (predefinito) —",
"slot_edit_profile_hint": "Inviato durante la sincronizzazione con OrcaSlicer come marchio specifico invece di un semplice \"Generico\"",
"slot_edit_save": "💾 Salva",
"slot_edit_title": "Modifica slot",
"slot_edit_unload": "⬆ Rimuovi",
"speed_normal": "⚡ Normale",
"speed_silent": "🐢 Silenzioso",
"speed_sport": "🚀 Sport",
"ss_date": "↓ Data",
"ss_dur": "⏱ Tempo di stampa",
"ss_name": "Nome AZ",
"store_delete_confirm": "Eliminare il file?",
"store_download": "⬇ Scarica",
"store_empty": "Nessun file caricato.",
"store_estimate": "Stima",
"store_never": "mai stampato",
"store_no_results": "Nessun file trovato.",
"store_print": "▶ Stampa",
"store_print_confirm": "Stampare il file?",
"store_refresh": "↻ Aggiorna",
"store_search_placeholder": "🔍 Cerca…",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "sfoglia",
"store_upload_label_prefix": "Trascina il GCode qui o ",
"store_upload_only_gcode": "✗ Sono consentiti solo file GCode (.gcode, .3mf, .bgcode)",
"store_upload_success": "✓ {file}",
"store_web_verify_abort": "Annulla",
"store_web_verify_confirm": "Conferma",
"store_web_verify_msg": "Verifica che questo file sia stato creato per Anycubic Kobra X.",
"store_web_verify_title": "Verifica file",
"update_apply": "Installa ora",
"update_applying": "Download in corso...",
"update_available": "disponibile",
"update_check": "Controlla aggiornamenti",
"update_checking": "Verifica in corso...",
"update_docker": "Copia comando",
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
"update_error": "Errore",
"update_none": "Già aggiornato",
"update_restarting": "Riavvio in corso...",
"slot_copy_from": "Copia colore dallo slot…"
}

View File

@@ -1,331 +1,291 @@
{
"ace_dry_auto_refill": "自动补料",
"ace_dry_chart": "历史 (温度/湿度)",
"ace_dry_current_temp": "温度",
"ace_dry_dialog_cancel": "取消",
"ace_dry_dialog_confirm": "确认",
"ace_dry_dialog_custom_name": "自定义名称",
"ace_dry_dialog_reset_default": "恢复默认",
"ace_dry_dialog_save_restart": "保存并重启",
"ace_dry_dialog_temp": "温度 (30-80°C)",
"ace_dry_dialog_time": "剩余时间 (h:m:s)",
"ace_dry_dialog_title": "烘干温度/时间设置",
"header_status_standby": "就绪",
"header_status_printing": "打印中",
"header_status_complete": "完成",
"header_status_error": "错误",
"kobra_free": "就绪",
"kobra_busy": "忙碌",
"kobra_printing": "打印中",
"kobra_preheating": "预热中",
"kobra_auto_leveling": "自动调平",
"kobra_checking": "检查中",
"kobra_updated": "更新中",
"kobra_init": "初始化中",
"kobra_pausing": "暂停中...",
"kobra_paused": "已暂停",
"kobra_resuming": "恢复中...",
"kobra_resumed": "已恢复",
"kobra_stopping": "停止中...",
"kobra_stoped": "已停止",
"kobra_finished": "已完成",
"kobra_failed": "错误",
"kobra_canceled": "已取消",
"kobra_offline": "离线",
"nav_dashboard": "仪表盘",
"nav_print": "打印",
"nav_temps": "温度",
"nav_motion": "运动",
"nav_ams": "AMS",
"nav_extras": "灯光 / 风扇",
"nav_console": "控制台",
"card_progress": "进度",
"card_temps": "温度",
"card_light_fan": "风扇",
"card_speed": "打印速度",
"card_cam": "相机",
"lbl_elapsed": "已用时间:",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_layers": "层",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 静音",
"speed_normal": "⚡ 标准",
"speed_sport": "🚀 运动",
"lbl_light": "💡 灯光",
"lbl_feed": "进料",
"lbl_unload": "退料",
"card_ace_dry": "ACE 烘干",
"ace_dry_dryer": "烘干机",
"ace_dry_duration": "时长 (分钟)",
"ace_dry_enable": "启用烘干",
"ace_dry_humidity": "湿度",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_custom": "自定义",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_tpu": "TPU",
"ace_dry_start": "▶ 启动",
"ace_dry_status_off": "状态: 关闭",
"ace_dry_status_on": "状态: 运行中",
"ace_dry_status_remaining": "剩余",
"ace_dry_stop": "■ 停止",
"ace_dry_humidity": "湿度",
"ace_dry_current_temp": "温度",
"ace_dry_chart": "历史 (温度/湿度)",
"ace_dry_temp": "温度 (°C)",
"ace_dry_duration": "时长 (分钟)",
"ace_dry_start": "▶ 启动",
"ace_dry_stop": "■ 停止",
"ace_dry_auto_refill": "自动补料",
"ace_dry_enable": "启用烘干",
"ace_dry_temp_line": "烘干温度",
"ace_dry_time_line": "烘干时间",
"ace_dry_ui_pending": "(仅 UI后端稍后支持)",
"add_printer": "添加打印机",
"ams_empty": "",
"ams_no_data": "未收到 AMS 数据",
"apd_cancel": "取消",
"apd_confirm": "添加",
"apd_err_ip": "请输入 IP 地址",
"apd_fetching": "正在从打印机获取数据…",
"apd_lbl_ip": "打印机 IP",
"apd_lbl_name": "名称 (可选)",
"apd_placeholder_name": "例如 Kobra X 客厅",
"apd_success": "打印机已添加Bridge 正在重启…",
"apd_title": "添加打印机",
"btn_cam_start": "▶ 相机",
"btn_cam_start2": "▶ 启动",
"btn_cam_stop": "◼ 相机",
"btn_cam_stop2": "◼ 停止",
"btn_cancel": "✕ 停止",
"btn_cancel_generic": "取消",
"btn_confirm_generic": "确认",
"btn_connect": "⚡ 连接",
"btn_delete": "删除",
"btn_disable_motors": "关闭电机",
"btn_disconnect": "✕ 断开",
"btn_home_all": "全部回零",
"btn_home_xy": "回零 XY",
"btn_home_z": "回零 Z",
"btn_pause": "⏸ 暂停",
"btn_resume": "▶ 继续",
"ace_dry_dialog_title": "烘干温度/时间设置",
"ace_dry_dialog_temp": "温度 (30-80°C)",
"ace_dry_dialog_time": "剩余时间 (h:m:s)",
"ace_dry_dialog_confirm": "确认",
"ace_dry_dialog_cancel": "取消",
"ace_dry_dialog_save_restart": "保存并重启",
"ace_dry_dialog_custom_name": "自定义名称",
"ace_dry_dialog_reset_default": "恢复默认",
"cam_placeholder": "📷 相机未启动",
"cam_stream_unavailable": "视频流不可用",
"card_ace_dry": "ACE 烘干",
"card_ams": "耗材",
"card_cam": "相机",
"card_light_fan": "风扇",
"card_progress": "进度",
"card_speed": "打印速度",
"card_temps": "温度",
"confirm_cancel": "确定要取消打印吗?",
"fd_cancel": "取消",
"fd_no_matching_material": "无匹配材料",
"fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?",
"fd_objects_hint": "跳过对象 (可选):",
"fd_objects_toggle": "跳过对象",
"fd_options_title": "选项",
"fd_print": "▶ 打印",
"fd_slot": "槽位",
"fd_slots_hint": "将 GCode 通道分配到 AMS 槽位:",
"fd_title": "槽位分配",
"fd_used": "已用",
"file_cancel_btn": "✕ 取消",
"file_ready_btn": "▶ 开始打印",
"file_slots_btn": "🎨 选择槽位",
"header_status_complete": "完成",
"header_status_error": "错误",
"header_status_printing": "打印中",
"header_status_standby": "就绪",
"hint_ip_no_port": "仅填写 IP不要端口 (例如 192.168.1.102)",
"kobra_auto_leveling": "自动调平",
"kobra_busy": "忙碌",
"kobra_canceled": "已取消",
"kobra_checking": "检查中",
"kobra_failed": "错误",
"kobra_finished": "已完成",
"kobra_free": "就绪",
"kobra_init": "初始化中",
"kobra_offline": "离线",
"kobra_paused": "已暂停",
"kobra_pausing": "暂停中...",
"kobra_preheating": "预热中",
"kobra_printing": "打印中",
"kobra_resumed": "已恢复",
"kobra_resuming": "恢复中...",
"kobra_stoped": "已停止",
"kobra_stopping": "停止中...",
"kobra_updated": "更新中",
"btn_cam_start": "▶ 相机",
"btn_cam_stop": "◼ 相机",
"btn_pause": "⏸ 暂停",
"btn_resume": "▶ 继续",
"btn_cancel": "✕ 停止",
"label_nozzle": "喷嘴",
"label_bed": "热床",
"label_fan": "🌀 风扇",
"label_light": "💡 灯光",
"label_nozzle": "喷嘴",
"label_off": "关闭",
"label_on_off": "开 / 关",
"label_set": "设置",
"label_slot": "槽位",
"label_speed": "速度",
"label_step": "步进:",
"panel_print_title": "打印控制",
"panel_print_btn_pause": "⏸ 暂停",
"panel_print_btn_resume": "▶ 继续",
"panel_print_btn_cancel": "✕ 取消",
"panel_print_temps_live": "温度 (实时)",
"label_set": "设置",
"label_off": "关闭",
"panel_temps_nozzle": "喷嘴",
"panel_temps_bed": "热床",
"panel_temps_chart": "历史 (最近 60 次读数)",
"label_target_c": "目标:",
"lbl_conn_error": "连接错误:",
"lbl_elapsed": "已用时间:",
"lbl_feed": "进料",
"lbl_layers": "",
"lbl_light": "💡 灯光",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_spoolman_sync_rate": "同步频率0=打印结束)",
"lbl_spoolman_url": "服务器地址",
"lbl_unload": "退料",
"lbl_zpos": "Z (mm)",
"log_auto": "⬇ 自动",
"log_axis": "",
"log_bed": "热床 →",
"log_cam_start": "相机已启动:",
"log_cam_stop": "相机已停止",
"log_clear": "✕ 清空",
"log_delete_failed": "删除失败",
"log_dir_all": "全部",
"log_dir_label": "方向:",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_download": "⬇ 下载",
"log_error": "错误:",
"panel_motion_xy": "XY 轴",
"panel_motion_z": "Z 轴",
"label_step": "步进:",
"btn_home_z": "回零 Z",
"btn_home_xy": "回零 XY",
"btn_home_all": "全部回零",
"btn_disable_motors": "关闭电机",
"panel_ams_title": "耗材",
"card_ams": "耗材",
"ams_no_data": "未收到 AMS 数据",
"label_slot": "槽位",
"ams_empty": "",
"panel_extras_light": "灯光",
"panel_extras_fan": "风扇",
"panel_extras_camera": "相机",
"btn_cam_start2": "▶ 启动",
"btn_cam_stop2": "◼ 停止",
"panel_console_title": "事件日志",
"log_light_on": "灯光已开",
"log_light_off": "灯光已关",
"log_fan": "风扇 →",
"log_filter_placeholder": "筛选…",
"log_nozzle": "喷嘴 →",
"log_bed": "热床 →",
"log_axis": "轴",
"log_home": "回零",
"log_home_all": "全部回零",
"log_light_off": "灯光已关",
"log_light_on": "灯光已开",
"log_lvl_err": "⛔ 错误",
"log_lvl_label": "级别:",
"log_lvl_warn": "⚠ 警告",
"log_nozzle": "喷嘴 →",
"log_cam_start": "相机已启动:",
"log_cam_stop": "相机已停止",
"log_poll_error": "轮询错误:",
"log_print_action": "打印:",
"log_print_start": "打印开始:",
"log_topic_ams": "AMS",
"log_topic_info": "信息",
"log_topic_label": "主题:",
"log_topic_print": "打印",
"log_topic_status": "状态",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
"nav_browser": "浏览器",
"nav_console": "控制台",
"nav_dashboard": "仪表盘",
"nav_extras": "灯光 / 风扇",
"nav_motion": "运动",
"nav_print": "打印",
"nav_printers": "打印机",
"log_error": "错误:",
"confirm_cancel": "确定要取消打印吗?",
"settings_title": "设置",
"settings_connection": "连接",
"settings_print": "打印设置",
"settings_poll": "轮询间隔(秒)",
"nav_settings": "设置",
"nav_temps": "温度",
"orca_profile_done": "已导入",
"orca_profile_dropmsg": "拖到此处或点击",
"orca_profile_help_html": "上传 OrcaSlicer 耗材文件夹的 <b>ZIP</b> 或单个 <b>.json</b> 文件。<br>在 OrcaSlicer 中: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"settings_cat_display": "外观",
"settings_cat_filament": "耗材",
"settings_cat_language": "语言",
"settings_cat_theme": "切换浅色 / 深色",
"settings_filament_mapping": "耗材配置映射(每槽位)",
"settings_filament_mapping_save": "保存映射",
"settings_visible_vendors": "可见厂商(配置下拉框)",
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
"settings_visible_vendors_save": "保存选择",
"progress_action_print": "打印",
"progress_action_slots": "分配槽位",
"progress_action_clear": "清除",
"settings_version": "版本",
"settings_save": "保存并重启",
"settings_printer_name": "打印机名称",
"settings_printer_ip": "打印机 IP",
"settings_mqtt_port": "MQTT 端口",
"settings_username": "MQTT 用户名",
"settings_password": "MQTT 密码",
"settings_device_id": "设备 ID",
"settings_mode_id": "模式 ID",
"hint_ip_no_port": "仅填写 IP不要端口 (例如 192.168.1.102)",
"settings_default_slot": "默认槽位 (单色)",
"settings_slot_auto": "自动 (所有已装载槽位)",
"settings_auto_leveling": "打印前自动调平",
"settings_camera_on_print": "打印开始时开启相机",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"update_check": "检查更新",
"update_checking": "检查中...",
"update_available": "可用",
"update_none": "已是最新版本",
"update_apply": "立即安装",
"update_applying": "下载中...",
"update_restarting": "重启中...",
"update_error": "错误",
"btn_connect": "⚡ 连接",
"btn_disconnect": "✕ 断开",
"lbl_conn_error": "连接错误:",
"slot_edit_title": "编辑槽位",
"slot_edit_color": "颜色",
"slot_edit_material": "材料",
"slot_edit_load": "⬇ 进料",
"slot_edit_unload": "⬆ 退料",
"slot_edit_save": "💾 保存",
"slot_edit_custom": "例如 PLA, PETG, ABS…",
"slot_edit_ok": "AMS 槽位",
"slot_edit_profile": "OrcaSlicer 配置",
"slot_edit_profile_hint": "在 OrcaSlicer 同步时发送具体品牌而不仅仅是“Generic”",
"slot_edit_profile_default": "— 通用 (默认) —",
"orca_profile_section": "OrcaSlicer 配置",
"orca_profile_hint": "导入你自己的 OrcaSlicer 耗材配置(在 Help → Show Configuration Folder 打开用户目录)",
"orca_profile_import_btn": "导入配置",
"orca_profile_import_link": "★ 导入自己的配置…",
"orca_profile_import_title": "导入你的 OrcaSlicer 配置",
"orca_profile_help_html": "上传 OrcaSlicer 耗材文件夹的 <b>ZIP</b> 或单个 <b>.json</b> 文件。<br>在 OrcaSlicer 中: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "拖到此处或点击",
"orca_profile_list_label": "已导入",
"orca_profile_section": "OrcaSlicer 配置",
"orca_profile_skipped": "跳过",
"orca_profile_uploading": "上传中…",
"orca_profile_user_empty": "",
"orca_profile_user_label": "自己的配置",
"panel_ams_title": "耗材",
"panel_browser_title": "文件浏览器",
"panel_console_title": "事件日志",
"panel_extras_camera": "相机",
"panel_extras_fan": "风扇",
"panel_extras_light": "灯光",
"panel_motion_xy": "轴控制",
"panel_print_btn_cancel": "✕ 取消",
"panel_print_btn_pause": "⏸ 暂停",
"panel_print_btn_resume": "▶ 继续",
"panel_print_temps_live": "温度 (实时)",
"panel_print_title": "打印控制",
"panel_temps_bed": "热床",
"panel_temps_chart": "历史 (最近 60 次读数)",
"panel_temps_nozzle": "喷嘴",
"print_auto_leveling": "本次打印自动调平",
"printers_active": "● 活动",
"printers_current": "当前打印机",
"printers_empty_hint": "尚未设置打印机。",
"printers_loading": "加载中…",
"printers_none": "未配置打印机。",
"printers_remove": "移除打印机",
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
"printers_switch": "切换 →",
"progress_action_clear": "清除",
"progress_action_print": "打印",
"progress_action_slots": "分配槽位",
"settings_auto_leveling": "打印前自动调平",
"settings_auto_leveling_label": "打印前自动调平",
"settings_vibration_compensation": "打印前共振补偿",
"settings_btn_tooltip": "设置",
"settings_camera_on_print": "打印开始时开启相机",
"settings_cat_connection": "连接",
"settings_cat_display": "外观",
"settings_cat_filament": "耗材",
"settings_cat_language": "语言",
"settings_cat_printer": "打印机",
"settings_cat_system": "系统",
"settings_cat_theme": "切换浅色 / 深色",
"settings_connection": "连接",
"settings_default_slot": "默认槽位 (单色)",
"settings_device_id": "设备 ID",
"settings_device_id_hint": "32 个十六进制字符",
"settings_device_id_placeholder": "32 个十六进制字符",
"settings_filament_mapping": "耗材配置映射(每槽位)",
"settings_filament_mapping_hint": "每个 AMS 槽位的固定 Orca 配置。在切片器同步时Bridge 会发送此配置而不是“Generic”。",
"settings_filament_mapping_label": "耗材配置映射(每槽位)",
"settings_filament_mapping_save": "保存映射",
"settings_filament_mapping_save_label": "保存映射",
"settings_file_ready_banner": "打印栏",
"settings_file_ready_dialog": "打印对话框",
"settings_file_ready_mode": "上传后:开始打印行为",
"settings_integrations": "集成",
"settings_language": "语言",
"settings_mode_id": "模式 ID",
"settings_mode_id_placeholder": "20030",
"settings_mqtt_port": "MQTT 端口",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "导入配置文件",
"settings_orca_profiles_label": "OrcaSlicer 配置文件",
"settings_password": "MQTT 密码",
"settings_poll": "轮询间隔(秒)",
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"settings_poll_interval_label": "轮询间隔(秒)",
"settings_print": "打印设置",
"settings_printer_ip": "打印机 IP",
"settings_printer_name": "打印机名称",
"settings_printer_name_placeholder": "例如 Kobra X 左",
"settings_save": "保存并重启",
"settings_slot_auto": "自动 (所有已装载槽位)",
"settings_theme_toggle": "切换浅色 / 深色",
"settings_title": "设置",
"settings_username": "MQTT 用户名",
"settings_vendor_filter_placeholder": "搜索厂商…",
"settings_version": "版本",
"settings_visible_vendors": "可见厂商(配置下拉框)",
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
"settings_visible_vendors_label": "可见厂商(配置下拉框)",
"settings_visible_vendors_save": "保存选择",
"settings_visible_vendors_save_label": "保存选择",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"sf_all": "全部",
"sf_err": "✗ 失败",
"sf_new": "新",
"sf_ok": "✓ 已完成",
"skip_already": "已跳过",
"skip_btn_label": "对象",
"skip_cancel": "取消",
"skip_confirm": "跳过",
"skip_confirm_btn": "跳过",
"orca_profile_user_empty": "",
"orca_profile_uploading": "上传中…",
"orca_profile_done": "已导入",
"orca_profile_skipped": "跳过",
"log_dir_all": "全部",
"log_lvl_label": "级别:",
"file_ready_btn": "▶ 开始打印",
"file_slots_btn": "🎨 选择槽位",
"file_cancel_btn": "✕ 取消",
"nav_printers": "打印机",
"skip_title": "✂ 跳过对象",
"skip_hint": "取消勾选不想继续打印的对象:",
"skip_btn_label": "对象",
"skip_no_objects": "此打印任务没有对象。",
"skip_already": "已跳过",
"skip_select_at_least_one": "请至少选择一个对象。",
"skip_sending": "发送中 …",
"skip_success": "对象将被跳过。",
"skip_title": "跳过对象",
"slot_edit_color": "颜色",
"slot_edit_custom": "例如 PLA, PETG, ABS…",
"slot_edit_load": "⬇ 进料",
"slot_edit_material": "材料",
"slot_edit_ok": "AMS 槽位",
"slot_edit_profile": "OrcaSlicer 配置",
"slot_edit_profile_default": "— 通用 (默认) —",
"slot_edit_profile_hint": "在 OrcaSlicer 同步时发送具体品牌而不仅仅是“Generic”",
"slot_edit_save": "💾 保存",
"slot_edit_title": "编辑槽位",
"slot_edit_unload": "⬆ 退料",
"speed_normal": "⚡ 标准",
"speed_silent": "🐢 静音",
"speed_sport": "🚀 运动",
"ss_date": "↓ 日期",
"ss_dur": "⏱ 打印时间",
"ss_name": "AZ 名称",
"store_delete_confirm": "删除文件?",
"store_download": "⬇ 下载",
"store_empty": "尚未上传文件。",
"store_estimate": "估算",
"store_never": "从未打印",
"store_no_results": "未找到文件。",
"store_print": "▶ 打印",
"store_print_confirm": "打印文件?",
"store_refresh": "↻ 刷新",
"fd_objects_hint": "跳过对象 (可选):",
"fd_objects_toggle": "跳过对象",
"fd_slots_hint": "将 GCode 通道分配到 AMS 槽位:",
"fd_cancel": "取消",
"fd_print": "▶ 打印",
"fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?",
"fd_slot": "槽位",
"fd_no_matching_material": "无匹配材料",
"fd_used": "已用",
"add_printer": "添加打印机",
"apd_lbl_ip": "打印机 IP",
"apd_lbl_name": "名称 (可选)",
"apd_placeholder_name": "例如 Kobra X 客厅",
"apd_cancel": "取消",
"apd_confirm": "添加",
"apd_fetching": "正在从打印机获取数据…",
"apd_success": "打印机已添加Bridge 正在重启…",
"apd_err_ip": "请输入 IP 地址",
"printers_remove": "移除打印机",
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
"printers_active": "● 活动",
"printers_switch": "切换 →",
"printers_current": "当前打印",
"printers_loading": "加载中…",
"printers_none": "未配置打印机。",
"printers_empty_hint": "尚未设置打印机。",
"nav_browser": "浏览器",
"panel_browser_title": "文件浏览器",
"store_search_placeholder": "🔍 搜索…",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "浏览",
"store_upload_label_prefix": "将 GCode 拖到这里或 ",
"store_upload_only_gcode": "✗ 仅允许 GCode 文件 (.gcode, .3mf, .bgcode)",
"store_upload_success": "✓ {file}",
"store_web_verify_abort": "取消",
"store_web_verify_confirm": "确认",
"store_web_verify_msg": "请验证此文件是为Anycubic Kobra X创建的。",
"store_empty": "尚未上传文件。",
"store_refresh": "↻ 刷新",
"store_print": "▶ 打印",
"store_download": "⬇ 下载",
"store_delete_confirm": "删除文件?",
"store_print_confirm": "打印文件?",
"store_web_verify_title": "验证文件",
"update_apply": "立即安装",
"update_applying": "下载中...",
"update_available": "可用",
"update_check": "检查更新",
"update_checking": "检查中...",
"update_docker": "复制命令",
"update_docker_copied": "已复制执行docker compose pull && docker compose up -d",
"update_error": "错误",
"update_none": "已是最新版本",
"update_restarting": "重启中...",
"slot_copy_from": "从插槽复制颜色…"
}
"store_web_verify_msg": "请确认此文件是为 Anycubic Kobra X 创建的。",
"store_web_verify_confirm": "确认",
"store_web_verify_abort": "取消",
"store_no_results": "未找到文件。",
"store_never": "从未打印",
"store_estimate": "估算",
"store_upload_label_prefix": "将 GCode 拖到这里或 ",
"store_upload_label_browse": "浏览",
"store_upload_busy": "⏳ 上传中…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ 仅允许 GCode 文件 (.gcode, .3mf, .bgcode)",
"sf_all": "全部",
"sf_ok": "✓ 已完成",
"sf_err": "✗ 失败",
"sf_new": "新",
"ss_date": "↓ 日期",
"ss_name": "AZ 名称",
"ss_dur": "⏱ 打印时间",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "自定义",
"fd_options_title": "选项",
"print_auto_leveling": "本次打印自动调平",
"settings_file_ready_mode": "开始打印对话框",
"settings_file_ready_banner": "打印栏",
"settings_file_ready_dialog": "打印对话框",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "方向:",
"log_lvl_err": "⛔ 错误",
"log_lvl_warn": "⚠ 警告",
"log_topic_label": "主题:",
"log_topic_ams": "AMS",
"log_topic_print": "打印",
"log_topic_info": "信息",
"log_topic_status": "状态",
"log_download": "⬇ 下载",
"log_auto": "⬇ 自动",
"log_clear": "✕ 清空",
"log_filter_placeholder": "筛选…",
"skip_cancel": "取消",
"skip_confirm": "跳过"
}