Compare commits

34 Commits

Author SHA1 Message Date
3f6ea269e6 build: sources for v0.9.20 2026-06-08 23:14:50 +02:00
3fff6e25f0 docs(readme): explicit warning that standalone binaries need anycubic_slicer.crt + .key next to the executable (from anycubic-certs.zip) 2026-06-04 20:34:41 +02:00
0f5a8cbc72 sync: kobrax_moonraker_bridge.py mit Dev-Repo (VERSION-Lookup-Fix) 2026-06-04 12:17:46 +02:00
a40f14af8e build: sources for v0.9.19.1 2026-06-04 11:40:02 +02:00
466b8c518d build: sources for v0.9.19.1 2026-06-03 13:12:30 +02:00
1c5396b37d fix(spec): VERSION ins Onefile einbetten — Windows-EXE zeigte vunknown 2026-06-03 13:08:14 +02:00
c23deebde5 docs(changelog): remove internal test-profile name 'Bert - PLA' 2026-06-02 14:34:55 +02:00
76738e5961 release: v0.9.19 2026-06-02 13:59:53 +02:00
9c82073540 build: sources for v0.9.19 2026-06-02 13:31:47 +02:00
031e34d8ea merge: PR #42 — Dryer toggle false error (@gangoke)
ACE-Dryer setDry geht jetzt fire-and-forget (timeout=0, kein Response-
Check). Drucker führt den Befehl korrekt aus, aber liefert code:0
statt code:200 — was eine 502-Fehlermeldung in der Bridge-UI auslöste
obwohl der Trockner-Toggle eigentlich funktioniert hat.

Pattern identisch zu setAutoFeed (Z.3161).
2026-06-01 14:28:42 +02:00
Gangoke
fc89dfffa5 fire and forget setDry 2026-05-31 17:27:01 -10:00
ac695ecf36 build: sources for v0.9.18 2026-05-31 19:53:36 +02:00
23b8a69065 merge: PR #40 — Spanish Translation Fixes (@pezfisk)
Native-speaker review der spanischen Übersetzung — fehlende Akzente
(impresión, cámara, después, animación, …) und sprachliche
Korrekturen (Pause→Pausa, Start→Iniciar, Layer→Capa, Stream→Stream).
Zusätzlich neue README.es.md plus Links in README.md + README.de.md.
2026-05-31 18:42:06 +02:00
22dc58258c Spanish translation 2026-05-31 15:47:19 +02:00
e4b4d091f3 Spanish translation 2026-05-31 15:42:28 +02:00
ba209827ce build: sources for v0.9.17 2026-05-30 19:32:39 +02:00
d26b37b332 build: sources for v0.9.17 2026-05-30 19:29:10 +02:00
6f269833d2 merge: PR #37 — Language Refactor (@gangoke)
Sprach-System modernisiert:
- Inline-JS-Translations → web/translations/{de,en,es,zh-cn}.json
- Toggle-Button → Dropdown mit Globe-Icon
- Browser-Locale-Detection mit LocalStorage-Priorität
- Backend-Route /kx/ui/translations/<lang>.json (regioned codes wie zh-cn)
- de.json fd_used 'GENUTZT' → 'BELEGT' (Slot-Kontext lesbarer)
- ES + ZH-CN sind AI-übersetzt (Hinweis im PR)
2026-05-30 18:28:58 +02:00
d808cd3ea8 fix(de): fd_used 'GENUTZT' → 'BELEGT' (klingt im deutschen Slot-Kontext natürlicher) 2026-05-30 18:28:28 +02:00
Gangoke
ecd444525a additional mappings and translations 2026-05-28 17:40:22 -10:00
Gangoke
d4bb79a68f revert docker-compose to image 2026-05-28 14:43:06 -10:00
Gangoke
cdaf74985c locale detection to auto select language if user didnt change language manually 2026-05-28 14:41:44 -10:00
Gangoke
8383c59b39 language refactor, baseline, de, en, es, zh-cn 2026-05-28 14:34:17 -10:00
1645de4cad fix: Review-Fixes für PR #32 (Content-Disposition + DE-Übersetzungen)
Nach Squash-Merge von #32 die Reviews-Anpassungen nachgereicht, die im
Dev-Repo (viewit/KX-Bridge@7c834bc) bereits enthalten waren:

- Content-Disposition mit RFC5987 filename*=UTF-8 + ASCII-Fallback
- DE-Strings im Verify-Dialog übersetzt (msg/confirm/abort)
2026-05-27 23:38:05 +02:00
42898c385c feat: GCode Web-Upload + Download + Verify-Dialog (PR #32)
Übernommen mit Anpassungen aus PR #32 von @gangoke:
- Drag&Drop GCode-Upload
- Download-Button pro Datei
- Web-Upload-Verify-Dialog (persistent Flag, global abschaltbar)

Review-Fixes:
- Content-Disposition mit RFC5987 filename*= + ASCII-Fallback
- DE-Strings übersetzt

Dev-Repo: viewit/KX-Bridge@7c834bc
Co-authored-by: gangoke <gangoke@noreply.localhost>
Co-committed-by: gangoke <gangoke@noreply.localhost>
2026-05-27 23:37:41 +02:00
6c5dd14dbd fix: config/config.ini und data/ ignorieren
Bei lokaler Nutzung von docker-compose im Release-Repo legt die Bridge
config.ini (mit Drucker-Credentials!) und data/ (SQLite, GCode-Store) an.
Diese Pfade dürfen niemals im öffentlichen Release-Repo landen.
2026-05-23 23:18:51 +02:00
c2d16270bc feat: docker-compose nutzt Registry-Image als Default
gitea.it-drui.de/viewit/kx-bridge:latest wird automatisch beim Push gebaut
(multi-arch amd64+arm64). 'build: .' bleibt als Kommentar für Self-Builder.
2026-05-23 23:14:59 +02:00
fd4b9b1254 docs: 'docker compose pull' als Update-Weg im README ergänzen
Bridge wird jetzt automatisch als Multi-Arch-Image (amd64+arm64) nach
gitea.it-drui.de/viewit/kx-bridge gebaut. Nutzer können mit
'docker compose pull && docker compose up -d' updaten.
2026-05-23 23:14:07 +02:00
21cd356757 docs: OrcaSlicer-KX (gepatchter Slicer) im README verlinken 2026-05-23 12:57:50 +02:00
40a27a47fc build: sources for v0.9.16 2026-05-22 11:26:16 +02:00
7815c66a82 build: sources for v0.9.15 2026-05-21 21:17:41 +02:00
312b4083d2 build: sources for v0.9.14 2026-05-21 14:35:25 +02:00
534ea41816 docs: Update-Warnung im 0.9.13-CHANGELOG 2026-05-20 17:56:50 +02:00
f1bfab969c build: sources for v0.9.13 2026-05-20 15:14:37 +02:00
26 changed files with 7747 additions and 1260 deletions

10
.gitignore vendored
View File

@@ -7,3 +7,13 @@ dist/
releases/*/kx-bridge
releases/*/extract_credentials
releases/*/extract_credentials.exe
!kx-bridge.spec
# Laufzeit-Daten und Drucker-Credentials — nie committen
config/config.ini
config/*.ini
!config/config.ini.example
data/
!data/orca_filaments.json

View File

@@ -1,5 +1,347 @@
# Changelog
## [0.9.20] 2026-06-08
### Neu
- **Französische Sprachunterstützung (PR #45 von @Nathacks)**
- **Z-Höhe in der Print-UI (PR #49 von @Nathacks).** Zeigt die aktuelle
Z-Position in mm unterhalb des Layer-Zählers.
### Behoben
- **Kamera-Autostart ignorierte das "Kamera bei Druckstart einschalten"-
Setting nach einem Bridge-Restart (Issue #50).** Das Setting wurde in
der Prozessumgebung gecacht — nach dem Speichern in der UI überlebte
der alte Wert den Restart und der neue Wert aus `config.ini` wurde
nicht gelesen.
- **Kamera startete nach manuellem Stopp während eines Drucks automatisch
neu (Issue #50).** Ein neues `_camera_user_stopped`-Flag unterdrückt
den Autostart für die aktuelle Drucksitzung. Es wird beim Druckende
zurückgesetzt.
- **Falscher "Stream nicht verfügbar"-Fehler-Toast beim manuellen
Kamera-Stopp.** Der Bild-Fehler-Handler war noch registriert als
`img.src` geleert wurde.
- **JS-Fehler (`ReferenceError: br is not defined`) beim Licht-Toggle.**
Variable wurde aus dem falschen Scope referenziert.
- Webcam-URLs sind jetzt absolut, damit Mobileraker/Obico-Clients sie
erreichen können.
## [0.9.19.1] 2026-06-04
### Behoben
- Standalone-Binaries (Linux/Windows) zeigten `vunknown` als Version.
Die `VERSION`-Datei ist jetzt ins PyInstaller-Onefile eingebettet.
- Bei fehlenden TLS-Zertifikaten (`anycubic_slicer.crt`/`.key`) gab
es nur den rohen Fehler `[Errno 2] No such file or directory`. Die
Bridge meldet jetzt klar, wo die Dateien hingelegt werden müssen
und dass `anycubic-certs.zip` aus dem Gitea-Release stammt.
### Geändert
- Filament-Profil-Liste neu kuratiert: 209 statt 399 Einträge.
Profile die nur für drucker-spezifische Vendor-Bundles existieren
(z.B. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR,
Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) sind rausgeflogen
— OrcaSlicer hätte sie im Standard-Kobra-X-Setup beim Sync
ohnehin nicht gefunden, weil die jeweiligen Vendor-Bundles nur
bei aktivem Drucker-Vendor geladen werden. Für solche Filamente
bleibt der Custom-Profile-Import (Issue #41) der Weg.
## [0.9.19] 2026-06-02
### Neu
- **🎯 Filament-Sync mit OrcaSlicer matched jetzt das richtige Preset**
statt immer auf „Generic PLA" zu landen. Voraussetzung: ein
OrcaSlicer-Build mit dem
[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)
Empfangs-Patch (im OrcaSlicer-KX-Build dabei). Die Bridge sendet pro
AMS-Slot jetzt `name` + `vendor_name` im Lane-Pfad UND
`gate_filament_name` im Happy-Hare-MMU-Pfad (OrcaSlicer wechselt bei
AMS-Setups automatisch auf den HH-Pfad).
- **Eigene OrcaSlicer-Profile in die Bridge importieren (Issue #41).**
Settings-Tab → „OrcaSlicer-Profile" oder direkt im Slot-Edit-Dialog
(„★ Eigene Profile importieren…") lädst du deine `.json`-Files aus
`~/.config/OrcaSlicer/user/<id>/filament/` hoch — einzeln oder als
ZIP. Erscheint dann im Slot-Dropdown unter „★ Eigene Profile" und
wird beim Sync an Orca als User-Match weitergegeben. Funktioniert
über HTTP, also auch wenn die Bridge im Docker auf Raspi/NAS läuft
und OrcaSlicer auf dem Desktop. Auch reine Override-Profile mit nur
`inherits: "Generic PLA @System"` + ein paar Tweaks werden korrekt
erkannt — die Bridge resolved die vererbten Felder aus dem
System-Parent.
### Fixes
- **AMS-Sync landete hartnäckig auf „Generic PLA":** das Orca-
Datenmodell hat 68 duplikate `filament_id`-Werte (`OGFL99` allein
136 mal), und die Bridge wählte oft eine ID die für den Kobra X
nicht `is_compatible` war (z.B. `GFL92` aus dem Kobra-2-Profil →
Orca verwarf es). Generator priorisiert jetzt Kobra-X-Varianten und
filtert Phantom-Profile (Cross-Vendor-Overrides) raus —
`orca_filaments.json` von 1035 → 400 saubere Profile.
- **Slot ohne expliziten Override sendet jetzt `Generic <Typ>`** statt
einer impliziten Vendor-Annahme. Library-Generic-Profile haben
`compatible_printers: []` (= alle Drucker), sind also immer sichtbar
und matchen verlässlich.
- **Slot-Karte zeigt den Hersteller direkt nach dem Speichern** —
ohne Browser-Reload. `poll()` war async, das Re-Render kam erst
beim nächsten Tick.
- **ACE-Trockner-Toggle warf 502-Fehler obwohl der Trockner ein-/aus-
ging (PR #42 von @gangoke):** `setDry` jetzt fire-and-forget wie
`setAutoFeed`. Der Drucker antwortet auf diesem Push-Topic mit
`code: 0` statt `code: 200`, das hat die Bridge fälschlich als
Fehler interpretiert.
### Datenmodell / API
- `orca_filaments.json` regeneriert: nur echte Vendor-Profile und
OrcaFilamentLibrary-Profile bleiben drin. Bambu/Polymaker/SUNLU-
Library-Profile dabei, Qidi-Cross-Bundles raus.
- Neue Endpoints: `POST /kx/filament/profiles/user` (ZIP/JSON-Upload),
`GET /kx/filament/profiles/user` (Liste der User-Imports),
`DELETE /kx/filament/profiles/user[?vendor=…&name=…]`. Persistenz
in `<KX_DATA_DIR>/orca_filaments.user.json` (Volume — überlebt
Image-Updates).
### Build
- Neues Modul `bridge/orca_filaments.py` (gemeinsame Parser-Helfer
für Generator und Import-Endpoint).
- Dockerfile + release.sh um `orca_filaments.py` erweitert.
## [0.9.18] 2026-05-31
### Neu
- **🎉 Filament-Material und -Farbe pro AMS-Slot aus der Bridge an den
Drucker senden:** Im Slot-Edit-Dialog gewählte Werte gehen jetzt
tatsächlich an den Drucker und werden persistent übernommen — am
Drucker-Display siehst du sofort dieselbe Belegung wie in der Bridge-UI.
In 0.9.17 wurde der Befehl über das falsche MQTT-Topic
(`slicer/printer/…`) gesendet, der Drucker hat ihn stillschweigend
ignoriert. Jetzt geht er über `web/printer/…` wie der Anycubic
Slicer Next es macht (verifiziert via Live-MQTT-Sniff +
Workbench-Vue-Source). **Achtung:** der Drucker muss im Idle-Zustand
sein, und leere Slots lassen sich nicht beschriften.
- **Mehrsprachiges UI spanische Übersetzung von Muttersprachler
überarbeitet (PR #40 von @pezfisk):** fehlende Akzente
(impresión, cámara, después, animación, …), Begriffe vereinheitlicht
(Pause → Pausa, Start → Iniciar, Layer → Capa). Plus neues
`README.es.md` und Cross-Links in den drei READMEs.
- **Z-Höhen-Anzeige in Obico** funktioniert jetzt. Der Drucker liefert
keine echte Z-Position via MQTT (per Live-Sniff bestätigt), die Bridge
schätzt sie aus `curr_layer × layer_height + first_layer_height`.
Layer-Heights kommen aus dem GCode-Header beim Upload, persistiert
im GCode-Store; Fallback aus dem OrcaSlicer-Default-Filename
(`…_0.2_…gcode`). `/server/files/metadata` liefert zusätzlich
`object_height` (Gesamt-Z), damit Obicos `mmProgress`-Widget
`aktuelles Z / Gesamt-Z` anzeigt.
- **Slot-Karte zeigt den OrcaSlicer-Profil-Vendor** unter dem Material
(z.B. „PLA / Polymaker"), mit Profil-Namen + interner ID als
Tooltip. So ist auf einen Blick erkennbar welcher Slot-Override
aktiv ist.
### Fixes
- **Slot-Profil-Auswahl im AMS-Dialog (Issue #39 von @harrygeier):**
drei separate Bugs in 0.9.17 sorgten dafür dass die gewählte Marke
nach dem Speichern verschwand und beim erneuten Öffnen ein falsches
Material angezeigt wurde.
- `multiColorBox/setInfo` über das falsche MQTT-Topic — siehe oben.
- Speichern lief in zwei parallelen Requests (Profil-Override +
Material/Farbe) → Race-Bedingung. Läuft jetzt sequenziell und
reloaded den lokalen State bevor der Dialog geschlossen wird.
- OrcaSlicer-Filament-IDs sind nicht eindeutig — `orca_filaments.json`
hat 68 duplikate IDs, `OGFL99` allein ist 136 Vendor-Profilen
zugeordnet (Erkenntnis von @gangoke). Der primäre Selector ist
jetzt `(vendor, name)` — über alle 1002 Profile eindeutig.
- **MQTT-Reconnect (Issue #33 von @icebear):** wurde der Drucker über
Nacht ausgeschaltet, schlug die Bridge nach 5 Reconnect-Versuchen
(~60 s gesamt) endgültig fehl — Filament-Sync ging morgens noch
(weil das HTTP ist), aber Print-Start scheiterte mit
„connection refused", User musste die Bridge selbst neu starten.
Reader-Thread reconnectet jetzt **endlos** (Backoff cappt bei 60 s)
bis der Drucker wieder antwortet, mit DEBUG-Logging nach den ersten
5 Versuchen damit das Log nicht über Nacht zugemüllt wird.
- **„Unknown child pid"-Warnungen im Log:** beim Killen der Kamera-
`ffmpeg`-Prozesse fehlte das `wait()` — Children blieben als
Zombies und asyncio meldete sie alle ~20 s. Gefixt im CameraCache
+ `/api/camera/stream`.
### UI-Aufräumen
- **Pause-Button als Toggle:** druckt der Drucker → `⏸ Pause`, ist
pausiert → `▶ Weiter`. Der separate „Weiter"-Button entfällt.
- **Pause + Stopp komplett ausgeblendet wenn Drucker idle** — bei
Standby waren beide Buttons vorher dauerhaft sichtbar, was beim
Idle-Drucker verwirrend wirkte.
### Build
- **GCode-Store-Migration:** neue Spalten `layer_height` +
`first_layer_height` in `gcode_files` (automatisch beim ersten
Start von 0.9.18 angelegt).
## [0.9.17] 2026-05-30
### Neu
- **🧪 Obico-Anbindung (experimentell):** Die Bridge spielt jetzt einen
Moonraker, der vom [moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
Plugin akzeptiert wird. Damit funktionieren Time-Lapse, Layer-aligned
First-Layer-Scan und WebRTC-Live-Stream gegen einen (selbst gehosteten oder
Cloud-) Obico-Server. **Hinweis:** Das KI-Modell zur Spaghetti-Erkennung
ist auf seitliche Kamera-Winkel (Ender/Voron) trainiert — wie zuverlässig
es beim Kobra X mit Top-Down-Kamera funktioniert, muss empirisch getestet
werden (bei uns ging es schon ganz gut). Stream, Time-Lapse und Telemetrie
laufen, die Failure-Erkennung ist deshalb noch als experimentell markiert.
- **Mehrsprachiges UI (PR #37 von @gangoke):** Inline-Translations sind raus,
stattdessen wechselbares Sprach-Dropdown mit Globe-Icon. Auto-Auswahl nach
Browser-Locale, manuelle Wahl wird im LocalStorage gemerkt. Sprachen: 🇩🇪 🇬🇧
🇪🇸 🇨🇳 (ES + ZH-CN sind KI-übersetzt und noch nicht von Muttersprachlern
geprüft).
- **OrcaSlicer-Filament-Profil pro AMS-Slot:** Im Slot-Bearbeiten-Dialog kannst
du jetzt ein konkretes OrcaSlicer-Profil (z.B. „PolyTerra PLA — Polymaker")
pro Slot wählen — die Bridge sendet diese Information beim AMS-Sync mit,
statt nur „Generic PLA". Die Profil-Liste wird aus dem OrcaSlicer-Source
generiert (~1000 Profile, 43 Hersteller). Damit OrcaSlicer den Hint
vollständig respektiert, wird ein passender Patch im OrcaSlicer-KX-Build
folgen.
- **H.264-Direkt-Stream:** Neuer Endpunkt `/api/camera/h264` liefert den
Drucker-Kamera-Stream ohne Re-Encoding als MPEG-TS — Latenz drastisch
reduziert, Bridge-CPU bei Obico-Stream von ~13 % auf ~3 %.
### Fixes
- **Temperatur-Setzen über Bridge-UI / Obico löste Drucker-Systemfehler aus:**
Per Live-MQTT-Sniff vom Anycubic Slicer Next korrigiert — der Befehl
`tempature/set` braucht ein `type`-Feld (0=Nozzle, 1=Bett, 2=beide) und
muss über das `web/printer/…`-Topic, nicht `slicer/printer/…`. Nozzle/Bett
über die Bridge heizen jetzt sauber.
- **Große GCode-Uploads (>50 MB) brachen mit Timeout ab:** Der
Connect-Timeout vom Socket lief auch während des `sendall()` — bei ~200 MB
über LAN brauchte das Schieben mehr als die 30 s und wurde fälschlich als
Connect-Timeout abgebrochen. Jetzt sind Connect-, Send- und Read-Phase
separat getimeoutet.
- **Kamera-Snapshot war langsam und konnte sich mit dem Live-Stream blockieren:**
Die Bridge hält nun einen zentralen Kamera-Cache (ein einziger ffmpeg-Prozess
zieht vom Drucker, alle Konsumenten teilen sich den Stream). Snapshots
kommen in ~1.3 ms aus dem RAM statt nach 1-2 s per neuer ffmpeg-Instanz.
Behebt außerdem das Single-Client-Limit am Drucker (HTTP 429 bei parallelen
Zugriffen).
- **Sprachwechsel aktualisierte den GCode-Browser nicht:** Die in die
File-Karten eingebackenen Texte („Drucken", „Schätzung", „Download") blieben
in der alten Sprache. Beim Sprachwechsel werden die Karten jetzt neu
gerendert.
- **GCode Web-Upload + Download + Verify-Dialog (PR #32 von @gangoke):**
Dateien können direkt im Browser hoch/runtergeladen werden, mit
Warn-Dialog wenn ein nicht durch OrcaSlicer hochgeladener GCode gestartet
wird.
### CI/Build
- Multi-Arch Docker-Image (amd64 + arm64) per Gitea-Actions automatisiert.
- Release-Build über lokalen CodeBuilder für alle drei Targets
(linux-amd64, linux-arm64, windows.exe).
## [0.9.16] 2026-05-22
### Neu
- **Kamera bei Druckstart automatisch einschalten:** neue Einstellung „Kamera bei
Druckstart einschalten" — die Bridge startet den Kamera-Stream automatisch, wenn
ein Druck beginnt (für OrcaSlicer und die Bridge-UI).
### Fixes
- **Einfarbiger Druck durch leeren AMS-Slot blockiert:** OrcaSlicer schreibt alle
konfigurierten Filamente in den GCode-Header, auch wenn das Modell nur eines
nutzt — die Bridge meldete dem Drucker dadurch alle Farben als nötig, und ein
leerer ungenutzter Slot brach den Druck ab. Die Bridge mappt jetzt nur die im
GCode tatsächlich genutzten Filamente.
- **Filament-Sync jetzt positionstreu:** Bei einem leeren Slot in der Mitte
(z.B. Slot 1 gelb, 2 leer, 3 rot, 4 weiß) zeigte OrcaSlicer die Farben auf den
falschen Slots. Behoben — leere Slots behalten ihre Position, und das
Sync-Farbformat folgt der Happy-Hare-Konvention (RRGGBB ohne `#`).
- **Slicer-Zeit + Thumbnail fehlten nach Browser-Reload** (oder bei Druckstart
direkt aus OrcaSlicer): beide werden jetzt aus dem GCode-Store anhand des
Dateinamens wiederhergestellt statt aus flüchtigem State.
- **Deutsche Übersetzungslücken** im ACE-Trockner-Dialog behoben.
### Logging
- Wiederholte Log-Zeilen werden als Zähler („×N") zusammengefasst statt zu spammen;
Status-Poll-Verkehr wird nicht mehr auf INFO geloggt.
- Neuer Level-Filter (Alle / Fehler / Warnungen), Toast bei neuen Fehlern, volle
Tracebacks im Browser-Log und ein Download-Dateiname mit Zeitstempel.
## [0.9.15] 2026-05-21
### Fixes (Issue #29)
- **UI im OrcaSlicer-Device-Tab kaputt:** OrcaSlicers eingebetteter Webview lädt
nur das nackte HTML und ignoriert externe `<script>`/`<link>`-Tags — nach der
v0.9.14-Theme-Auslagerung funktionierte dort kein Button mehr. Die Bridge
bettet CSS + JS jetzt inline in die Seite ein — funktioniert in Browser UND
OrcaSlicer-Webview.
- **Dropdowns unlesbar (weiß auf weiß) im OrcaSlicer-Webview:** `color-scheme` +
explizite `select`/`option`-Farben ergänzt, damit die nativen Dropdowns in
Hell- und Dunkel-Theme korrekt dargestellt werden.
- **„Select slots"-Button tat direkt nach Upload nichts:** eine fehlende
Variablen-Deklaration (`storeFiles`) warf einen `ReferenceError`, wenn vor dem
Laden des Browser-Tabs geklickt wurde. Behoben.
- **Upload-Banner kam nach abgeschlossenem Druck zurück:** der „file ready"-Status
wurde nur bei Stop/Abbruch geleert, nicht bei `finished`. Jetzt auch nach
erfolgreichem Druckende geleert.
## [0.9.14] 2026-05-21
### Neu
- **Theme-System (Community-Beitrag von @hirnwunde, PR #27):** Die Web-UI liegt
jetzt in echten Dateien unter `web/themes/<name>/` (`index.html` + `style.css`
+ `app.js`) statt im Python-Quelltext eingebettet. Theme umschalten mit
`--ui-theme <name>`. Für Theme-Autoren gibt es eine dokumentierte Hook-Referenz
(`web/DOC/THEME-CSS-HOOKS.md`, `THEME-JS-ID-HOOKS.md`). Das Default-Theme
enthält die komplette aktuelle UI (ACE2, Objekte überspringen, Filament-Dialog).
Für Nutzer keine Änderung — Binaries/Docker-Image liefern das Theme eingebettet.
- **Neustart über API (Community-Beitrag von @gangoke, PR #28):** neuer Endpoint
`POST /api/restart`, um die Bridge per API neu zu starten — z. B. für einen
Neustart-Button in der Home-Assistant-Integration.
### Intern
- Vereinheitlichter PyInstaller-Build (`kx-bridge.spec`) für Linux, Windows und
Docker — bindet `web/` (Themes) ins Onefile-Binary ein, zur Laufzeit aus
`sys._MEIPASS` gelesen. Theme-Einbettung in Linux-Binary und Windows-EXE verifiziert.
- `data/` in `.gitignore` aufgenommen.
## [0.9.13] 2026-05-20
============================================================
STOPP — VOR DEM DRÜCKEN VON "UPDATE" LESEN
============================================================
Der "Update"-Button ist in 0.9.11 und 0.9.12 KAPUTT.
NICHT benutzen. Stattdessen einmalig manuell updaten —
ab 0.9.13 funktioniert er wieder.
>> WINDOWS-.EXE / LINUX-BINARY-Nutzer — GEFAHR:
Update ÜBERSCHREIBT deine kx-bridge.exe / kx-bridge mit
einer Textdatei. Das Programm STARTET DANN NICHT MEHR
und kann sich nicht selbst reparieren.
--> Manuell updaten: die 0.9.13
kx-bridge-windows.zip / kx-bridge-linux.zip von der
Releases-Seite laden und die alte Datei ersetzen.
Deine config/- und data/-Ordner bleiben erhalten.
>> DOCKER-Nutzer:
Update führt zur Crash-Loop des Containers
(ModuleNotFoundError: No module named '_web_assets').
--> Manuell updaten:
docker compose pull (oder docker compose up -d --build)
config- + data-Volumes bleiben erhalten.
Ab 0.9.13 ist der In-App-Updater repariert und wieder sicher.
============================================================
### Fixes
- **Self-Update war in 0.9.11 und 0.9.12 kaputt (kritisch):** Der In-App-Updater
ersetzte nur `kobrax_moonraker_bridge.py`. Zwei Probleme:
- **Binary/EXE-Modus:** Er überschrieb die laufende Programmdatei
(`sys.executable`) mit einer Python-Textdatei — übrig blieb ein nicht mehr
startbares Programm, das sich nicht selbst reparieren kann (manueller
Re-Download nötig).
- **Python/Docker-Modus:** Seit 0.9.12 importiert die Hauptdatei das
ausgelagerte `_web_assets.py` (gebündeltes Frontend), das der Updater nicht
mitlud → `ModuleNotFoundError: No module named '_web_assets'` → Crash-Loop.
Der Updater lädt jetzt **alle** Bridge-Module (Hauptdatei + `_web_assets.py` +
Client + Loader) erst vollständig herunter, ersetzt sie dann atomar und
**verweigert das Self-Update im Binary-Modus** (mit Verweis auf den manuellen
Download).
## [0.9.12] 2026-05-20
### Fixes

View File

@@ -1,5 +1,344 @@
# Changelog
## [0.9.20] 2026-06-08
### New
- **French language support (PR #45 by @Nathacks)**
- **Z height display in the print UI (PR #49 by @Nathacks).** Shows
current Z position in mm below the layer counter.
### Fixed
- **Camera auto-start ignored "Enable camera on print start" setting
after a bridge restart (issue #50).** The setting was cached in the
process environment — after saving it in the UI, the old value
survived the restart and the new value from `config.ini` was never
read.
- **Camera restarted automatically after manual stop during a print
(issue #50).** A new `_camera_user_stopped` flag suppresses
auto-restart for the current print session. It resets when the
print ends.
- **Spurious "stream unavailable" error toast when stopping the camera
manually.** The image error handler was still registered when
`img.src` was cleared.
- **JS error (`ReferenceError: br is not defined`) when toggling the
light.** Variable was referenced from the wrong scope.
- Webcam URLs are now absolute so that Mobileraker/Obico clients can
reach them.
## [0.9.19.1] 2026-06-04
### Fixed
- Standalone binaries (Linux/Windows) reported `vunknown` as their
version. The `VERSION` file is now embedded into the PyInstaller
onefile bundle.
- When the TLS certificates (`anycubic_slicer.crt`/`.key`) were
missing, the bridge only logged the raw `[Errno 2] No such file
or directory`. It now states clearly where the files need to be
placed and that `anycubic-certs.zip` from the Gitea release is the
source.
### Changed
- Filament profile list re-curated: 209 entries instead of 399.
Profiles that only exist inside printer-specific vendor bundles
(e.g. Eryone Thinker X400, Artillery M1 Pro, WonderMaker ZR,
Tiertime, Cubicon, CoLiDo, Afinia, Snapmaker) were dropped —
OrcaSlicer wouldn't have found them in a default Kobra X setup
anyway, because the matching vendor bundle is only loaded when
the corresponding printer vendor is active. For those filaments
the custom profile import (issue #41) remains the way.
## [0.9.19] 2026-06-02
### New
- **🎯 Filament sync with OrcaSlicer now picks the right preset**
instead of always falling back to "Generic PLA". Requires an
OrcaSlicer build with the
[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)
receive-side patch (included in the OrcaSlicer-KX build). The bridge
sends `name` + `vendor_name` per AMS slot on the lane path AND
`gate_filament_name` per gate on the Happy-Hare MMU path (OrcaSlicer
switches to the HH path automatically for AMS setups).
- **Import your own OrcaSlicer profiles into the bridge (issue #41).**
Settings → "OrcaSlicer Profiles" or directly in the slot-edit dialog
("★ Import own profiles…") lets you upload `.json` files from
`~/.config/OrcaSlicer/user/<id>/filament/` — single files or as a
ZIP. They show up in the slot dropdown under "★ Own profiles" and
are passed through to Orca on sync as user matches. Works over HTTP
so the bridge can run in Docker on a Raspi/NAS while OrcaSlicer
lives on a desktop. Override-only profiles with just
`inherits: "Generic PLA @System"` + a few tweaks are detected
correctly — the bridge resolves the inherited fields from the
system parent.
### Fixes
- **AMS sync stuck on "Generic PLA":** the Orca data model has 68
duplicate `filament_id` values (`OGFL99` alone shared by 136
profiles), and the bridge often picked an ID that was not
`is_compatible` with the Kobra X (e.g. `GFL92` from the Kobra-2
profile → Orca rejected it). The generator now prioritises Kobra-X
variants and filters out phantom profiles (cross-vendor overrides) —
`orca_filaments.json` dropped from 1035 to 400 clean profiles.
- **Slot without an explicit override now sends `Generic <type>`**
instead of an implicit vendor guess. Library generic profiles have
`compatible_printers: []` (= all printers), so they are always
visible and match reliably.
- **Slot card shows the vendor right after save** without a browser
reload. `poll()` was async, the re-render only happened on the next
tick.
- **ACE dryer toggle threw a 502 even though the dryer worked
(PR #42 by @gangoke):** `setDry` is now fire-and-forget like
`setAutoFeed`. The printer answers on that push topic with
`code: 0` instead of `code: 200`, which the bridge wrongly treated
as an error.
### Data model / API
- `orca_filaments.json` regenerated: only real vendor profiles and
OrcaFilamentLibrary profiles. Bambu/Polymaker/SUNLU library profiles
in, Qidi cross-bundles out.
- New endpoints: `POST /kx/filament/profiles/user` (ZIP/JSON upload),
`GET /kx/filament/profiles/user` (list user imports),
`DELETE /kx/filament/profiles/user[?vendor=…&name=…]`. Persisted in
`<KX_DATA_DIR>/orca_filaments.user.json` (volume — survives image
updates).
### Build
- New module `bridge/orca_filaments.py` (shared parser helpers used by
the generator and the import endpoint).
- Dockerfile + release.sh updated to include `orca_filaments.py`.
## [0.9.18] 2026-05-31
### New
- **🎉 Push filament material and colour from the bridge to the
printer:** The values you pick in the slot-edit dialog now actually
reach the printer and stick — the printer display shows the same
slot setup as the bridge UI right away. In 0.9.17 the command was
sent over the wrong MQTT topic (`slicer/printer/…`) and the printer
silently dropped it. It now goes via `web/printer/…` like the
Anycubic Slicer Next does (verified by live MQTT sniff +
Workbench-Vue source). **Note:** the printer must be idle, and
empty slots can not be labelled.
- **Spanish translation reviewed by a native speaker (PR #40 by
@pezfisk):** missing accents (impresión, cámara, después,
animación, …) and term consistency (Pause → Pausa, Start →
Iniciar, Layer → Capa). New `README.es.md` and cross-links between
the three READMEs.
- **Z-height now shows up in Obico.** The printer does not report a
real Z position over MQTT (live-sniff confirmed), so the bridge
estimates it from `current_layer × layer_height + first_layer_height`.
Layer heights are parsed from the gcode header at upload time and
persisted in the gcode store; fallback for prints started directly
from the slicer is the OrcaSlicer default filename pattern
(`…_0.2_…gcode`). `/server/files/metadata` also serves
`object_height` (total Z) so Obicos `mmProgress` widget can render
`current Z / total Z`.
- **Slot card shows the OrcaSlicer profile vendor** under the
material (e.g. `PLA / Polymaker`), with the profile name + internal
ID as tooltip. Lets you see at a glance which slot override is
active.
### Fixes
- **Slot profile picker in the AMS dialog (issue #39 by
@harrygeier):** three separate bugs in 0.9.17 caused the chosen
brand to disappear after save and a different material to show up
on re-open.
- `multiColorBox/setInfo` was sent on the wrong MQTT topic — see
above.
- Save fired two parallel requests (profile override + material/
colour) → race. Now sequential, and the local state is reloaded
before the dialog closes.
- OrcaSlicer filament IDs are not unique — `orca_filaments.json`
has 68 duplicate IDs, `OGFL99` alone is shared by 136 vendor
profiles (caught by @gangoke). The primary selector is now
`(vendor, name)` — unique across all 1002 profiles.
- **MQTT reconnect (issue #33 by @icebear):** if the printer was
powered off overnight the bridge gave up after 5 reconnect attempts
(~60 s total) — filament sync still worked in the morning (its
HTTP), but starting a print failed with `connection refused` and
the user had to restart the bridge itself. The reader thread now
reconnects **forever** (backoff caps at 60 s) until the printer
responds again, with logs dropping to DEBUG after the first 5
attempts so an overnight outage does not spam the log.
- **`Unknown child pid` warnings in the log:** the camera ffmpeg
helpers were killed without awaiting their `wait()` — children
lingered as zombies and asyncio reported them every ~20 s. Fixed
in CameraCache + `/api/camera/stream`.
### UI polish
- **Pause button is now a toggle:** while printing → `⏸ Pause`,
while paused → `▶ Resume`. The separate resume button is gone.
- **Pause + stop hidden when the printer is idle** — both used to be
visible at all times, which was confusing on a standby printer.
### Build
- **gcode store migration:** new columns `layer_height` +
`first_layer_height` on `gcode_files` (added automatically on first
start of 0.9.18).
## [0.9.17] 2026-05-30
### New
- **🧪 Obico integration (experimental):** The bridge now exposes a
Moonraker-compatible surface that the
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
plugin accepts. Time-lapses, layer-aligned first-layer scan and WebRTC
live streaming work against a (self-hosted or cloud) Obico server.
**Note:** the spaghetti-detection ML model is trained on side-view
cameras (Ender/Voron); how well it works with the Kobra X's top-down
camera is still to be evaluated empirically (it already looked
promising in our tests). Stream, time-lapse and telemetry work — the
failure-detection side stays flagged as experimental for now.
- **Multi-language UI (PR #37 by @gangoke):** Inline translations have
moved into JSON files; a globe-icon dropdown lets you switch language.
Browser locale is auto-detected; manual choice persists in
LocalStorage. Languages: 🇩🇪 🇬🇧 🇪🇸 🇨🇳 (ES + ZH-CN are AI-translated
and not verified by native speakers yet).
- **OrcaSlicer filament profile per AMS slot:** The slot-edit dialog now
lets you pick a concrete OrcaSlicer profile (e.g. "PolyTerra PLA —
Polymaker") per slot; the bridge sends it along on AMS sync instead
of just "Generic PLA". Profile list is generated from the OrcaSlicer
source (~1000 profiles, 43 vendors). A matching patch in
OrcaSlicer-KX is on the way so OrcaSlicer fully honours the hint.
- **H.264 direct stream:** New `/api/camera/h264` endpoint serves the
printer camera stream as MPEG-TS without re-encoding — dramatically
reduces latency, bridge CPU during Obico streaming drops from ~13 %
to ~3 %.
### Fixes
- **Setting temperature via bridge UI / Obico caused a printer system
error:** Fixed via live MQTT capture from Anycubic Slicer Next — the
`tempature/set` command needs a `type` field (0=nozzle, 1=bed,
2=both) and must go over the `web/printer/…` topic, not
`slicer/printer/…`. Nozzle/bed heating from the bridge now works.
- **Large GCode uploads (>50 MB) timed out:** The socket connect timeout
was active during `sendall()` too — pushing ~200 MB over LAN took
more than 30 s and was falsely aborted. Connect / send / read phases
are now timed out separately.
- **Camera snapshots were slow and could collide with the live stream:**
The bridge now keeps a central camera cache (one ffmpeg pulls from
the printer, all consumers share it). Snapshots return in ~1.3 ms
from RAM instead of 12 s per spawned ffmpeg. Also resolves the
single-client limit on the printer (HTTP 429 on parallel access).
- **Language switch did not refresh the GCode browser:** Strings baked
into the file cards ("Print", "Estimate", "Download") stayed in the
previous language. Cards are now re-rendered on language switch.
- **GCode web upload + download + verify dialog (PR #32 by @gangoke):**
Files can be uploaded / downloaded directly in the browser, with a
warning dialog when starting a GCode that was not uploaded via
OrcaSlicer.
### CI/Build
- Multi-arch Docker image (amd64 + arm64) automated via Gitea Actions.
- Release builds for all three targets (linux-amd64, linux-arm64,
windows.exe) via the local CodeBuilder.
## [0.9.16] 2026-05-22
### New
- **Auto-start camera on print:** new setting "Turn camera on at print start" —
when enabled, the bridge starts the camera stream automatically when a print
begins (works for both OrcaSlicer and the Bridge UI).
### Fixes
- **Single-color print blocked by an empty AMS slot:** OrcaSlicer writes all
configured filaments into the GCode header even when the model uses only one,
so the bridge told the printer it needed every color — and an empty unused slot
aborted the print. The bridge now maps only the filaments actually used by the
GCode.
- **Filament sync now position-accurate:** with an empty slot in the middle
(e.g. slot 1 yellow, 2 empty, 3 red, 4 white) OrcaSlicer showed the colors
shifted onto the wrong slots. Fixed — empty slots keep their position, and the
sync color format follows the Happy Hare convention (RRGGBB without `#`).
- **Slicer time + thumbnail missing after a browser reload** (or when a print was
started directly from OrcaSlicer): both are now restored from the GCode store
by filename instead of relying on volatile state.
- **German translation gaps** in the ACE dryer dialog fixed.
### Logging
- Repeated log lines are collapsed into a counter ("×N") instead of spamming the
console; status-poll traffic is no longer logged at INFO.
- New log level filter (All / Errors / Warnings), a toast on new errors, full
tracebacks forwarded to the browser log, and a timestamped download filename.
## [0.9.15] 2026-05-21
### Fixes (Issue #29)
- **UI in the OrcaSlicer device tab was broken:** OrcaSlicer's embedded webview
only loads the bare HTML and ignores external `<script>`/`<link>` tags, so after
the v0.9.14 theme split none of the buttons worked in the device tab. The
bridge now inlines CSS + JS into the page — works in both the browser and the
OrcaSlicer webview.
- **Dropdowns unreadable (white-on-white) in the OrcaSlicer webview:** added
`color-scheme` + explicit `select`/`option` colors so the native dropdowns
render correctly in dark and light theme.
- **"Select slots" button did nothing right after an upload:** a missing variable
declaration (`storeFiles`) threw a `ReferenceError` when clicked before the
Browser tab had loaded. Fixed.
- **Upload banner came back after a finished print:** the "file ready" state was
only cleared on stop/cancel, not on `finished`. Now cleared on completion too.
## [0.9.14] 2026-05-21
### New
- **Theme system (community contribution by @hirnwunde, PR #27):** the web UI now
lives in real files under `web/themes/<name>/` (`index.html` + `style.css` +
`app.js`) instead of being embedded in the Python source. Switch themes with
`--ui-theme <name>`. Theme authors get a documented hook reference
(`web/DOC/THEME-CSS-HOOKS.md`, `THEME-JS-ID-HOOKS.md`). The default theme
carries the full current UI (ACE2, skip objects, filament dialog). No change
for users — the bundled binaries/Docker image ship the theme embedded.
- **Restart over API (community contribution by @gangoke, PR #28):** new
`POST /api/restart` endpoint to restart the bridge remotely — e.g. a restart
button in the Home Assistant integration.
### Internal
- Unified PyInstaller build (`kx-bridge.spec`) for Linux, Windows and Docker —
embeds `web/` (themes) into the one-file binary, read at runtime from
`sys._MEIPASS`. Verified the theme ships in the Linux binary and the Windows EXE.
- `data/` added to `.gitignore`.
## [0.9.13] 2026-05-20
============================================================
STOP — READ THIS BEFORE PRESSING "UPDATE"
============================================================
The in-app "Update" button is BROKEN in 0.9.11 and 0.9.12.
Do NOT use it. Update manually instead (one time), then it
works again from 0.9.13 onward.
>> WINDOWS .EXE / LINUX BINARY users — DANGER:
Pressing Update OVERWRITES your kx-bridge.exe / kx-bridge
with a text file. The program will NOT start anymore.
It cannot repair itself.
--> Update manually: download the 0.9.13
kx-bridge-windows.zip / kx-bridge-linux.zip from the
Releases page and replace your old file.
Your config/ and data/ folders are kept.
>> DOCKER users:
Pressing Update makes the container crash-loop
(ModuleNotFoundError: No module named '_web_assets').
--> Update manually:
docker compose pull (or docker compose up -d --build)
Your config + data volumes are kept.
From 0.9.13 on, the in-app updater is fixed and safe again.
============================================================
### Fixes
- **Self-update was broken in 0.9.11 and 0.9.12 (critical):** the in-app updater
only replaced `kobrax_moonraker_bridge.py`. Two problems:
- **Binary/EXE mode:** it overwrote the running executable (`sys.executable`)
with a Python text file, leaving an unstartable program that can't recover
itself — manual re-download required.
- **Python/Docker mode:** since 0.9.12 the main file imports the extracted
`_web_assets.py` (bundled frontend), which the updater didn't fetch →
`ModuleNotFoundError: No module named '_web_assets'` → crash loop.
The updater now downloads **all** bridge modules (main file + `_web_assets.py`
+ client + loaders) fully, then swaps them atomically, and **refuses to
self-update in binary mode** (pointing you to the manual download instead).
## [0.9.12] 2026-05-20
### Fixes

View File

@@ -2,14 +2,20 @@ FROM python:3.11-slim
WORKDIR /app
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
COPY kobrax_moonraker_bridge.py .
COPY _web_assets.py .
COPY web/ ./web/
# Statische Daten (orca_filaments.json etc.) liegen in /app/static/, NICHT in
# /app/data/ — letzteres wird vom User als Volume gemountet (Runtime-State).
COPY data/ ./static/
COPY config_loader.py .
COPY env_loader.py .
COPY kobrax_client.py .
COPY orca_filaments.py .
COPY VERSION .
COPY anycubic_slicer.crt .
COPY anycubic_slicer.key .

View File

@@ -8,7 +8,12 @@
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
<sub>🇬🇧 <a href="README.md">English version</a></sub>
<sub>🧪 Ein Community-Bericht auf Reddit deutet darauf hin, dass die Bridge auch
mit dem **Kobra S1** und **Kobra S1 Max** funktioniert — die Protokolle wirken
kompatibel, beides ist aber weder offiziell getestet noch unterstützt.
Feedback willkommen.</sub>
<sub>🇬🇧 <a href="README.md">English version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
<br>
@@ -32,12 +37,17 @@ Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
|---|---|
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
| 🎨 | **AMS / Multicolor**Filament-Slots, Per-Kanal-Remapping, MMU-Emulation für OrcaSlicer Filament-Sync |
| 🎨 | **AMS / Multicolor**Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück |
| 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user/<id>/filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf |
| 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) |
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
| | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
| 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart |
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / 中文, Browser-Sprache automatisch erkannt |
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
| 🌐 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket), DE/EN UI |
| 🧠 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket); für korrekten Vendor-Match pro Slot den [OrcaSlicer-KX-Build](#-empfohlener-slicer) nutzen |
---
@@ -101,11 +111,45 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
---
## 🎨 Empfohlener Slicer
Für sauberen AMS-Filament-Sync gibt es einen **gepatchten OrcaSlicer-Build**:
**[OrcaSlicer-KX Releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
**Upstream-PRs im KX-Build:**
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Moonraker / Happy-Hare AMS-Sync-Fix (Slot-Positionen bleiben auch bei leerem Slot in der Mitte korrekt)
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Vendor- + Name-Matching für Moonraker (liest `name` + `vendor_name` pro Slot und matched gegen deine Filament-Presets), von [@LordGuenni](https://github.com/LordGuenni)
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — Eindeutige `filament_id` für User-Presets (neu erstellte eigene Profile bekommen eine frische ID statt das `OGFL99` vom Generic-Parent zu erben), von [@mrnoisytiger](https://github.com/mrnoisytiger)
**Plus vier KX-eigene Verbesserungen on top:**
- Bridge-Filament-Hint (`tray_info_idx` + Vendor) respektieren
- Vendor-Match auch wenn das gewählte Base-Preset **nicht is_compatible** mit dem aktiven Drucker ist (so matchen Profile aus anderen Drucker-Setups trotzdem über die Marke)
- Vendor-Match wenn `tray_info_idx` gesetzt ist, das Preset aber inkompatibel
- Zwei-Pass-Suche: erst kompatible Presets, dann alle sichtbaren
**Warum das zusammen wichtig ist:** ohne #13719 landen die AMS-Slots in OrcaSlicer alle auf `Generic PLA` / `Generic PETG`, obwohl die Bridge die konkrete Marke schon mitsendet (`name + vendor_name + gate_filament_name`). Mit dem KX-Build matched OrcaSlicer deine echten User-Presets — auch die, die du via [Eigene OrcaSlicer-Profile importieren](#-features) in die Bridge gezogen hast.
Stock-Upstream-OrcaSlicer funktioniert für Slicing und Drucken weiterhin — nur das Per-Slot-Vendor-Matching beim AMS-Sync fällt dann weg. Material und Farbe pro Slot kannst du auch ohne den KX-Build über die Bridge ans Drucker-Display schreiben (das läuft über MQTT, nicht über den Slicer).
OrcaSlicer-KX ist ein Build von [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); der Quellcode der Upstream-PRs ist auf GitHub, die KX-spezifischen Patches im OrcaSlicer-KX-Repo.
---
## 🏠 Community & Integrationen
- **[Home-Assistant-Integration](https://github.com/gangoke/kobrax-lan-hass-component)**
von [@gangoke](https://github.com/gangoke) — bindet Sensoren, Drucksteuerung,
Licht, Kamera und das GCode-Vorschaubild als native Home-Assistant-Entitäten ein.
- **[Obico (selbst gehostet)](https://github.com/TheSpaghettiDetective/obico-server)** —
die Bridge bietet eine Moonraker-kompatible API, die
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
akzeptiert; damit hast du Time-Lapse und WebRTC-Live-Stream gegen deinen
eigenen Obico-Server. Die KI-Spaghetti-Erkennung ist beim Kobra X
experimentell — der Top-Down-Kamerawinkel weicht von dem ab, auf den
das Modell trainiert wurde.
> Dies sind **Community-Projekte**, die nicht von KX-Bridge betreut oder
> supportet werden. Bei Fragen oder Problemen bitte das verlinkte Repository nutzen.
@@ -132,9 +176,10 @@ dann `extract_credentials` ausführen → gibt Username, Passwort, Device-ID und
## ⚙️ Nützliche Befehle
```bash
docker compose logs -f # Logs anzeigen
docker compose down # Bridge stoppen
docker compose up -d --build # Bridge neu bauen & starten (nach Update)
docker compose logs -f # Logs anzeigen
docker compose down # Bridge stoppen
docker compose pull && docker compose up -d # auf neueste veröffentlichte Version updaten
docker compose up -d --build # lokal selber bauen (statt zu pullen)
```
---

277
README.es.md Normal file
View File

@@ -0,0 +1,277 @@
<div align="center">
<img src="knlogo.png" alt="KX-Bridge" width="160"/>
# KX-Bridge
**Controla tu Anycubic Kobra X con OrcaSlicer — sin Klipper, sin Raspberry Pi.**
Un puente compatible con Moonraker que se comunica directamente con la impresora.
<sub>🧪 Un usuario en Reddit ha reportado que el puente también funciona con la
**Kobra S1** y la **Kobra S1 Max** — los protocolos parecen compatibles, pero
ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
<sub>🇬🇧 <a href="README.md">English version</a> · 🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
<br>
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Apoya%20este%20proyecto-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/viewitde)
&nbsp;
[![Releases](https://img.shields.io/badge/Descargar-Lanzamientos-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Downloads](https://img.shields.io/badge/Descargas-800%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&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)
<sub>¿Te gusta KX-Bridge? Un café en <a href="https://ko-fi.com/viewitde">Ko-fi</a> mantiene el proyecto vivo. ☕</sub>
</div>
---
## ✨ Características
| | |
|---|---|
| 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión |
| 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara |
| 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora |
| 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user/<id>/filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios |
| 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico |
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) |
| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros |
| 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable |
| | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente |
| 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual |
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / 中文, detecta automáticamente el idioma del navegador |
| 🔄 | **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 |
---
## 🚀 Inicio rápido
### 1. Prepara la impresora
Activa el modo LAN en la Kobra X:
**Pantalla de la impresora → Ajustes → Activar modo LAN**
### 2. Inicia el puente
**Docker (recomendado):**
```bash
docker compose up -d
```
**Binario Linux (sin Docker):**
```bash
chmod +x kx-bridge-linux-amd64 && ./kx-bridge-linux-amd64
```
**EXE Windows (sin Docker):**
```
kx-bridge.exe
```
> ⚠️ **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
pip install -r bridge/requirements.txt
python bridge/kobrax_moonraker_bridge.py
```
### 3. Configura la impresora
Abre la interfaz web: **`http://IP-DEL-PUENTE:7125`**
En el primer inicio, la pestaña **Impresoras** muestra *"+ Añadir impresora"* — solo introduce la dirección IP
de la impresora, el resto (usuario, contraseña, ID de dispositivo) se obtiene de la impresora y se desencripta
automáticamente. Listo.
> ¿Más de una impresora? Simplemente haz clic en *"+ Añadir impresora"* de nuevo — cada una recibe su propio puerto
> (7125, 7126, …) y se puede seleccionar desde el menú desplegable del encabezado.
### 4. Conecta OrcaSlicer
Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:7125`
> ⚠️ El tipo de conexión debe ser **Moonraker** (no "Bambu" ni "Klipper").
> Introduce la URL completa incluyendo `http://` y el puerto `:7125` en el campo de host.
---
## 📺 Vídeo tutorial
[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
---
## 🎨 Slicer recomendado
Para una sincronización de filamento AMS correcta ofrecemos una **versión modificada de OrcaSlicer**:
**[Lanzamientos de OrcaSlicer-KX](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
**PRs upstream incluidos en el build KX:**
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Corrección de sincronización Moonraker / Happy-Hare AMS (las posiciones de las ranuras se mantienen correctas incluso con ranuras vacías)
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Coincidencia de Vendor + Nombre para Moonraker (lee `name` + `vendor_name` por ranura y los empareja con los presets de filamento del usuario), por [@LordGuenni](https://github.com/LordGuenni)
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — `filament_id` único para presets de usuario (los perfiles nuevos reciben un ID nuevo en vez de heredar `OGFL99` del padre genérico), por [@mrnoisytiger](https://github.com/mrnoisytiger)
**Más cuatro mejoras específicas de KX encima:**
- Respetar el hint de filamento del puente (`tray_info_idx` + vendor)
- Coincidencia por vendor incluso cuando el preset base no es **is_compatible** con la impresora activa (así un perfil copiado de otra máquina sigue coincidiendo por vendor)
- Coincidencia por vendor cuando `tray_info_idx` está definido pero su preset es incompatible
- Búsqueda de dos pasadas: primero presets compatibles, luego todos los visibles
**Por qué importa:** sin #13719 todas las ranuras AMS caen en `Generic PLA` / `Generic PETG` aunque el puente ya envíe la marca concreta (`name + vendor_name + gate_filament_name`). Con el build KX, OrcaSlicer coincide con tus presets de usuario reales — incluyendo los perfiles que importaste al puente vía [Importa tus propios perfiles de OrcaSlicer](#-características).
OrcaSlicer upstream también funciona para rebanar e imprimir — solo pierdes la coincidencia de vendor por ranura en la sincronización AMS. El material y color por ranura se pueden empujar puente → impresora con cualquier slicer (eso va por MQTT, no por el slicer).
OrcaSlicer-KX es un build de [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); el código fuente de cada PR upstream está en GitHub, los parches específicos de KX en el repo OrcaSlicer-KX.
---
## 🏠 Comunidad e integraciones
- **[Integración con Home Assistant](https://github.com/gangoke/kobrax-lan-hass-component)**
por [@gangoke](https://github.com/gangoke) — expone sensores, controles de impresión,
luz, cámara y la vista previa del GCode como entidades nativas de Home Assistant.
- **[Obico (autoalojado)](https://github.com/TheSpaghettiDetective/obico-server)** —
el puente expone una API compatible con Moonraker que
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
acepta, así obtienes Time-Lapse y streaming en vivo WebRTC contra tu propio
servidor Obico. La detección de fallos por IA es experimental en la Kobra X
(el ángulo cenital de la cámara difiere del que el modelo fue entrenado).
> Estos son **proyectos de la comunidad**, no mantenidos ni soportados por KX-Bridge.
> Para preguntas o problemas, utiliza el repositorio enlazado.
---
## 🔧 Obtener credenciales manualmente
Normalmente no es necesario — *"+ Añadir impresora"* lo hace automáticamente. Si lo necesitas:
```bash
fetch_credentials --ip 192.168.x.x --write-config
```
Obtiene las credenciales directamente de la impresora vía HTTP y las escribe en `config/config.ini`.
Solo se requiere la IP de la impresora, no hace falta un slicer.
Alternativamente (si se desconoce la IP): abre AnycubicSlicerNext, conecta la impresora, luego ejecuta
`extract_credentials` → muestra usuario, contraseña, ID de dispositivo y la IP de la impresora.
> **Descargas:** [Lanzamientos](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux y Windows)
---
## ⚙️ Comandos útiles
```bash
docker compose logs -f # mostrar registros
docker compose down # detener el puente
docker compose pull && docker compose up -d # actualizar a la imagen publicada más reciente
docker compose up -d --build # recompilar localmente (en lugar de descargar)
```
---
## 🩹 Solución de problemas
<details>
<summary><b>"Credenciales MQTT incorrectas" al iniciar</b></summary>
- Vuelve a añadir la impresora mediante *"+ Añadir impresora"*, o ejecuta
`fetch_credentials --ip <ip> --write-config` y reinicia el puente
- Introduce solo la dirección IP, sin puerto (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
</details>
<details>
<summary><b>Impresora no encontrada / modo LAN no activado</b></summary>
- En la pantalla de la impresora: Ajustes → Activar modo LAN
- La impresora y el puente deben estar en la misma red
</details>
<details>
<summary><b>Docker: Permiso denegado</b></summary>
```bash
sudo usermod -aG docker $USER # luego cierra la sesión y vuelve a iniciarla
```
</details>
<details>
<summary><b>Actualizar desde 0.9.1 o anterior</b></summary>
A partir de 0.9.2, KX-Bridge almacena la configuración en `config/config.ini` en lugar de `.env`.
La migración se ejecuta automáticamente en el primer inicio después de la actualización — no requiere acción.
</details>
---
## 🔒 Seguridad
- El puente es accesible en la red local en `http://<IP-del-host>:7125`**no** lo expongas a internet
- `config/config.ini` contiene las credenciales de la impresora — no las compartas públicamente
- Las credenciales **no** otorgan acceso a los servicios en la nube de Anycubic
---
## 📄 Licencia
[![License: GPL v3](https://img.shields.io/badge/License-GPL_v3-blue.svg)](LICENSE)
KX-Bridge se publica bajo la **GNU General Public License v3.0**. Consulta
[LICENSE](LICENSE) para el texto completo. Las bifurcaciones y modificaciones deben
permanecer bajo GPLv3 si se redistribuyen.
La implementación del protocolo MQTT es el resultado de una ingeniería inversa
independiente con fines de interoperabilidad (§69e UrhG / Directiva de Software de la UE
Art. 6). El material de terceros en el repositorio (certificados TLS de Anycubic)
**no** está cubierto por GPLv3 y se incluye únicamente para permitir la
autenticación contra impresoras que el usuario final ya posee. Consulta
[NOTICE.md](NOTICE.md) para más detalles y el aviso legal.
Este proyecto es independiente y no está afiliado con Anycubic.
<div align="center">
<br>
**Si KX-Bridge te ayuda, el proyecto agradece tu apoyo:**
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/viewitde)
</div>

View File

@@ -8,7 +8,11 @@
A Moonraker-compatible bridge that talks directly to the printer.
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
<sub>🧪 A community report on Reddit suggests the bridge also works with the
**Kobra S1** and **Kobra S1 Max** — protocols look compatible, but neither is
officially tested or supported. Feedback welcome.</sub>
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a> · 🇪🇸 <a href="README.es.md">Versión española</a></sub>
<br>
@@ -32,12 +36,17 @@ A Moonraker-compatible bridge that talks directly to the printer.
|---|---|
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
| 🎨 | **AMS / multicolor**filament slots, per-channel remapping, MMU emulation for OrcaSlicer filament sync |
| 🎨 | **AMS / multicolor**slots with per-slot **profile picker** (assign your own brand from OrcaSlicer profiles per slot); bridge writes material & colour back to the printer display |
| 📦 | **Import your own OrcaSlicer profiles** — drag a ZIP from `~/.config/OrcaSlicer/user/<id>/filament/` into the bridge; they show up in the slot dropdown under ★ Own profiles |
| 📷 | **Obico integration (experimental)** — Time-Lapse and WebRTC live stream against a self-hosted [Obico server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget |
| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter |
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
| | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
| 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart |
| 🌐 | **Multi-language UI** — DE / EN / ES / 中文, auto-detect browser locale |
| 🔄 | **Self-update** — install new versions directly in the browser |
| 🌐 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket), EN/DE UI |
| 🧠 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket); pair with the [OrcaSlicer-KX build](#-recommended-slicer) for proper per-slot vendor matching |
---
@@ -101,11 +110,44 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
---
## 🎨 Recommended Slicer
For proper AMS filament-sync we ship a **patched OrcaSlicer build**:
**[OrcaSlicer-KX releases](https://gitea.it-drui.de/viewit/OrcaSlicer-KX/releases/latest)** (Linux AppImage + Windows ZIP)
**Upstream PRs bundled in the KX build:**
- **[PR #13372](https://github.com/SoftFever/OrcaSlicer/pull/13372)** — Moonraker / Happy-Hare AMS sync fix (slot positions stay correct even with empty slots)
- **[PR #13719](https://github.com/SoftFever/OrcaSlicer/pull/13719)** — Vendor + Name matching for Moonraker (reads `name` + `vendor_name` per slot and matches against the user's filament presets), by [@LordGuenni](https://github.com/LordGuenni)
- **[PR #13315](https://github.com/SoftFever/OrcaSlicer/pull/13315)** — Unique `filament_id` for user presets (so newly created custom profiles get a fresh ID instead of inheriting `OGFL99` from the generic parent), by [@mrnoisytiger](https://github.com/mrnoisytiger)
**Plus four KX-specific matching improvements on top:**
- Respect the bridge filament hint (`tray_info_idx` + vendor)
- Vendor match also when the chosen base preset is **not is_compatible** with the active printer (so a profile copied from a different machine still matches by vendor)
- Vendor match when `tray_info_idx` is set but its preset is incompatible
- Two-pass lookup: first compatible presets, then all visible ones
**Why this matters:** without #13719 the AMS slots in OrcaSlicer all fall back to `Generic PLA` / `Generic PETG` even though the bridge already sends the concrete brand (`name + vendor_name + gate_filament_name`). With the KX build OrcaSlicer matches your actual user presets — including profiles you imported into the bridge via the [Import your own OrcaSlicer profiles](#-features) flow.
Stock upstream OrcaSlicer still works for slicing and printing — you just lose the per-slot brand matching on AMS sync. Slot material + colour can still be pushed bridge → printer either way (that goes over MQTT, not via the slicer).
OrcaSlicer-KX is a build of [OrcaSlicer](https://github.com/SoftFever/OrcaSlicer) (AGPL-3.0); source for each upstream PR is on GitHub, KX-specific patches live in the OrcaSlicer-KX repo.
---
## 🏠 Community & Integrations
- **[Home Assistant integration](https://github.com/gangoke/kobrax-lan-hass-component)**
by [@gangoke](https://github.com/gangoke) — exposes sensors, print controls,
light, camera and the GCode thumbnail as native Home Assistant entities.
- **[Obico (self-hosted)](https://github.com/TheSpaghettiDetective/obico-server)** —
the bridge exposes a Moonraker-compatible surface that
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
accepts, so you get Time-Lapse and WebRTC live streaming against your own
Obico server. The AI failure-detection side is experimental on the Kobra X
(top-down camera angle differs from what the model was trained on).
> These are **community projects**, not maintained or supported by KX-Bridge.
> For questions or issues, please use the linked repository.
@@ -132,9 +174,10 @@ Alternatively (if the IP is unknown): open AnycubicSlicerNext, connect the print
## ⚙️ Useful commands
```bash
docker compose logs -f # show logs
docker compose down # stop the bridge
docker compose up -d --build # rebuild & start (after an update)
docker compose logs -f # show logs
docker compose down # stop the bridge
docker compose pull && docker compose up -d # update to the latest published image
docker compose up -d --build # rebuild locally (instead of pulling)
```
---

View File

@@ -1 +1 @@
0.9.12
0.9.20

View File

@@ -2,8 +2,10 @@
# Kopiere diese Datei nach config.ini und trage deine Werte ein:
# cp config.ini.example config.ini
#
# Credentials mit extract_credentials.exe (Windows) oder
# extract_credentials (Linux) aus dem laufenden AnycubicSlicerNext auslesen.
# Credentials automatisch eintragen:
# python3 tools/fetch_credentials.py --ip 192.168.x.x --write-config
# Alternativ (Windows, ohne Drucker-IP bekannt):
# extract_credentials.exe --write-env (liest aus laufendem AnycubicSlicerNext)
[connection]
# IP-Adresse des Druckers im lokalen Netzwerk
@@ -29,6 +31,65 @@ 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
[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

View File

@@ -59,6 +59,8 @@ def _load_config_file(path: pathlib.Path):
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
}
for env_key, (section, option) in mapping.items():
@@ -95,6 +97,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"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
}
cfg[CONFIG_SECTION_BRIDGE] = {
"poll_interval": "3",
@@ -162,6 +166,84 @@ def list_printers() -> list[dict]:
return printers
def list_filament_profiles() -> dict[int, dict]:
"""Liest die [filament_profiles]-Sektion aus config.ini.
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
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-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()
if not path:
return {}
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
if not cfg.has_section("filament_profiles"):
return {}
result: dict[int, dict] = {}
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)
if len(parts) < 3:
continue
try:
slot_idx = int(parts[1])
except ValueError:
continue
field = parts[2]
if field not in ("id", "vendor", "name"):
continue
if not value.strip():
continue
result.setdefault(slot_idx, {})[field] = value.strip()
return result
def save_filament_profiles(profiles: dict[int, dict]) -> bool:
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
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")
if cfg.has_section("filament_profiles"):
cfg.remove_section("filament_profiles")
if profiles:
cfg["filament_profiles"] = {}
for slot_idx in sorted(profiles.keys()):
entry = profiles[slot_idx] or {}
if entry.get("vendor"):
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
if entry.get("name"):
cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"]
if entry.get("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 get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
@@ -175,3 +257,5 @@ MODE_ID = get("MODE_ID", "")
DEVICE_ID = get("DEVICE_ID", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))

1465
data/orca_filaments.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
services:
kx-bridge:
image: kx-bridge:latest
build: .
image: gitea.it-drui.de/viewit/kx-bridge:latest
# Selbst bauen statt das Registry-Image zu pullen?
# Dann image-Zeile auskommentieren und folgende aktivieren:
# build: .
volumes:
- ./config:/app/config
- ./data:/app/data

View File

@@ -154,6 +154,13 @@ class KobraXClient:
# -- Connection ----------------------------------------------------------
def _do_connect(self):
if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
raise FileNotFoundError(
f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key "
f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). "
f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke "
f"die Dateien dorthin."
)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
@@ -179,10 +186,24 @@ class KobraXClient:
def connect(self):
self._do_connect()
self._running = True
t = threading.Thread(target=self._read_loop, daemon=True)
t.start()
self._ensure_reader()
time.sleep(0.3)
def _ensure_reader(self):
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
würde dann zwar senden, aber auf Antworten ewig warten."""
if not self._running:
return # gewollter disconnect
t = getattr(self, "_reader_thread", None)
if t is not None and t.is_alive():
return
self._reader_thread = threading.Thread(
target=self._read_loop, daemon=True, name="kobrax-mqtt-reader",
)
self._reader_thread.start()
def disconnect(self):
self._running = False
try:
@@ -191,20 +212,34 @@ class KobraXClient:
pass
def _reconnect(self):
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
ausgeschaltet) zu vermeiden."""
log.warning("Verbindung verloren reconnect…")
try:
self._sock.close()
except Exception:
pass
for delay in [2, 4, 8, 15, 30]:
delays = [2, 4, 8, 15, 30, 60]
attempt = 0
while self._running:
delay = delays[min(attempt, len(delays) - 1)]
try:
self._do_connect()
log.info("Reconnect erfolgreich")
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
return True
except Exception as e:
log.warning("Reconnect fehlgeschlagen (%s), warte %ss…", e, delay)
time.sleep(delay)
return False
attempt += 1
lvl = log.warning if attempt <= 5 else log.debug
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
slept = 0.0
while slept < delay and self._running:
time.sleep(min(0.5, delay - slept))
slept += 0.5
return False # nur wenn disconnect() gerufen wurde
def _subscribe(self, topic: str):
with self._lock:
@@ -348,6 +383,9 @@ class KobraXClient:
# -- Publish + request/response ------------------------------------------
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
self._ensure_reader()
msgid = str(uuid.uuid4())
payload = json.dumps({
"type": msg_type,
@@ -371,9 +409,12 @@ class KobraXClient:
report_registered = True
topic = self._pub_topic(msg_type)
log.info("TX %-25s action=%-12s data=%s",
f"{msg_type}/request", action,
json.dumps(data, ensure_ascii=False) if data else "null")
# 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",
f"{msg_type}/request", action,
json.dumps(data, ensure_ascii=False) if data else "null")
try:
with self._lock:
self._sock.sendall(_build_publish(topic, payload))
@@ -410,6 +451,7 @@ class KobraXClient:
def publish_web(self, msg_type: str, action: str, data=None) -> None:
"""Fire-and-forget publish on the web/printer topic (used for runtime updates during print)."""
self._ensure_reader()
msgid = str(uuid.uuid4())
payload = json.dumps({
"type": msg_type,
@@ -426,7 +468,14 @@ class KobraXClient:
with self._lock:
self._sock.sendall(_build_publish(topic, payload))
except Exception as e:
log.error("web send error: %s", e)
log.error("web send error: %s, reconnecting…", e)
# Reconnect triggern (analog zu publish()); ohne Retry weil
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
# treffen.
try:
self._reconnect()
except Exception:
pass
# -- High-level commands -------------------------------------------------
@@ -542,9 +591,15 @@ class KobraXClient:
f"Connection: close\r\n\r\n"
).encode()
sock = socket.create_connection((self.host, 18910), timeout=30)
# Connect-Timeout kurz (LAN). Während sendall() darf der Socket so
# lange brauchen wie nötig — bei großen Dateien (>100 MB) und
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
# generös (Drucker verarbeitet die Datei bevor er antwortet).
sock = socket.create_connection((self.host, 18910), timeout=10)
sock.settimeout(None) # blocking während Send
sock.sendall(headers + body)
sock.settimeout(120) # große GCode-Dateien brauchen Zeit bis der Drucker antwortet
sock.settimeout(180)
response = b""
try:
while True:

File diff suppressed because it is too large Load Diff

45
kx-bridge.spec Normal file
View File

@@ -0,0 +1,45 @@
# PyInstaller-Spec für kx-bridge — plattformneutral (Linux + Windows via PyBuilder).
# Wird relativ zum Repo-Root ausgeführt (`pyinstaller kx-bridge.spec`), wo
# kobrax_moonraker_bridge.py und web/ flach liegen (Release-Repo-Layout).
#
# Bindet das Web-Theme-System (web/themes/<name>/ + web/DOC/) ins Onefile-Binary
# ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge).
from PyInstaller.utils.hooks import collect_all
datas = [("web", "web"), ("data", "static"), ("VERSION", ".")] # bridge/data/ → static/ im _MEIPASS
binaries = []
hiddenimports = []
# pycryptodome vollständig einsammeln (Krypto für die Drucker-Auth)
_d, _b, _h = collect_all("pycryptodome")
datas += _d
binaries += _b
hiddenimports += _h
a = Analysis(
["kobrax_moonraker_bridge.py"],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="kx-bridge",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
onefile=True,
)

147
orca_filaments.py Normal file
View File

@@ -0,0 +1,147 @@
"""OrcaSlicer Filament-Profil Parser.
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
"""
from __future__ import annotations
import json
import re
def first_str(value, default: str = "") -> str:
"""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):
return value
return default
def clean_name(raw: str) -> str:
"""Strippt printer-/varianten-spezifische Suffixe:
'PolyTerra PLA @base''PolyTerra PLA'
'Anycubic PLA @Anycubic Kobra X 0.4 nozzle''Anycubic PLA'
'Anker Generic PLA 0.4 nozzle''Anker Generic PLA'
"""
name = re.sub(r"\s*@.*$", "", raw).strip()
name = re.sub(r"\s+\d+(\.\d+)?\s*nozzle\s*$", "", name, flags=re.IGNORECASE).strip()
return name or raw
def parse_profile(data: dict, by_name: dict | None = None,
path_vendor: str | None = None,
source_path: str = "",
system_index: list | None = None) -> dict | None:
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
`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).
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-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 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 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:
if isinstance(p, dict) and p.get("name"):
sys_by_name[p["name"]] = p
def _resolve(key: str, depth: int = 5):
cur_list = [data]
for _ in range(depth):
for cur in cur_list:
v = cur.get(key)
if v not in ("", [], None, [""]) and v is not None:
return v
# Erst raw-Inherits via by_name (Generator-Pfad)
if by_name:
next_list: list[dict] = []
for cur in cur_list:
parent_name = cur.get("inherits")
if parent_name and parent_name in by_name:
next_list.extend(by_name[parent_name])
if next_list:
cur_list = next_list
continue
break
return None
def _resolve_via_system_index(key: str):
"""Inherits-Kette über system_index (clean_name-Match)."""
parent_raw = data.get("inherits")
if not parent_raw or not sys_by_name:
return None
parent_clean = clean_name(parent_raw)
sys_p = sys_by_name.get(parent_clean)
if not sys_p:
return None
# System-JSON benutzt schon das normalisierte Schema
mapping = {
"filament_id": "id",
"filament_vendor": "vendor",
"filament_type": "type",
"default_filament_colour": "color",
}
return sys_p.get(mapping.get(key, key))
def _resolve_full(key: str):
v = _resolve(key)
if v not in ("", [], None, [""]) and v is not None:
return v
return _resolve_via_system_index(key)
fid = _resolve_full("filament_id")
if not fid or not isinstance(fid, str):
return None
name_raw = data.get("name", fid)
name = clean_name(name_raw)
vendor = first_str(_resolve_full("filament_vendor")) or (path_vendor or "Generic")
ftype = first_str(_resolve_full("filament_type"), "")
color = first_str(_resolve_full("default_filament_colour"), "")
return {
"id": fid,
"name": name,
"vendor": vendor,
"type": ftype,
"color": color,
}
def parse_profile_bytes(blob: bytes, source_name: str = "",
system_index: list | None = None) -> dict | None:
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
`system_index` ist optional die fertige Liste aus orca_filaments.json —
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
Schema vom System-Parent erben."""
try:
data = json.loads(blob.decode("utf-8", errors="replace"))
except Exception:
return None
return parse_profile(data, source_path=source_name, system_index=system_index)

136
web/DOC/THEME-CSS-HOOKS.md Normal file
View File

@@ -0,0 +1,136 @@
# KX-Bridge Theme CSS-ID-Hooks
Referenzliste für CSS-/Layout-Anpassungen.
| ID | Verwendung |
|---|---|
| `#ace-dry-dialog-custom-name-label` | Hook / Selektor |
| `#ace-dry-dialog-custom-name-row` | Hook / Selektor |
| `#ace-dry-dialog-temp-label` | Hook / Selektor |
| `#ace-dry-dialog-time-label` | Hook / Selektor |
| `#ace-dry-dialog-title` | Hook / Selektor |
| `#add-printer-btn-label` | Hook / Selektor |
| `#ams-no-data` | Hook / Selektor |
| `#apd-ip` | Hook / Selektor |
| `#apd-lbl-ip` | Hook / Selektor |
| `#apd-lbl-name` | Hook / Selektor |
| `#apd-name` | Hook / Selektor |
| `#apd-status` | Hook / Selektor |
| `#apd-title` | Hook / Selektor |
| `#btn-log-dl` | Hook / Selektor |
| `#cam-fname` | Hook / Selektor |
| `#cam-img` | Hook / Selektor |
| `#cam-overlay` | Hook / Selektor |
| `#cam-placeholder` | Hook / Selektor |
| `#cam-placeholder-txt` | Hook / Selektor |
| `#cam-spinner` | Hook / Selektor |
| `#cam-wrap` | Hook / Selektor |
| `#conn-error-banner` | Hook / Selektor |
| `#d-ace-dry-grid` | Hook / Selektor |
| `#d-ace-dry-wrap` | Hook / Selektor |
| `#d-ams-card` | Hook / Selektor |
| `#d-bt-t` | Hook / Selektor |
| `#d-btbar` | Hook / Selektor |
| `#d-btn-skip-label` | Hook / Selektor |
| `#d-card-ams` | Hook / Selektor |
| `#d-card-cam` | Hook / Selektor |
| `#d-card-lightfan` | Hook / Selektor |
| `#d-card-progress` | Hook / Selektor |
| `#d-card-speed` | Hook / Selektor |
| `#d-card-temps` | Hook / Selektor |
| `#d-chart-label` | Hook / Selektor |
| `#d-ctrl-btns` | Hook / Selektor |
| `#d-elapsed` | Hook / Selektor |
| `#d-fname` | Hook / Selektor |
| `#d-layers` | Hook / Selektor |
| `#d-lbl-bed` | Hook / Selektor |
| `#d-lbl-elapsed` | Hook / Selektor |
| `#d-lbl-layers` | Hook / Selektor |
| `#d-lbl-light` | Hook / Selektor |
| `#d-lbl-remain` | Hook / Selektor |
| `#d-nt` | Hook / Selektor |
| `#d-nt-t` | Hook / Selektor |
| `#d-ntbar` | Hook / Selektor |
| `#d-pbar` | Hook / Selektor |
| `#d-pct` | Hook / Selektor |
| `#d-remain` | Hook / Selektor |
| `#d-slicer-label` | Hook / Selektor |
| `#d-slicer-row` | Hook / Selektor |
| `#d-slicer-time` | Hook / Selektor |
| `#d-spd-bar` | Hook / Selektor |
| `#d-spd-lbl-1` | Hook / Selektor |
| `#d-spd-lbl-2` | Hook / Selektor |
| `#d-spd-lbl-3` | Hook / Selektor |
| `#d-thumbnail` | Hook / Selektor |
| `#fd-objects` | Hook / Selektor |
| `#fd-objects-hint` | Hook / Selektor |
| `#fd-objects-section` | Hook / Selektor |
| `#fd-objects-svg` | Hook / Selektor |
| `#fd-slots-hint` | Hook / Selektor |
| `#fd-title` | Hook / Selektor |
| `#file-ready-banner` | Hook / Selektor |
| `#file-ready-name` | Hook / Selektor |
| `#h-badge` | Hook / Selektor |
| `#h-pname` | Hook / Selektor |
| `#h-pname-single` | Hook / Selektor |
| `#h-state` | Hook / Selektor |
| `#h-version` | Hook / Selektor |
| `#lbl-auto-leveling` | Hook / Selektor |
| `#lbl-default-slot` | Hook / Selektor |
| `#lbl-device-id` | Hook / Selektor |
| `#lbl-ip-hint` | Hook / Selektor |
| `#lbl-mode-id` | Hook / Selektor |
| `#lbl-mqtt-port` | Hook / Selektor |
| `#lbl-password` | Hook / Selektor |
| `#lbl-printer-ip` | Hook / Selektor |
| `#lbl-printer-name` | Hook / Selektor |
| `#lbl-slot-color` | Hook / Selektor |
| `#lbl-slot-material` | Hook / Selektor |
| `#lbl-update-apply` | Hook / Selektor |
| `#lbl-update-check` | Hook / Selektor |
| `#lbl-username` | Hook / Selektor |
| `#log-badge` | Hook / Selektor |
| `#log-badge-bot` | Hook / Selektor |
| `#modal-sec-connection` | Hook / Selektor |
| `#modal-sec-poll` | Hook / Selektor |
| `#modal-sec-print` | Hook / Selektor |
| `#modal-sec-version` | Hook / Selektor |
| `#modal-title-settings` | Hook / Selektor |
| `#opt-slot-0` | Hook / Selektor |
| `#opt-slot-1` | Hook / Selektor |
| `#opt-slot-2` | Hook / Selektor |
| `#opt-slot-3` | Hook / Selektor |
| `#opt-slot-auto` | Hook / Selektor |
| `#printer-dropdown-menu` | Hook / Selektor |
| `#printer-dropdown-wrap` | Hook / Selektor |
| `#printers-panel-title` | Hook / Selektor |
| `#ptitle-console` | Hook / Selektor |
| `#ptitle-motion-xy` | Hook / Selektor |
| `#ptitle-motion-z` | Hook / Selektor |
| `#s-auto-leveling` | Hook / Selektor |
| `#s-default-slot` | Hook / Selektor |
| `#s-device-id` | Hook / Selektor |
| `#s-mode-id` | Hook / Selektor |
| `#s-mqtt-port` | Hook / Selektor |
| `#s-password` | Hook / Selektor |
| `#s-printer-name` | Hook / Selektor |
| `#s-username` | Hook / Selektor |
| `#s-version-label` | Hook / Selektor |
| `#sf-all` | Hook / Selektor |
| `#sf-err` | Hook / Selektor |
| `#sf-new` | Hook / Selektor |
| `#sf-ok` | Hook / Selektor |
| `#skip-hint` | Hook / Selektor |
| `#skip-list` | Hook / Selektor |
| `#skip-status` | Hook / Selektor |
| `#skip-svg` | Hook / Selektor |
| `#skip-title` | Hook / Selektor |
| `#slot-edit-title` | Hook / Selektor |
| `#ss-date` | Hook / Selektor |
| `#ss-dur` | Hook / Selektor |
| `#ss-name` | Hook / Selektor |
| `#step-display` | Hook / Selektor |
| `#store-empty` | Hook / Selektor |
| `#store-panel-title` | Hook / Selektor |
| `#update-changelog` | Hook / Selektor |
| `#update-status` | Hook / Selektor |

View File

@@ -0,0 +1,90 @@
# KX-Bridge Theme JavaScript-ID-Hooks
Referenzliste für JavaScript-/DOM-Hooks.
| ID | Verwendung |
|---|---|
| `#ace-dry-dialog` | Hook / Selektor |
| `#ace-dry-dialog-cancel` | Hook / Selektor |
| `#ace-dry-dialog-confirm` | Hook / Selektor |
| `#ace-dry-dialog-custom-name` | Hook / Selektor |
| `#ace-dry-dialog-h` | Hook / Selektor |
| `#ace-dry-dialog-m` | Hook / Selektor |
| `#ace-dry-dialog-reset-default` | Hook / Selektor |
| `#ace-dry-dialog-s` | Hook / Selektor |
| `#ace-dry-dialog-save-preset` | Hook / Selektor |
| `#ace-dry-dialog-temp` | Hook / Selektor |
| `#add-printer-dialog` | Hook / Selektor |
| `#ams-slots` | Hook / Selektor |
| `#apd-confirm` | Hook / Selektor |
| `#bnb-console` | Hook / Selektor |
| `#bnb-dashboard` | Hook / Selektor |
| `#bnb-printers` | Hook / Selektor |
| `#bnb-store` | Hook / Selektor |
| `#btn-autoscroll` | Hook / Selektor |
| `#btn-save-settings` | Hook / Selektor |
| `#btn-slot-edit-feed` | Hook / Selektor |
| `#btn-slot-edit-save` | Hook / Selektor |
| `#btn-update-apply` | Hook / Selektor |
| `#btn-update-check` | Hook / Selektor |
| `#cam-toggle-btn` | Hook / Selektor |
| `#conn-btn` | Hook / Selektor |
| `#console-log` | Hook / Selektor |
| `#d-bt` | Hook / Selektor |
| `#d-btn-cancel` | Hook / Selektor |
| `#d-btn-pause` | Hook / Selektor |
| `#d-btn-resume` | Hook / Selektor |
| `#d-btn-skip` | Hook / Selektor |
| `#d-chart` | Hook / Selektor |
| `#d-fan` | Hook / Selektor |
| `#d-fan-val` | Hook / Selektor |
| `#d-light-toggle` | Hook / Selektor |
| `#d-spd-1` | Hook / Selektor |
| `#d-spd-2` | Hook / Selektor |
| `#d-spd-3` | Hook / Selektor |
| `#fd-cancel` | Hook / Selektor |
| `#fd-print` | Hook / Selektor |
| `#fd-slots` | Hook / Selektor |
| `#filament-dialog` | Hook / Selektor |
| `#file-cancel-btn` | Hook / Selektor |
| `#file-ready-btn` | Hook / Selektor |
| `#file-slots-btn` | Hook / Selektor |
| `#lang-btn` | Hook / Selektor |
| `#log-filter` | Hook / Selektor |
| `#logdir-all` | Hook / Selektor |
| `#logdir-rx` | Hook / Selektor |
| `#logdir-tx` | Hook / Selektor |
| `#log-lbl-level` | i18n-Label "Level:" |
| `#loglvl-all` | onclick `setLogLevel('all')` |
| `#loglvl-err` | onclick `setLogLevel('err')` — nur Fehler |
| `#loglvl-warn` | onclick `setLogLevel('warn')` — Fehler + Warnungen |
| `#nb-console` | Hook / Selektor |
| `#nb-dashboard` | Hook / Selektor |
| `#nb-printers` | Hook / Selektor |
| `#nb-store` | Hook / Selektor |
| `#p-bed-inp` | Hook / Selektor |
| `#p-nozzle-inp` | Hook / Selektor |
| `#panel-console` | Hook / Selektor |
| `#panel-dashboard` | Hook / Selektor |
| `#panel-printers` | Hook / Selektor |
| `#panel-store` | Hook / Selektor |
| `#poll-1` | Hook / Selektor |
| `#poll-2` | Hook / Selektor |
| `#poll-5` | Hook / Selektor |
| `#printer-dropdown-btn` | Hook / Selektor |
| `#printers-grid` | Hook / Selektor |
| `#s-printer-ip` | Hook / Selektor |
| `#settings-btn` | Hook / Selektor |
| `#settings-modal` | Hook / Selektor |
| `#skip-confirm` | Hook / Selektor |
| `#skip-dialog` | Hook / Selektor |
| `#slot-edit-color` | Hook / Selektor |
| `#slot-edit-mat` | Hook / Selektor |
| `#slot-edit-modal` | Hook / Selektor |
| `#slot-edit-preview` | Hook / Selektor |
| `#slot-mat-btns` | Hook / Selektor |
| `#store-filter` | Hook / Selektor |
| `#store-grid` | Hook / Selektor |
| `#store-refresh-btn` | Hook / Selektor |
| `#store-search` | Hook / Selektor |
| `#store-sort` | Hook / Selektor |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KX-Bridge</title>
<link rel="stylesheet" href="/kx/ui/style.css">
<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>
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
<span>📄 <span id="file-ready-name"></span></span>
<button id="file-ready-btn" onclick="startReadyFile()"
style="padding:5px 16px;background:#fff;color:#1a6e3c;border:none;border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
<button id="file-slots-btn" onclick="startReadyFileWithSlots()"
style="padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
<button id="file-cancel-btn" onclick="cancelReadyFile()"
style="padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
</div>
<header>
<div class="logo">⬡ KX-Bridge</div>
<div style="flex:1"></div>
<div id="printer-dropdown-wrap" style="display:none;position:relative">
<button id="printer-dropdown-btn" onclick="togglePrinterDropdown()" style="background:var(--raised);border:1px solid var(--border);border-radius:6px;padding:4px 10px;color:var(--txt);cursor:pointer;font-size:13px;display:flex;align-items:center;gap:6px">
<span id="h-pname">Anycubic Kobra X</span><span style="opacity:.5"></span>
</button>
<div id="printer-dropdown-menu" style="display:none;position:absolute;top:calc(100% + 4px);right:0;background:var(--card);border:1px solid var(--border);border-radius:8px;min-width:200px;z-index:200;box-shadow:0 4px 16px #0006;overflow:hidden">
</div>
</div>
<div id="h-pname-single" class="hname">Anycubic Kobra X</div>
<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="openSettings()" id="settings-btn" title="Einstellungen"></button>
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
</header>
<!-- ═══ SETTINGS MODAL ═══ -->
<div class="modal-overlay" id="settings-modal" onclick="if(event.target===this)closeSettings()">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title" id="modal-title-settings">Einstellungen</span>
<button class="modal-close" onclick="closeSettings()"></button>
</div>
<div>
<div class="modal-field" style="margin-bottom:12px">
<label id="lbl-printer-name" style="font-weight:600">Drucker-Name</label>
<input type="text" id="s-printer-name" placeholder="z.B. Kobra X Links">
</div>
<div class="modal-section" id="modal-sec-connection">Verbindung</div>
<div class="modal-field">
<label id="lbl-printer-ip">Drucker-IP</label>
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
</div>
<div class="modal-field">
<label id="lbl-mqtt-port">MQTT-Port</label>
<input type="number" id="s-mqtt-port" placeholder="9883">
</div>
<div class="modal-field">
<label id="lbl-username">MQTT-Benutzername</label>
<input type="text" id="s-username" placeholder="userXXXXXXXX" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-password">MQTT-Passwort</label>
<input type="password" id="s-password" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-device-id">Device-ID</label>
<input type="text" id="s-device-id" placeholder="32 Hex-Zeichen">
</div>
<div class="modal-field">
<label id="lbl-mode-id">Mode-ID</label>
<input type="text" id="s-mode-id" placeholder="20030">
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-print">Druckeinstellungen</div>
<div class="modal-field">
<label id="lbl-default-slot">Standard-Slot (Einfarbdruck)</label>
<select id="s-default-slot">
<option value="auto" id="opt-slot-auto">Auto (alle belegten Slots)</option>
<option value="0" id="opt-slot-0">Slot 1</option>
<option value="1" id="opt-slot-1">Slot 2</option>
<option value="2" id="opt-slot-2">Slot 3</option>
<option value="3" id="opt-slot-3">Slot 4</option>
</select>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<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-camera-on-print" style="width:auto;margin:0">
<label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-poll">Poll-Intervall</div>
<div class="poll-btns">
<button class="poll-btn" onclick="setPoll(1000)" id="poll-1">1s</button>
<button class="poll-btn active" onclick="setPoll(2000)" id="poll-2">2s</button>
<button class="poll-btn" onclick="setPoll(5000)" id="poll-5">5s</button>
</div>
</div>
<!-- OrcaSlicer-Profile (Custom-Profile-Import, Issue #41) -->
<div>
<div class="modal-section" id="modal-sec-orca-profiles">OrcaSlicer-Profile</div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:8px" id="orca-profiles-hint">
Eigene Profile aus OrcaSlicer importieren (User-Dir öffnen via Help → Show Configuration Folder)
</div>
<div id="orca-profiles-list" style="margin-bottom:8px;font-size:12px;color:var(--txt2)"></div>
<button class="btn btn-sm" id="btn-orca-profiles-import" onclick="openProfileImport()"
style="background:var(--raised);color:var(--txt)">
<span id="lbl-orca-profiles-import">Profile importieren</span>
</button>
</div>
<div>
<div class="modal-section" id="modal-sec-version">Version</div>
<div class="update-row">
<span id="s-version-label" style="font-size:13px;color:var(--txt)"></span>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="checkUpdate()" id="btn-update-check">🔄 <span id="lbl-update-check">Auf Updates prüfen</span></button>
</div>
<div class="update-status" id="update-status" style="margin-top:6px"></div>
<button class="btn btn-sm btn-accent" id="btn-update-apply" style="display:none;margin-top:8px" onclick="applyUpdate()">
<span id="lbl-update-apply">Jetzt installieren</span>
</button>
<div id="update-changelog" style="display:none;margin-top:10px;background:var(--raised);border-radius:6px;padding:10px;font-size:11px;font-family:var(--mono);color:var(--txt2);white-space:pre-wrap;max-height:180px;overflow-y:auto;line-height:1.6"></div>
</div>
<button class="modal-save" onclick="saveSettings()" id="btn-save-settings">Speichern &amp; Neustart</button>
</div>
</div>
<!-- ═══ AMS SLOT EDIT DIALOG ═══ -->
<div class="modal-overlay" id="slot-edit-modal" onclick="if(event.target===this)closeSlotEdit()">
<div class="modal-box" style="max-width:340px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<span class="modal-title" id="slot-edit-title"></span>
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1"></button>
</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
<div style="flex:1">
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
<input type="color" id="slot-edit-color"
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
</div>
</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">
</div>
<input type="text" id="slot-edit-mat"
oninput="highlightMatBtn(this.value)"
style="margin-top:8px;width:100%;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px;box-sizing:border-box">
</div>
<!-- Orca-Filament-Profil-Override (für AMS-Sync) -->
<div style="margin-bottom:20px">
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-profile"></div>
<select id="slot-edit-profile"
style="width:100%;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px;box-sizing:border-box">
<option value="" id="slot-profile-default-opt"></option>
</select>
<div style="font-size:11px;color:var(--txt2);margin-top:4px" id="slot-profile-hint"></div>
<a href="#" onclick="event.preventDefault();openProfileImport()"
style="display:inline-block;margin-top:6px;font-size:11px;color:var(--accent);text-decoration:none"
id="lbl-slot-profile-import">★ Eigene Profile importieren…</a>
</div>
<button class="btn" id="btn-slot-edit-feed" style="width:100%;margin-bottom:8px" onclick="slotEditFeed()"></button>
<button class="modal-save" id="btn-slot-edit-save" onclick="saveSlotEdit()"></button>
</div>
</div>
<!-- ═══ ORCA-PROFILE-IMPORT-DIALOG (Issue #41) ═══ -->
<div class="modal-overlay" id="profile-import-modal" onclick="if(event.target===this)closeProfileImport()">
<div class="modal-box" style="max-width:480px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div style="font-size:16px;font-weight:600" id="profile-import-title">Eigene OrcaSlicer-Profile importieren</div>
<button onclick="closeProfileImport()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer">×</button>
</div>
<div style="font-size:12px;color:var(--txt2);margin-bottom:12px;line-height:1.5" id="profile-import-help">
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>
</div>
<div id="profile-import-drop" style="border:2px dashed var(--border);border-radius:8px;padding:24px;text-align:center;cursor:pointer;margin-bottom:12px"
ondragover="event.preventDefault();this.style.borderColor='var(--accent)'"
ondragleave="this.style.borderColor='var(--border)'"
ondrop="event.preventDefault();this.style.borderColor='var(--border)';doProfileImportUpload(event.dataTransfer.files)"
onclick="document.getElementById('profile-import-file').click()">
<div style="font-size:32px;margin-bottom:8px"></div>
<div style="font-size:13px;color:var(--txt2)" id="profile-import-dropmsg">Hierher ziehen oder klicken</div>
<input type="file" id="profile-import-file" accept=".zip,.json" multiple
style="display:none" onchange="doProfileImportUpload(this.files);this.value=''">
</div>
<div id="profile-import-status" style="font-size:12px;margin-bottom:12px;min-height:18px"></div>
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="profile-import-list-label">Aktuell importiert</div>
<div id="profile-import-list" style="max-height:240px;overflow-y:auto;font-size:12px"></div>
</div>
</div>
<div class="layout">
<nav class="sidebar">
<button class="nav-btn active" onclick="showPanel('dashboard')" id="nb-dashboard">
<span class="nav-icon"></span><span class="nav-text">Dashboard</span></button>
<button class="nav-btn" onclick="showPanel('printers');loadPrinterTab()" id="nb-printers">
<span class="nav-icon">🖨</span><span class="nav-text">Drucker</span></button>
<button class="nav-btn" onclick="showPanel('store');loadStore()" id="nb-store">
<span class="nav-icon">🗂</span><span class="nav-text">Browser</span></button>
<button class="nav-btn" onclick="showPanel('console');clearLogBadge()" id="nb-console">
<span class="nav-icon"></span><span class="nav-text">Konsole</span><span id="log-badge" style="display:none;margin-left:4px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 5px;font-weight:700"></span></button>
</nav>
<main>
<!-- ═══ DASHBOARD ═══ -->
<div class="panel active" id="panel-dashboard">
<div class="grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px">
<span id="d-lbl-light" style="font-size:12px;color:var(--txt2)">💡 Licht</span>
<label class="toggle">
<input type="checkbox" id="d-light-toggle" onchange="setLight()">
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</label>
</div>
</div>
<div class="cam-wrap" id="cam-wrap">
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
<div class="cam-spinner" id="cam-spinner"></div>
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
<div class="cam-overlay" id="cam-overlay" style="display:none">
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
</div>
<button class="cam-toggle" onclick="toggleCam()" id="cam-toggle-btn">▶ Kamera</button>
</div>
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
<div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div style="display:flex;flex-direction:column;gap:4px;flex-shrink:0">
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center">
<div class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size:16px" id="d-layers"></div>
</div>
<div class="time-block" style="padding:4px 10px;min-width:72px;text-align:center">
<div class="time-label" id="d-lbl-zpos">Z</div>
<div class="time-val" style="font-size:13px" id="d-zpos"></div>
</div>
</div>
</div>
<div class="time-grid">
<div class="time-block">
<div class="time-label" id="d-lbl-elapsed"></div>
<div class="time-val" id="d-elapsed"></div>
</div>
<div class="time-block" id="d-slicer-row" style="display:none">
<div class="time-label" id="d-slicer-label"></div>
<div class="time-val" id="d-slicer-time"></div>
</div>
<div class="time-block" style="color:var(--acc)">
<div class="time-label" id="d-lbl-remain"></div>
<div class="time-val" id="d-remain" style="color:var(--acc)"></div>
</div>
</div>
<div class="fname" id="d-fname" title="" style="margin-top:6px"></div>
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px;display:none">
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="togglePauseResume()">⏸ Pause</button>
<button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none"><span id="d-btn-skip-label">Objekte</span></button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
</div>
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
<div class="temp-label" id="d-lbl-nozzle">Nozzle</div>
<div class="temp-row">
<div class="temp-val" id="d-nt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-nt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-ntbar" style="width:0%;background:linear-gradient(90deg,var(--accent2),#ffb020)"></div>
</div>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-nozzle-inp" placeholder="Ziel" min="0" max="300" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setNozzle()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-nozzle-inp').value=0;setNozzle()"><span class="lbl-off">Aus</span></button>
</div>
</div>
<div class="temp-block">
<div class="temp-label" id="d-lbl-bed">Bett</div>
<div class="temp-row">
<div class="temp-val" id="d-bt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-bt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-btbar" style="width:0%;background:linear-gradient(90deg,#ff6b35,var(--warn))"></div>
</div>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-bed-inp" placeholder="Ziel" min="0" max="120" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setBed()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-bed-inp').value=0;setBed()"><span class="lbl-off">Aus</span></button>
</div>
</div>
</div>
<div style="margin-top:14px">
<div style="font-size:10px;color:var(--txt2);margin-bottom:4px" id="d-chart-label">Verlauf (letzte 60 Messungen)</div>
<canvas id="d-chart" width="800" height="120" style="width:100%;height:120px;background:var(--raised);border-radius:8px"></canvas>
</div>
</div>
<!-- Achsensteuerung -->
<div class="card">
<div class="card-title"><span></span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
<div class="joypad">
<div></div>
<button class="joy" onclick="move(1,1,getStep())" title="Y+"></button>
<div></div>
<button class="joy" onclick="move(0,-1,getStep())" title="X"></button>
<button class="joy home" onclick="homeAll()" title="Home All"></button>
<button class="joy" onclick="move(0,1,getStep())" title="X+"></button>
<div></div>
<button class="joy" onclick="move(1,-1,getStep())" title="Y"></button>
<div></div>
</div>
<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>
<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">
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
<div style="display:flex;gap:8px;margin-top:4px">
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
<span class="spd-icon">🐢</span>
<span id="d-spd-lbl-1">Leise</span>
</button>
<button class="spd-btn spd-active" id="d-spd-2" onclick="setSpeed(2)">
<span class="spd-icon"></span>
<span id="d-spd-lbl-2">Normal</span>
</button>
<button class="spd-btn" id="d-spd-3" onclick="setSpeed(3)">
<span class="spd-icon">🚀</span>
<span id="d-spd-lbl-3">Sport</span>
</button>
</div>
<div class="spd-bar" style="margin-top:12px">
<div class="spd-bar-fill" id="d-spd-bar" style="width:50%"></div>
</div>
</div>
<!-- Lüfter -->
<div class="card">
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
<div class="slider-row">
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
<span class="slider-val" id="d-fan-val">0</span>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(0)">Aus</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(25)">25%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(50)">50%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(75)">75%</button>
<button class="btn btn-sm btn-accent" onclick="quickFan(100)">100%</button>
</div>
</div>
<div id="d-ace-dry-wrap" style="display:none">
<div id="d-ace-dry-grid" style="display:contents"></div>
</div>
<!-- AMS -->
<div class="card" style="grid-column:1/-1" id="d-ams-card">
<div class="card-title"><span></span> <span id="d-card-ams">Filament</span></div>
<div class="ams-slots" id="ams-slots">
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
</div>
</div>
</div>
</div>
<!-- ═══ CONSOLE ═══ -->
<!-- ═══ DRUCKER ═══ -->
<div class="panel" id="panel-printers">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span id="printers-panel-title">🖨 Drucker</span>
<div style="display:flex;gap:8px">
<button onclick="openAddPrinterDialog()" style="font-size:12px;padding:4px 12px;background:var(--accent);border:none;border-radius:6px;color:#fff;cursor:pointer;font-weight:600">+ <span id="add-printer-btn-label">Drucker hinzufügen</span></button>
<button onclick="loadPrinterTab()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer"></button>
</div>
</div>
<div id="printers-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px"></div>
</div>
</div>
<!-- ═══ GCODE STORE ═══ -->
<div class="panel" id="panel-store">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>
<div class="panel" id="panel-console">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
<span><span></span> <span id="ptitle-console">Ereignis-Log</span></span>
<a id="btn-log-dl" href="/api/log/download" download
style="font-size:12px;padding:4px 10px;background:var(--raised);border-radius:6px;color:var(--txt2);text-decoration:none">⬇ Download</a>
</div>
<div style="display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap;align-items:center">
<input id="log-filter" type="text" placeholder="Filter…"
oninput="renderLog()"
style="flex:1;min-width:120px;padding:5px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;font-family:var(--mono)">
<button id="btn-autoscroll" onclick="toggleAutoScroll()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--accent);color:#fff;cursor:pointer;white-space:nowrap">⬇ Auto</button>
<button onclick="consoleLogs=[];renderLog()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">✕ Clear</button>
</div>
<div style="display:flex;gap:5px;margin-bottom:8px;flex-wrap:wrap">
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-right:2px">Dir:</span>
<button class="log-dir-btn active" id="logdir-all" onclick="setLogDir('all')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer"></button>
<button class="log-dir-btn" id="logdir-rx" onclick="setLogDir('rx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">RX</button>
<button class="log-dir-btn" id="logdir-tx" onclick="setLogDir('tx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">TX</button>
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-left:6px;margin-right:2px" id="log-lbl-level">Level:</span>
<button class="log-lvl-btn active" id="loglvl-all" onclick="setLogLevel('all')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer"></button>
<button class="log-lvl-btn" id="loglvl-err" onclick="setLogLevel('err')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">⛔ Errors</button>
<button class="log-lvl-btn" id="loglvl-warn" onclick="setLogLevel('warn')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">⚠ Warn</button>
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-left:6px;margin-right:2px">Topic:</span>
<button class="log-topic-btn" data-topic="multiColorBox" onclick="setLogTopic('multiColorBox')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">AMS</button>
<button class="log-topic-btn" data-topic="print" onclick="setLogTopic('print')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">print</button>
<button class="log-topic-btn" data-topic="info" onclick="setLogTopic('info')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">info</button>
<button class="log-topic-btn" data-topic="status" onclick="setLogTopic('status')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">status</button>
</div>
<div class="console" id="console-log" style="height:calc(100vh - 260px);min-height:160px" onscroll="onLogScroll()"></div>
</div>
</div>
</main>
</div>
<nav class="bottom-nav">
<button class="bnav-btn active" onclick="showPanel('dashboard')" id="bnb-dashboard"><span class="bnav-icon"></span>Dashboard</button>
<button class="bnav-btn" onclick="showPanel('printers');loadPrinterTab()" id="bnb-printers"><span class="bnav-icon">🖨</span>Drucker</button>
<button class="bnav-btn" onclick="showPanel('store');loadStore()" id="bnb-store"><span class="bnav-icon">🗂</span>Browser</button>
<button class="bnav-btn" onclick="showPanel('console');clearLogBadge()" id="bnb-console"><span class="bnav-icon"></span>Log<span id="log-badge-bot" style="display:none;margin-left:3px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 4px;font-weight:700"></span></button>
</nav>
<!-- Web-Upload-Verify-Dialog -->
<div class="modal-overlay" id="store-web-verify-dialog" onclick="if(event.target===this)closeStoreWebVerifyDialog()">
<div class="modal-box" style="max-width:420px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="store-web-verify-title">Datei verifizieren</span>
<button onclick="closeStoreWebVerifyDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<p id="store-web-verify-msg" style="font-size:13px;color:var(--txt);margin-bottom:12px">Bitte bestätige, dass diese Datei für den Anycubic Kobra X erstellt wurde.</p>
<div id="store-web-verify-status" style="font-size:12px;color:var(--txt2);min-height:16px;margin-bottom:8px"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="store-web-verify-abort" onclick="closeStoreWebVerifyDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="store-web-verify-confirm" onclick="confirmStoreWebVerify()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">Bestätigen</button>
</div>
</div>
</div>
<!-- Filament-Slot-Dialog -->
<div class="modal-overlay" id="filament-dialog" onclick="if(event.target===this)closeFilamentDialog()">
<div class="modal-box" style="max-width:380px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="fd-title" style="font-size:14px;word-break:break-all"></span>
<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-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
<div id="fd-objects-section" style="display:none;margin-bottom:16px">
<p id="fd-objects-hint" style="font-size:12px;color:var(--txt2);margin-bottom:8px">Objekte überspringen (optional):</p>
<div id="fd-objects-svg" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:6px;margin-bottom:8px;text-align:center"></div>
<div id="fd-objects" style="display:flex;flex-direction:column;gap:6px;max-height:140px;overflow-y:auto"></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>
</div>
</div>
</div>
<!-- Drucker-hinzufügen-Dialog -->
<div class="modal-overlay" id="add-printer-dialog" onclick="if(event.target===this)closeAddPrinterDialog()">
<div class="modal-box" style="max-width:380px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="apd-title">Drucker hinzufügen</span>
<button onclick="closeAddPrinterDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<label id="apd-lbl-ip" style="display:block;font-size:12px;color:var(--txt2);margin-bottom:4px">Drucker-IP</label>
<input type="text" id="apd-ip" placeholder="192.168.1.100" style="width:100%;box-sizing:border-box;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);margin-bottom:10px">
<label id="apd-lbl-name" style="display:block;font-size:12px;color:var(--txt2);margin-bottom:4px">Name (optional)</label>
<input type="text" id="apd-name" placeholder="z.B. Kobra X Wohnzimmer" style="width:100%;box-sizing:border-box;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);margin-bottom:6px">
<div id="apd-status" style="font-size:12px;margin:8px 0;min-height:16px;color:var(--txt2)"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="apd-cancel" onclick="closeAddPrinterDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="apd-confirm" onclick="confirmAddPrinter()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">Hinzufügen</button>
</div>
</div>
</div>
<!-- Mid-Print Skip-Dialog -->
<div class="modal-overlay" id="skip-dialog" onclick="if(event.target===this)closeSkipDialog()">
<div class="modal-box" style="max-width:420px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="skip-title">✂ Objekte überspringen</span>
<button onclick="closeSkipDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<p id="skip-hint" style="font-size:12px;color:var(--txt2);margin-bottom:10px">Objekte abwählen, die nicht weiter gedruckt werden sollen:</p>
<div id="skip-svg" style="display:none;background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:6px;margin-bottom:10px;text-align:center"></div>
<div id="skip-list" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow-y:auto;margin-bottom:12px"></div>
<div id="skip-status" style="font-size:12px;color:var(--txt2);min-height:16px;margin-bottom:8px"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeSkipDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="skip-confirm" onclick="confirmSkip()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">Überspringen</button>
</div>
</div>
</div>
<!-- ACE Dryer Temp/Time Settings Dialog -->
<div class="modal-overlay" id="ace-dry-dialog" onclick="if(event.target===this)closeAceDryDialog()">
<div class="modal-box" style="max-width:560px;width:100%">
<div class="modal-header" style="margin-bottom:10px">
<span class="modal-title" id="ace-dry-dialog-title">Dryer Temp/Time Settings</span>
<button onclick="closeAceDryDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<label id="ace-dry-dialog-temp-label" style="min-width:190px;font-size:12px;color:var(--txt)">Temperature (30-80°C)</label>
<input id="ace-dry-dialog-temp" type="number" min="30" max="80" step="1"
oninput="aceDryDialogInputsChanged()"
style="width:130px;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);text-align:center" value="45">
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
<label id="ace-dry-dialog-time-label" style="min-width:190px;font-size:12px;color:var(--txt)">Rem. Time (h:m:s)</label>
<div style="display:flex;align-items:center;gap:8px">
<input id="ace-dry-dialog-h" type="number" min="0" max="24" step="1" value="4"
oninput="aceDryDialogInputsChanged()"
style="width:70px;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);text-align:center">
<span style="color:var(--txt2)">:</span>
<input id="ace-dry-dialog-m" type="number" min="0" max="59" step="1" value="0"
oninput="aceDryDialogInputsChanged()"
style="width:70px;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);text-align:center">
<span style="color:var(--txt2)">:</span>
<input id="ace-dry-dialog-s" type="number" min="0" max="59" step="1" value="0"
oninput="aceDryDialogInputsChanged()"
style="width:70px;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);text-align:center">
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:8px">
<button class="ace-dry-preset-btn" data-preset="pla" onclick="aceDryDialogPreset('pla')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">PLA</button>
<button class="ace-dry-preset-btn" data-preset="pla_plus" onclick="aceDryDialogPreset('pla_plus')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">PLA+</button>
<button class="ace-dry-preset-btn" data-preset="petg" onclick="aceDryDialogPreset('petg')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">PETG</button>
<button class="ace-dry-preset-btn" data-preset="tpu" onclick="aceDryDialogPreset('tpu')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">TPU</button>
<button class="ace-dry-preset-btn" data-preset="abs_asa" onclick="aceDryDialogPreset('abs_asa')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">ABS / ASA</button>
<button class="ace-dry-preset-btn" data-preset="pa_pc" onclick="aceDryDialogPreset('pa_pc')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">PA / PC</button>
<button class="ace-dry-preset-btn" data-preset="custom_1" onclick="aceDryDialogPreset('custom_1')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">Custom 1</button>
<button class="ace-dry-preset-btn" data-preset="custom_2" onclick="aceDryDialogPreset('custom_2')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">Custom 2</button>
<button class="ace-dry-preset-btn" data-preset="custom_3" onclick="aceDryDialogPreset('custom_3')" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">Custom 3</button>
</div>
<div id="ace-dry-dialog-custom-name-row" style="display:none;align-items:center;gap:12px;margin-bottom:14px">
<label id="ace-dry-dialog-custom-name-label" style="min-width:190px;font-size:12px;color:var(--txt)">Custom Name</label>
<input id="ace-dry-dialog-custom-name" type="text" maxlength="32" oninput="aceDryDialogInputsChanged()"
style="width:220px;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt)">
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button id="ace-dry-dialog-reset-default" onclick="resetAceDryPresetToDefault()" style="display:none;padding:8px 14px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Reset to Default</button>
<button id="ace-dry-dialog-save-preset" onclick="saveAceDryPresetAndRestart()" style="display:none;padding:8px 14px;background:var(--warn);border:1px solid transparent;border-radius:8px;color:#fff;cursor:pointer">Save & Restart</button>
<button id="ace-dry-dialog-cancel" onclick="closeAceDryDialog()" style="padding:8px 14px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Cancel</button>
<button id="ace-dry-dialog-confirm" onclick="confirmAceDryDialog()" style="padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">Confirm</button>
</div>
</div>
</div>
<script src="/kx/ui/app.js"></script></head>
<footer style="text-align:center;padding:12px;font-size:11px;color:var(--txt2);border-top:1px solid var(--border);margin-top:auto">
&copy; ViewIT 2026
</footer>
</body>
</html>

View File

@@ -0,0 +1,315 @@
:root{
color-scheme:dark; /* native Form-Controls (select) im Webview dunkel rendern */
--bg:#1a1a1f;--card:#24242c;--raised:#2e2e3a;--border:#3a3a4a;
--txt:#f0f0f5;--txt2:#8888aa;--accent:#00c8ff;--accent2:#ff6b35;
--ok:#4cde80;--err:#ff4d6d;--warn:#ffb020;
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--mono:"JetBrains Mono","Fira Code",monospace;
}
[data-theme=light]{
color-scheme:light;
--bg:#f0f0f5;--card:#fff;--raised:#e8e8f0;--border:#d0d0e0;
--txt:#1a1a2e;--txt2:#666680;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--accent);text-decoration:none}
/* select/option-Farben explizit setzen — OrcaSlicers Device-Tab-Webview erbt
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29). */
select{background:var(--raised)!important;color:var(--txt)!important}
select option{background:var(--card)!important;color:var(--txt)!important}
/* ── HEADER ── */
header{background:var(--card);border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
position:sticky;top:0;z-index:100}
.logo{font-size:18px;font-weight:700;color:var(--accent);letter-spacing:-.02em}
.hname{font-size:13px;color:var(--txt2)}
.hbadge{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;
padding:4px 10px;border-radius:20px;background:var(--raised);color:var(--txt2);
text-transform:uppercase;letter-spacing:.04em}
.hbadge.printing{background:#0d2d1a;color:var(--ok)}
.hbadge.complete{background:#0d1f38;color:#60b0ff}
.hbadge.error{background:#2d0d0d;color:var(--err)}
.hbadge .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
.hbadge.printing .dot{animation:pulse 1.4s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.theme-btn{background:none;border:1px solid var(--border);color:var(--txt2);
border-radius:8px;padding:6px 10px;cursor:pointer;font-size:13px;transition:.15s}
.theme-btn:hover{border-color:var(--accent);color:var(--accent)}
.conn-btn{border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px;
font-weight:600;border:none;transition:.15s}
.conn-btn.disconnected{background:var(--accent);color:#fff}
.conn-btn.disconnected:hover{opacity:.85}
.conn-btn.connected{background:transparent;border:1px solid var(--border);color:var(--txt2)}
.conn-btn.connected:hover{border-color:#e05;color:#e05}
/* ── LAYOUT ── */
.layout{display:flex;flex:1;min-height:0}
nav.sidebar{width:200px;background:var(--card);border-right:1px solid var(--border);
display:flex;flex-direction:column;padding:12px 8px;gap:2px;flex-shrink:0}
.nav-btn{background:none;border:none;color:var(--txt2);text-align:left;
padding:9px 12px;border-radius:8px;cursor:pointer;font-size:13px;
display:flex;align-items:center;gap:10px;transition:.12s;width:100%}
.nav-btn:hover{background:var(--raised);color:var(--txt)}
.nav-btn.active{background:var(--raised);color:var(--accent)}
.nav-icon{font-size:16px;width:20px;text-align:center}
main{flex:1;overflow-y:auto;padding:20px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}
/* ── CARD ── */
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
padding:18px;transition:box-shadow .15s,transform .15s}
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
margin-bottom:14px;display:flex;align-items:center;gap:8px}
.card-title span{font-size:14px}
/* ── HERO ── */
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
@media(max-width:900px){.hero{grid-template-columns:1fr}}
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
@keyframes spin{to{transform:rotate(360deg)}}
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;display:none}
.cam-overlay{position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.75));padding:14px}
.cam-toggle{position:absolute;top:10px;right:10px;background:rgba(0,0,0,.5);
border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px;
padding:6px 10px;cursor:pointer;font-size:12px;backdrop-filter:blur(4px)}
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
.hero-info{display:flex;flex-direction:column;gap:12px}
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}
.progress-bar{height:8px;background:var(--raised);border-radius:4px;overflow:hidden;margin:4px 0}
.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#0080cc);
border-radius:4px;transition:width .6s ease}
.meta-row{display:flex;justify-content:space-between;font-size:12px;color:var(--txt2)}
.layer-badge{background:var(--raised);border-radius:6px;padding:4px 8px;
font-family:var(--mono);font-size:12px;color:var(--txt)}
.fname{font-size:12px;color:var(--txt2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
background:var(--raised);border-radius:6px;padding:6px 8px}
/* ── PRINT CONTROLS ── */
.ctrl-btns{display:flex;gap:8px;flex-wrap:wrap}
.btn{border:none;border-radius:8px;padding:10px 16px;font-size:13px;font-weight:600;
cursor:pointer;transition:opacity .15s,transform .1s;white-space:nowrap}
.btn:hover{opacity:.85;transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.btn-start{background:var(--ok);color:#0d2010}
.btn-pause{background:var(--raised);color:var(--txt);border:1px solid var(--border)}
.btn-resume{background:#0d2d1a;color:var(--ok);border:1px solid var(--ok)}
.btn-skip{background:var(--raised);color:var(--warn);border:1px solid var(--warn)}
.btn-cancel{background:#2d0d0d;color:var(--err);border:1px solid var(--err)}
.btn-accent{background:var(--accent);color:#001a24}
.btn-sm{padding:7px 12px;font-size:12px}
.spd-btn{flex:1;border:1.5px solid var(--border);background:var(--raised);color:var(--txt);
border-radius:10px;padding:14px 8px;font-size:13px;font-weight:600;cursor:pointer;
transition:all .15s;display:flex;flex-direction:column;align-items:center;gap:4px}
.spd-btn:hover{border-color:var(--accent);color:var(--accent)}
.spd-btn.spd-active{border-color:var(--accent);background:rgba(0,200,255,.12);color:var(--accent)}
.spd-btn .spd-icon{font-size:22px}
.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
/* ── TIME CARDS ── */
.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
.time-block{background:var(--raised);border-radius:10px;padding:10px 12px}
.time-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:4px}
.time-val{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--txt)}
/* ── TEMPS ── */
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
.temp-row{display:flex;align-items:baseline;gap:6px}
.temp-val{font-size:30px;font-weight:700;font-family:var(--mono)}
.temp-unit{font-size:14px;color:var(--txt2)}
.temp-target{font-size:11px;color:var(--txt2);margin-top:2px}
.temp-arc{position:absolute;top:12px;right:12px}
.temp-edit{display:flex;gap:6px;margin-top:10px}
.temp-input{background:var(--bg);border:1px solid var(--border);color:var(--txt);
border-radius:6px;padding:5px 8px;font-size:13px;font-family:var(--mono);width:70px}
.temp-input:focus{outline:none;border-color:var(--accent)}
.chart-wrap{margin-top:12px}
canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:var(--raised)}
/* ── MOTION ── */
.joypad{display:grid;grid-template-columns:repeat(3,52px);
grid-template-rows:repeat(3,52px);gap:6px;justify-content:center;margin:8px auto}
.joy{background:var(--raised);border:1px solid var(--border);color:var(--txt);
border-radius:10px;font-size:18px;cursor:pointer;transition:.12s;
display:flex;align-items:center;justify-content:center}
.joy:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
.joy:active{transform:scale(.93)}
.joy.home{font-size:14px;background:var(--bg)}
.step-btns{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-top:10px}
.step-btn{background:var(--raised);border:1px solid var(--border);color:var(--txt2);
border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer;transition:.12s}
.step-btn.active,.step-btn:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
.home-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;justify-content:center}
/* ── AMS ── */
.ams-slots{display:flex;flex-direction:column;gap:12px}
.ams-box-group{}
.ams-box-label{font-size:11px;font-weight:700;color:var(--txt2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px;padding-left:2px}
.ams-box-slots{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.ams-slot{background:var(--raised);border-radius:10px;padding:10px;text-align:center;
border:2px solid transparent;transition:.2s;position:relative}
.ams-slot.active{border-color:var(--slot-color,var(--accent));
box-shadow:0 0 12px rgba(var(--slot-rgb,0,200,255),.3)}
.ams-slot.loaded{border-color:var(--ok)!important;
box-shadow:0 0 0 2px rgba(64,220,120,.35),0 0 14px rgba(64,220,120,.35)}
.ams-slot.loading{border-color:var(--ok)!important;animation:amsPulseGreen 1s ease-in-out infinite}
.ams-slot.unloading{border-color:var(--err)!important;animation:amsPulseRed 1s ease-in-out infinite}
@keyframes amsPulseGreen{0%{box-shadow:0 0 0 0 rgba(64,220,120,.55)}50%{box-shadow:0 0 0 4px rgba(64,220,120,.25),0 0 18px rgba(64,220,120,.45)}100%{box-shadow:0 0 0 0 rgba(64,220,120,.55)}}
@keyframes amsPulseRed{0%{box-shadow:0 0 0 0 rgba(230,80,80,.55)}50%{box-shadow:0 0 0 4px rgba(230,80,80,.25),0 0 18px rgba(230,80,80,.45)}100%{box-shadow:0 0 0 0 rgba(230,80,80,.55)}}
.ams-slot-bridge{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;
border:1px dashed var(--border);background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.01));
color:var(--txt2);min-height:106px}
.ams-slot-bridge .bridge-chip{width:58px;height:58px;border:1px solid rgba(255,255,255,.14);border-radius:50%;
display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.04);color:var(--txt2);
font-size:13px;font-weight:700;letter-spacing:.04em}
.slot-circle{width:36px;height:36px;border-radius:50%;margin:0 auto 6px;border:2px solid rgba(255,255,255,.15)}
.slot-label{font-size:11px;color:var(--txt2);font-family:var(--mono)}
.slot-material{font-size:12px;font-weight:600;margin-bottom:2px}
/* ── LIGHT + FAN ── */
.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.toggle-label{font-size:13px;font-weight:600}
.toggle{position:relative;width:44px;height:24px;cursor:pointer}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.toggle-track{width:44px;height:24px;background:var(--raised);border-radius:12px;
border:1px solid var(--border);transition:.25s;display:block}
.toggle input:checked+.toggle-track{background:var(--accent)}
.toggle-thumb{position:absolute;top:3px;left:3px;width:18px;height:18px;
background:#fff;border-radius:50%;transition:.25s;pointer-events:none}
.toggle input:checked~.toggle-thumb{transform:translateX(20px)}
.slider-row{display:flex;align-items:center;gap:10px;margin-top:8px}
.slider-label{font-size:12px;color:var(--txt2);width:80px}
.slider{flex:1;-webkit-appearance:none;height:4px;border-radius:2px;
background:var(--raised);outline:none;cursor:pointer}
.slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;
border-radius:50%;background:var(--accent);cursor:pointer;transition:.1s}
.slider::-webkit-slider-thumb:hover{transform:scale(1.2)}
.slider-val{font-family:var(--mono);font-size:12px;color:var(--txt);width:30px;text-align:right}
/* ── CONSOLE ── */
.console{background:#0a0a0e;border-radius:8px;padding:10px;font-family:var(--mono);
font-size:11px;color:#8888aa;overflow-y:auto;line-height:1.6}
.console .ts{color:#444;margin-right:6px}
.console .msg-info{color:#8888aa}
.console .msg-ok{color:var(--ok)}
.console .msg-err{color:var(--err)}
.console .msg-warn{color:var(--warn)}
/* ── PANELS ── */
.panel{display:none}
.panel.active{display:block}
/* ── FILE BROWSER UPLOAD ZONE ── */
#store-upload-zone{
display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:6px;padding:18px 12px;margin-bottom:14px;
border:2px dashed var(--border);border-radius:10px;
background:var(--raised);color:var(--txt2);
cursor:pointer;transition:border-color .15s,background .15s;
font-size:13px;text-align:center;user-select:none;
}
#store-upload-zone:hover{border-color:var(--accent);background:rgba(0,200,255,.06);color:var(--txt)}
#store-upload-zone.drag-over{border-color:var(--accent);background:rgba(0,200,255,.12);color:var(--accent)}
#store-upload-icon{font-size:22px;line-height:1}
.upload-status-busy{color:var(--txt2)}
.upload-status-ok{color:var(--ok)}
.upload-status-err{color:var(--err)}
/* ── MODAL ── */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);
z-index:200;align-items:center;justify-content:center;padding:16px}
.modal-overlay.open{display:flex}
.modal-box{background:var(--card);border:1px solid var(--border);border-radius:14px;
width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:24px;
display:flex;flex-direction:column;gap:18px}
.modal-header{display:flex;align-items:center;justify-content:space-between}
.modal-title{font-size:15px;font-weight:700;color:var(--txt)}
.modal-close{background:none;border:none;color:var(--txt2);font-size:20px;
cursor:pointer;padding:4px 8px;border-radius:6px}
.modal-close:hover{background:var(--raised);color:var(--txt)}
.modal-section{font-size:10px;text-transform:uppercase;letter-spacing:.1em;
color:var(--txt2);margin-bottom:6px;margin-top:4px}
.modal-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.modal-field label{font-size:12px;color:var(--txt2)}
.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)}
.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}
.poll-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600}
.update-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.update-status{font-size:12px;color:var(--txt2);flex:1;min-width:0}
.modal-save{width:100%;padding:10px;background:var(--accent);border:none;
border-radius:8px;color:#000;font-weight:700;font-size:14px;cursor:pointer;margin-top:4px}
.modal-save:hover{opacity:.88}
/* ── BOTTOM NAV (mobile) ── */
nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
background:var(--card);border-top:1px solid var(--border);
justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))}
.bnav-btn{background:none;border:none;color:var(--txt2);display:flex;
flex-direction:column;align-items:center;gap:3px;cursor:pointer;font-size:10px;padding:4px 8px}
.bnav-btn.active{color:var(--accent)}
.bnav-icon{font-size:20px}
/* ── Tablet (7691100px): schmale Sidebar ── */
@media(min-width:769px) and (max-width:1100px){
nav.sidebar{width:52px;padding:12px 4px}
.nav-btn .nav-text{display:none}
.nav-btn{justify-content:center;padding:10px}
.nav-icon{width:auto}
.grid{grid-template-columns:repeat(2,1fr)}
.hero{grid-template-columns:1fr}
}
/* ── Mobile (≤768px): Bottom-Nav, 1-Spalte ── */
@media(max-width:768px){
nav.sidebar{display:none}
nav.bottom-nav{display:flex}
main{padding:10px;padding-bottom:72px}
/* Header kompakt */
header{padding:0 12px;gap:8px}
.hname{display:none}
/* 1-Spalten-Grid, full-width spans funktionieren weiterhin */
.grid{grid-template-columns:1fr;gap:12px}
/* Hero: Kamera über Info */
.hero{grid-template-columns:1fr}
.cam-wrap{max-height:220px}
/* Temp-Pair und Temp-Card übereinander */
.temp-pair{grid-template-columns:1fr}
.temp-card-inner{grid-template-columns:1fr}
/* AMS: 2 Spalten auf kleinen Screens */
.ams-box-slots{grid-template-columns:repeat(2,1fr)}
/* Joypad etwas kleiner */
.joypad{grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:5px}
.joy{font-size:16px}
/* Buttons größere Touch-Targets */
.btn{padding:10px 14px;font-size:13px}
.btn-sm{padding:8px 12px}
.step-btn{padding:8px 12px;font-size:13px}
/* Modal vollbreite auf kleinen Screens */
.modal-box{padding:16px;border-radius:10px}
.poll-btns{gap:6px}
}

248
web/translations/de.json Normal file
View File

@@ -0,0 +1,248 @@
{
"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_status_off": "Status: Aus",
"ace_dry_status_on": "Status: Aktiv",
"ace_dry_status_remaining": "Rest",
"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)",
"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",
"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_on_off": "Ein / Aus",
"label_speed": "Geschwindigkeit",
"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:",
"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_nozzle": "Düse →",
"log_bed": "Bett →",
"log_axis": "Achse",
"log_home": "Home",
"log_home_all": "Home All",
"log_cam_start": "Kamera gestartet:",
"log_cam_stop": "Kamera gestoppt",
"log_poll_error": "Poll-Fehler:",
"log_error": "Fehler:",
"confirm_cancel": "Druck wirklich abbrechen?",
"settings_title": "Einstellungen",
"settings_connection": "Verbindung",
"settings_print": "Druckeinstellungen",
"settings_poll": "Poll-Intervall",
"settings_version": "Version",
"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_user_label": "Eigene Profile",
"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.",
"fd_objects_hint": "Objekte überspringen (optional):",
"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_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",
"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}",
"sf_all": "Alle",
"sf_ok": "✓ Erfolgreich",
"sf_err": "✗ Fehler",
"sf_new": "Neu",
"ss_date": "↓ Datum",
"ss_name": "AZ Name",
"ss_dur": "⏱ Druckzeit"
}

248
web/translations/en.json Normal file
View File

@@ -0,0 +1,248 @@
{
"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_status_off": "Status: Off",
"ace_dry_status_on": "Status: Active",
"ace_dry_status_remaining": "Remaining",
"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)",
"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",
"cam_placeholder": "📷 Camera not started",
"cam_stream_unavailable": "Stream unavailable",
"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_on_off": "On / Off",
"label_speed": "Speed",
"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:",
"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_nozzle": "Nozzle →",
"log_bed": "Bed →",
"log_axis": "Axis",
"log_home": "Home",
"log_home_all": "Home All",
"log_cam_start": "Camera started:",
"log_cam_stop": "Camera stopped",
"log_poll_error": "Poll error:",
"log_error": "Error:",
"confirm_cancel": "Really cancel the print?",
"settings_title": "Settings",
"settings_connection": "Connection",
"settings_print": "Print Settings",
"settings_poll": "Poll Interval",
"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 before print",
"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_user_label": "Own profiles",
"orca_profile_user_empty": " none ",
"orca_profile_uploading": "Uploading…",
"orca_profile_done": "Imported",
"orca_profile_skipped": "skipped",
"log_dir_all": "All",
"log_lvl_label": "Level:",
"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_select_at_least_one": "Please pick at least one object.",
"skip_sending": "Sending …",
"skip_success": "Objects will be skipped.",
"fd_objects_hint": "Skip objects (optional):",
"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_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",
"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}",
"sf_all": "All",
"sf_ok": "✓ Completed",
"sf_err": "✗ Failed",
"sf_new": "New",
"ss_date": "↓ Date",
"ss_name": "AZ Name",
"ss_dur": "⏱ Print time"
}

248
web/translations/es.json Normal file
View File

@@ -0,0 +1,248 @@
{
"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_status_off": "Estado: Apagado",
"ace_dry_status_on": "Estado: Activo",
"ace_dry_status_remaining": "Restante",
"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)",
"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",
"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_on_off": "Encendido / Apagado",
"label_speed": "Velocidad",
"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:",
"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_nozzle": "Boquilla →",
"log_bed": "Cama →",
"log_axis": "Eje",
"log_home": "Home",
"log_home_all": "Home All",
"log_cam_start": "Cámara iniciada:",
"log_cam_stop": "Cámara detenida",
"log_poll_error": "Error de sondeo:",
"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",
"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_user_label": "Perfiles propios",
"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.",
"fd_objects_hint": "Omitir objetos (opcional):",
"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_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",
"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}",
"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"
}

249
web/translations/fr.json Normal file
View File

@@ -0,0 +1,249 @@
{
"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_status_off": "Statut : Arrêté",
"ace_dry_status_on": "Statut : Actif",
"ace_dry_status_remaining": "Restant",
"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)",
"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",
"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_on_off": "On / Off",
"label_speed": "Vitesse",
"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 :",
"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_nozzle": "Buse →",
"log_bed": "Plateau →",
"log_axis": "Axe",
"log_home": "Origine",
"log_home_all": "Origine Tout",
"log_cam_start": "Caméra démarrée :",
"log_cam_stop": "Caméra arrêtée",
"log_poll_error": "Erreur de sondage :",
"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",
"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_user_label": "Mes profils",
"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.",
"fd_objects_hint": "Ignorer des objets (optionnel) :",
"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_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",
"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}",
"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"
}

248
web/translations/zh-cn.json Normal file
View File

@@ -0,0 +1,248 @@
{
"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_status_off": "状态: 关闭",
"ace_dry_status_on": "状态: 运行中",
"ace_dry_status_remaining": "剩余",
"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后端稍后支持)",
"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": "视频流不可用",
"btn_cam_start": "▶ 相机",
"btn_cam_stop": "◼ 相机",
"btn_pause": "⏸ 暂停",
"btn_resume": "▶ 继续",
"btn_cancel": "✕ 停止",
"label_nozzle": "喷嘴",
"label_bed": "热床",
"label_fan": "🌀 风扇",
"label_light": "💡 灯光",
"label_on_off": "开 / 关",
"label_speed": "速度",
"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": "目标:",
"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_nozzle": "喷嘴 →",
"log_bed": "热床 →",
"log_axis": "轴",
"log_home": "回零",
"log_home_all": "全部回零",
"log_cam_start": "相机已启动:",
"log_cam_stop": "相机已停止",
"log_poll_error": "轮询错误:",
"log_error": "错误:",
"confirm_cancel": "确定要取消打印吗?",
"settings_title": "设置",
"settings_connection": "连接",
"settings_print": "打印设置",
"settings_poll": "轮询间隔",
"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_user_label": "自己的配置",
"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": "对象将被跳过。",
"fd_objects_hint": "跳过对象 (可选):",
"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_empty": "尚未上传文件。",
"store_refresh": "↻ 刷新",
"store_print": "▶ 打印",
"store_download": "⬇ 下载",
"store_delete_confirm": "删除文件?",
"store_print_confirm": "打印文件?",
"store_web_verify_title": "验证文件",
"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}",
"sf_all": "全部",
"sf_ok": "✓ 已完成",
"sf_err": "✗ 失败",
"sf_new": "新",
"ss_date": "↓ 日期",
"ss_name": "AZ 名称",
"ss_dur": "⏱ 打印时间"
}