Merge branch 'nightly_plus_notifs' into master_plus_notifs

This commit is contained in:
2026-07-28 07:45:35 -05:00
23 changed files with 8169 additions and 2956 deletions

30
.editorconfig Normal file
View File

@ -0,0 +1,30 @@
# EditorConfig helps maintain consistent coding styles across all files
# https://editorconfig.org
root = true
# Unix-style newlines, UTF-8 encoding for all files
[*]
end_of_line = lf
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
# Python: 4 spaces (PEP 8)
[*.py]
indent_style = space
indent_size = 4
# JavaScript/JSON/YAML: 2 spaces (common web standard)
[*.{js,json,yml,yaml}]
indent_style = space
indent_size = 2
# HTML/CSS: 2 spaces
[*.{html,css}]
indent_style = space
indent_size = 2
# Markdown: preserve formatting
[*.md]
trim_trailing_whitespace = false

2
.gitignore vendored
View File

@ -6,6 +6,8 @@ dist/
*.spec
releases/
node_modules/*
!kx-bridge.spec
# Laufzeit-Daten und Drucker-Credentials — nie committen

11
.prettierrc.json Normal file
View File

@ -0,0 +1,11 @@
{
"jsonRecursiveSort": false,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"],
"printWidth": 100,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false
}

161
CLAUDE.md Normal file
View File

@ -0,0 +1,161 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
KX-Bridge is a Python 3.11+ bridge that emulates the Klipper/Moonraker API to enable OrcaSlicer to control Anycubic Kobra X printers in LAN mode — no Klipper, no Raspberry Pi required. It reverse-engineers the Anycubic MQTT protocol to relay commands from slicers to the printer.
## Running
**Docker (recommended):**
```bash
docker compose up -d
docker compose logs -f
```
**Python directly:**
```bash
pip install -r requirements.txt
python kobrax_moonraker_bridge.py --printer-ip 192.168.x.x
```
**Build binary:**
```bash
pyinstaller kx-bridge.spec
```
No test suite exists in this repository.
## Architecture
The system bridges three parties:
```
OrcaSlicer / Browser
↕ HTTP + WebSocket (port 7125+, Moonraker API)
KX-Bridge (kobrax_moonraker_bridge.py)
↕ MQTT over TLS (port 9883, Anycubic LAN protocol)
Anycubic Kobra X Printer
```
### Core Files
- [kobrax_moonraker_bridge.py](kobrax_moonraker_bridge.py) — Main server (~5,000 lines). Houses `KobraXBridge` (HTTP/WebSocket handlers, state management), `GCodeStore` (SQLite-backed file/print-history storage), and `CameraCache` (MJPEG stream caching). This is where all Moonraker API endpoints are implemented.
- [kobrax_client.py](kobrax_client.py) — Low-level MQTT client for the Kobra X. Handles raw MQTT 3.1.1 framing, TLS certificate auth, topic subscriptions, and message dispatch.
- [config_loader.py](config_loader.py) — Loads `config/config.ini` (primary); [env_loader.py](env_loader.py) handles `.env` fallback. Also exposes `list_notification_urls()`, `list_printers()`, `list_filament_profiles()`.
- [fetch_credentials.py](fetch_credentials.py) — Standalone tool to pull printer credentials over HTTP from the Kobra X.
- [extract_credentials.py](extract_credentials.py) — Reads credentials from a running AnycubicSlicerNext process (Windows/Linux).
### Web UI
Single-page app served by the bridge itself:
- [web/themes/default/index.html](web/themes/default/index.html) — Shell HTML
- [web/themes/default/app.js](web/themes/default/app.js) — All client-side logic (~2,500 lines): WebSocket handling, state rendering, UI events
- [web/themes/default/style.css](web/themes/default/style.css) — Dark/light theme via CSS variables
- [web/translations/](web/translations/) — i18n strings (DE, EN, ES, ZH-CN)
When adding UI text, all four translation files must be updated. The hint element for notifications uses `innerHTML` (not `textContent`) to support links — follow the same pattern used for `orca_profile_help_html` when translation values contain HTML.
The theme system is documented in [web/DOC/THEME-CSS-HOOKS.md](web/DOC/THEME-CSS-HOOKS.md) and [web/DOC/THEME-JS-ID-HOOKS.md](web/DOC/THEME-JS-ID-HOOKS.md).
### Configuration
Primary config: `config/config.ini` (copy from `config/config.ini.example`). Sections:
- `[connection]` — Printer IP, MQTT credentials, device/mode IDs
- `[printer_N]` — One section per printer for multi-printer setups (ports 71257130)
- `[print]` — AMS slots, auto-leveling, camera
- `[filament_profiles]` — Per-slot filament for AMS
- `[bridge]` — Poll interval (15 s, default 3 s), printer name
- `[notifications]` — Apprise notification URLs and interval settings (see below)
### Data Storage
- **SQLite** at `data/kx-bridge.db` — G-code metadata, print history, thumbnails (base64)
- **`data/orca_filaments.json`** — OrcaSlicer filament database, loaded at startup
### Key Protocol Details
- **MQTT auth:** AES-256-CBC encrypted credentials + TLS certificates (`anycubic_slicer.crt/key` bundled via PyInstaller)
- **Moonraker emulation:** Kobra states are mapped to Klipper states via `KOBRA_TO_KLIPPER_STATE` in `kobrax_moonraker_bridge.py`
- **Real-time logs:** Streamed to the browser via Server-Sent Events (SSE), not WebSocket
- **Multi-printer:** Each printer runs its own `KobraXBridge` instance on a separate port
### PyInstaller Build
[kx-bridge.spec](kx-bridge.spec) bundles the web UI, filament database, and Anycubic TLS certificates into a single binary. The `web/` tree maps to `static/` inside the binary. Both `pycryptodome` and `apprise` use `collect_all()` to capture dynamically loaded plugins.
### Notification System (Apprise)
Push notifications are sent via the [apprise](https://github.com/caronc/apprise) library (supports 60+ services via URL syntax: `discord://`, `telegram://`, `pover://`, `gotify://`, `slack://`, etc.).
**Config format** (`[notifications]` section):
```ini
url_1 = discord://webhook_id/webhook_token
events_1 = started,finished,failed,cancelled,paused,progress
image_1 = false
url_2 = pover://USERKEY@TOKEN
events_2 = finished,failed
image_2 = true
notify_every_minutes = 10
notify_every_layers = 0
```
**Key implementation points:**
- `KobraXBridge._notify(event, filename)` — fires on state transitions detected in `_on_print()`. Splits matching URLs into plain and image groups; image group attaches a temp `.jpg` from `camera_cache.latest_jpeg` via `apprise.AppriseAttachment`.
- `KobraXBridge._check_progress_notifications()` — called from `_poll_loop()` every poll tick; fires `progress` event when the time or layer threshold is crossed. Both counters are reset together when either fires, and are also reset when a new print starts.
- `_prev_kobra_state` guards against duplicate notifications when the printer repeatedly reports the same state.
- All notification dispatches run in `threading.Thread(daemon=True)` to avoid blocking the MQTT reader thread or the event loop.
- The test endpoint (`POST /api/notifications/test`) runs apprise synchronously via `run_in_executor`.
**Supported events:** `started`, `finished`, `failed`, `cancelled`, `paused`, `progress`
**Settings UI:** Managed through the WebUI settings modal. Each URL entry has per-event checkboxes, a `📷 Image` toggle, and a Test button. Global repeat interval fields (minutes / layers) apply to all URLs subscribed to the `progress` event.
## Android App
A companion Android app lives in [android/](android/). It connects to a running KX-Bridge server and provides a mobile printer control panel.
### Building
```bash
cd android
./gradlew assembleDebug # APK at app/build/outputs/apk/debug/
./gradlew assembleRelease # Minified release APK
```
- **Min SDK:** 26 (Android 8.0), **Compile/Target SDK:** 34
- **Language:** Kotlin 2.0.0 + Jetpack Compose (Material3)
- **Java toolchain:** 17
- No test suite exists.
### Architecture
```
android/app/src/main/java/com/kxbridge/
├── MainActivity.kt # Entry point; reads SharedPreferences for server URL
├── data/
│ ├── PrinterRepository.kt # OkHttp3 HTTP client; polls /api/state every 3 s
│ └── model/PrinterState.kt # kotlinx-serialization data model (snake_case JSON)
├── viewmodel/PrinterViewModel.kt # StateFlow<UiState> (Loading / Success / Error)
└── ui/
├── PrinterScreen.kt # Main control UI: temps, progress, pause/resume/cancel
└── SetupScreen.kt # First-run server URL entry form
```
`MainActivity` stores the server URL in SharedPreferences (`"kxbridge"` / key `"server_url"`) and switches between `SetupScreen` and `PrinterScreen` based on whether a URL is configured.
`PrinterViewModel` drives all network interaction: it holds a `PrinterRepository`, exposes a `StateFlow<UiState>`, and exposes command methods (`pause`, `resume`, `cancel`). `UiState` is a sealed interface.
### Communication with KX-Bridge
The app uses plain HTTP (cleartext allowed in the manifest):
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/state` | Fetch `PrinterState` JSON (polled every 3 s) |
| POST | `/printer/print/pause` | Pause active print |
| POST | `/printer/print/resume` | Resume paused print |
| POST | `/printer/print/cancel` | Cancel print |
`PrinterState` fields use `@SerialName` for snake_case JSON keys. ProGuard is configured to keep `com.kxbridge.data.model.**` to prevent serialization breakage in release builds.

View File

@ -194,7 +194,6 @@ def list_printers() -> list[dict]:
idx += 1
return printers
def _filament_section(printer_id: Optional[str] = None) -> str:
"""Section name holding a printer's filament-profile mapping.
@ -207,19 +206,14 @@ def _filament_section(printer_id: Optional[str] = None) -> str:
if pid and pid != "0":
return f"filament_profiles_{pid}"
return "filament_profiles"
def list_filament_profiles() -> dict[int, dict]:
"""Liest die [filament_profiles]-Sektion aus config.ini.
def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
"""Reads the [filament_profiles] section from config.ini.
With ``printer_id`` set, reads the per-printer ``[filament_profiles_<id>]``
section and falls back to the legacy global ``[filament_profiles]`` while
that printer has no own section yet.
Format per AMS slot - the primary selector is (vendor, name); the `id` is
looked up from orca_filaments.json on save and carried along
(as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing
the same filament_id like 'OGFL99', i.e. the ID is not unique):
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
[filament_profiles]
slot_0_vendor = Polymaker

30
eslint.config.js Normal file
View File

@ -0,0 +1,30 @@
import js from "@eslint/js";
import globals from "globals";
import prettier from "eslint-config-prettier";
export default [
{
ignores: ["node_modules/**", "data/**", "releases/**", "android/**", "web/translations/**"],
},
js.configs.recommended,
{
// Browser-side single-page app served by the bridge. Loaded as a classic
// (non-module) script, so all top-level functions/vars share one global scope.
files: ["web/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "script",
globals: {
...globals.browser,
},
},
rules: {
// Top-level functions/vars are intentional globals invoked from inline
// HTML handlers, so only flag unused *local* bindings, not globals.
"no-unused-vars": ["warn", { vars: "local", args: "none" }],
// Empty catch blocks are used intentionally to swallow non-critical errors.
"no-empty": ["error", { allowEmptyCatch: true }],
},
},
prettier,
];

View File

@ -1068,6 +1068,27 @@ class KobraXBridge:
self._camera_autostarted: bool = False
self._camera_user_stopped: bool = False # user manually stopped the camera during a print
self.camera_cache: CameraCache = CameraCache()
self._loop: "asyncio.AbstractEventLoop | None" = None
self._prev_kobra_state: str = ""
self._print_active: bool = False
self._notify_every_minutes: int = 0
self._notify_every_layers: int = 0
self._notifications_paused: bool = False
self._last_progress_notif_time: float = 0.0
self._last_progress_notif_layer: int = 0
try:
import config_loader as _cl2
self._notification_urls: list[dict] = _cl2.list_notification_urls()
_np = _cl2._find_config_file()
if _np:
import configparser as _cparser
_ncfg = _cparser.ConfigParser()
_ncfg.read(_np, encoding="utf-8")
self._notify_every_minutes = _ncfg.getint("notifications", "notify_every_minutes", fallback=0)
self._notify_every_layers = _ncfg.getint("notifications", "notify_every_layers", fallback=0)
self._notifications_paused = _ncfg.getboolean("notifications", "paused", fallback=False)
except Exception:
self._notification_urls = []
self._thumbnail_b64: str = ""
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
@ -1330,6 +1351,175 @@ class KobraXBridge:
out[key]["name"] = name or str(d.get("name", "Custom"))
return out
# -------------------------------------------------------------------------
# Notifications (apprise)
# -------------------------------------------------------------------------
async def _capture_notification_image(self, timeout: float = 15.0) -> bytes:
"""Liefert einen aktuellen JPEG-Frame für Bild-Benachrichtigungen.
Falls die Kamera nicht aktiv streamt, wird sie kurz eingeschaltet, ein
Frame abgegriffen und sofern wir sie selbst gestartet haben wieder
ausgeschaltet (toggle on → snapshot → toggle off).
"""
loop = asyncio.get_event_loop()
# 1) Bereits ein frischer Frame im Cache (z. B. WebUI schaut zu)? → direkt nutzen.
if self.camera_cache.latest_jpeg and (time.time() - self.camera_cache.latest_jpeg_ts) < 3.0:
return self.camera_cache.latest_jpeg
started_here = False
try:
# 2) Kamera am Drucker einschalten, falls sie nicht ohnehin läuft.
if not self._camera_autostarted:
try:
await loop.run_in_executor(None, self.client.start_camera)
started_here = True
log.debug("Kamera für Bild-Benachrichtigung temporär gestartet")
except Exception as e:
log.warning(f"Kamera-Start für Benachrichtigung fehlgeschlagen: {e}")
# rtspUrl aktualisieren (kommt im info/report mit, wenn Kamera streamt)
try:
info = await loop.run_in_executor(None, self.client.query_info)
if info:
self._on_info(info)
except Exception:
pass
url = self._state.get("camera_url", "")
if not url:
return b""
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
# 3) Auf einen frischen Frame warten (ffmpeg + RTSP-Warmup).
ref_ts = time.time()
deadline = ref_ts + timeout
while time.time() < deadline:
if self.camera_cache.latest_jpeg and self.camera_cache.latest_jpeg_ts >= ref_ts:
return self.camera_cache.latest_jpeg
await asyncio.sleep(0.3)
# Kein frischer Frame notfalls den letzten vorhandenen nehmen.
return self.camera_cache.latest_jpeg
finally:
# 4) Wieder ausschalten, wenn wir sie nur für den Snapshot gestartet
# haben (nicht während eines laufenden Drucks mit Auto-Kamera).
if started_here and not self._camera_autostarted:
try:
await loop.run_in_executor(None, self.client.stop_camera)
log.debug("Kamera nach Bild-Benachrichtigung wieder gestoppt")
except Exception as e:
log.debug(f"Kamera-Stop nach Benachrichtigung fehlgeschlagen: {e}")
def _notify(self, event: str, filename: str = ""):
if self._notifications_paused:
return
matching = [e for e in self._notification_urls
if e.get("enabled", True) and event in e.get("events", [])]
if not matching:
return
printer_name = self._state.get("printer_name", "KX-Bridge")
titles = {
"started": "Print Started ▶",
"finished": "Print Finished ✓",
"failed": "Print Failed ✗",
"cancelled": "Print Cancelled",
"paused": "Print Paused ⏸",
"progress": "Print Progress",
}
title = titles.get(event, "KX-Bridge")
if event == "progress":
pct = int(self._state.get("progress", 0) * 100)
curr = self._state.get("curr_layer", 0)
total = self._state.get("total_layers", 0)
rem = self._state.get("remain_time", 0)
rem_str = f"{rem // 3600}h {(rem % 3600) // 60}m" if rem else ""
parts = [f"{pct}%"]
if total:
parts.append(f"Layer {curr}/{total}")
if rem_str:
parts.append(f"Remaining: {rem_str}")
if filename:
parts.append(filename)
body = "".join(parts) if parts else printer_name
else:
body = filename if filename else printer_name
if filename and printer_name:
body = f"{filename}\n{printer_name}"
urls_plain = [e["url"] for e in matching if not e.get("include_image")]
urls_image = [e["url"] for e in matching if e.get("include_image")]
def _send():
import apprise, os, tempfile
try:
if urls_plain:
ap = apprise.Apprise()
for u in urls_plain:
ap.add(u)
ap.notify(title=title, body=body)
if urls_image:
ap2 = apprise.Apprise()
for u in urls_image:
ap2.add(u)
attach = None
tmppath = None
jpeg = b""
if self._loop is not None:
try:
fut = asyncio.run_coroutine_threadsafe(
self._capture_notification_image(timeout=15.0), self._loop)
jpeg = fut.result(timeout=30.0) or b""
except Exception as e:
log.warning(f"Kamera-Snapshot für Benachrichtigung fehlgeschlagen: {e}")
jpeg = self.camera_cache.latest_jpeg
else:
jpeg = self.camera_cache.latest_jpeg
if jpeg:
try:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(jpeg)
tmppath = f.name
attach = apprise.AppriseAttachment()
attach.add(tmppath)
except Exception as e:
log.warning(f"Kamera-Anhang fehlgeschlagen: {e}")
attach = None
else:
log.debug("Bild-Benachrichtigung ohne Anhang (kein Kamera-Frame verfügbar)")
ap2.notify(title=title, body=body, attach=attach)
if tmppath:
try:
os.unlink(tmppath)
except Exception:
pass
log.info(f"Benachrichtigung '{event}' gesendet ({len(matching)} URL(s))")
except Exception as e:
log.warning(f"Benachrichtigung fehlgeschlagen: {e}")
threading.Thread(target=_send, daemon=True).start()
def _check_progress_notifications(self):
if self._state.get("kobra_state") != "printing":
return
if not any("progress" in e.get("events", []) for e in self._notification_urls
if e.get("enabled", True)):
return
if self._notify_every_minutes == 0 and self._notify_every_layers == 0:
return
now = time.monotonic()
curr_layer = self._state.get("curr_layer", 0)
fired = False
if self._notify_every_minutes > 0:
if now - self._last_progress_notif_time >= self._notify_every_minutes * 60:
fired = True
if not fired and self._notify_every_layers > 0 and curr_layer > 0:
if curr_layer - self._last_progress_notif_layer >= self._notify_every_layers:
fired = True
if fired:
self._last_progress_notif_time = now
self._last_progress_notif_layer = curr_layer
self._notify("progress", self._state.get("filename", ""))
# -------------------------------------------------------------------------
# MQTT callbacks (called from reader thread)
# -------------------------------------------------------------------------
@ -1491,6 +1681,7 @@ class KobraXBridge:
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # release for the next print
self._print_active = False
if project:
if "filename" in project:
self._state["filename"] = project["filename"]
@ -4135,7 +4326,7 @@ class KobraXBridge:
except OSError:
raise web.HTTPNotFound()
if name == "app.js":
raw = raw.replace("'__VERSION__'", f"'{self._read_version()}'")
raw = raw.replace("__VERSION__", self._read_version())
return web.Response(
text=raw,
content_type=ctype,
@ -4882,9 +5073,13 @@ class KobraXBridge:
"verbose_http_log": getattr(self._args, "verbose_http_log", 0),
"filament_profiles": {str(k): v for k, v in self._filament_profiles.items()},
"visible_vendors": self._visible_vendors,
"ace_dry_presets": self._ace_dry_presets,
"ace_dry_presets": self._ace_dry_presets,
"spoolman_server": getattr(self._args, "spoolman_server", "") or "",
"spoolman_sync_rate": getattr(self._args, "spoolman_sync_rate", 0),
"notifications": self._notification_urls,
"notify_every_minutes": self._notify_every_minutes,
"notify_every_layers": self._notify_every_layers,
"notifications_paused": self._notifications_paused,
})
async def handle_api_settings_post(self, request):
@ -4953,6 +5148,42 @@ class KobraXBridge:
cfg.set("ace_dry_presets", f"{key}_name", str(val.get("name", key.replace("_", " ").title())))
self._ace_dry_presets = presets
# Benachrichtigungen speichern
if cfg.has_section("notifications"):
cfg.remove_section("notifications")
incoming_notifs = data.get("notifications")
if isinstance(incoming_notifs, list):
valid_events = {"started", "finished", "failed", "cancelled", "paused", "progress"}
entries = []
for entry in incoming_notifs:
if not isinstance(entry, dict):
continue
url = str(entry.get("url", "")).strip()
if not url:
continue
events = [e for e in entry.get("events", []) if e in valid_events]
include_image = bool(entry.get("include_image", False))
enabled = bool(entry.get("enabled", True))
entries.append({"url": url, "events": events, "include_image": include_image, "enabled": enabled})
cfg.add_section("notifications")
for i, entry in enumerate(entries, 1):
cfg.set("notifications", f"url_{i}", entry["url"])
cfg.set("notifications", f"events_{i}", ",".join(entry["events"]))
cfg.set("notifications", f"image_{i}", "true" if entry["include_image"] else "false")
cfg.set("notifications", f"enabled_{i}", "true" if entry["enabled"] else "false")
self._notification_urls = entries
elif not cfg.has_section("notifications"):
cfg.add_section("notifications")
notify_every_minutes = max(0, int(data.get("notify_every_minutes") or 0))
notify_every_layers = max(0, int(data.get("notify_every_layers") or 0))
cfg.set("notifications", "notify_every_minutes", str(notify_every_minutes))
cfg.set("notifications", "notify_every_layers", str(notify_every_layers))
self._notify_every_minutes = notify_every_minutes
self._notify_every_layers = notify_every_layers
if "notifications_paused" in data:
self._notifications_paused = bool(data.get("notifications_paused"))
cfg.set("notifications", "paused", "true" if self._notifications_paused else "false")
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n\n")
cfg.write(f)
@ -4962,6 +5193,59 @@ class KobraXBridge:
asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
return response
async def handle_api_notifications_pause(self, request):
"""Toggle or set the global notifications-paused flag without restarting."""
try:
data = await request.json()
except Exception:
data = {}
if isinstance(data, dict) and "paused" in data:
self._notifications_paused = bool(data.get("paused"))
else:
self._notifications_paused = not self._notifications_paused
try:
import configparser
config_path = self._find_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
cfg = configparser.ConfigParser()
if config_path.is_file():
cfg.read(config_path, encoding="utf-8")
if not cfg.has_section("notifications"):
cfg.add_section("notifications")
cfg.set("notifications", "paused", "true" if self._notifications_paused else "false")
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n\n")
cfg.write(f)
except Exception as e:
log.warning(f"Notif-Pause speichern fehlgeschlagen: {e}")
log.info(f"Benachrichtigungen {'pausiert' if self._notifications_paused else 'aktiv'}")
return self._json_cors({"status": "ok", "paused": self._notifications_paused})
async def handle_api_notifications_test(self, request):
try:
data = await request.json()
except Exception:
return self._json_cors({"status": "error", "message": "invalid json"}, status=400)
url = str(data.get("url", "")).strip()
if not url:
return self._json_cors({"status": "error", "message": "url required"}, status=400)
printer_name = self._state.get("printer_name", "KX-Bridge")
try:
import apprise
ap = apprise.Apprise()
ap.add(url)
ok = await asyncio.get_event_loop().run_in_executor(
None,
lambda: ap.notify(title="KX-Bridge Test", body=f"Test notification from {printer_name}"),
)
if ok is False:
return self._json_cors({"status": "error", "message": "Notification failed (check URL)"})
return self._json_cors({"status": "ok"})
except ImportError:
return self._json_cors({"status": "error", "message": "apprise not installed"}, status=500)
except Exception as e:
return self._json_cors({"status": "error", "message": str(e)}, status=500)
async def handle_kx_printer_add(self, request):
"""Adds a printer: fetches credentials via IP, writes [printer_N], restarts."""
try:
@ -5722,6 +6006,7 @@ class KobraXBridge:
if now - self._spoolman_last_sync >= self._spoolman.sync_rate:
self._spoolman_sync_midprint()
self._spoolman_last_sync = now
self._check_progress_notifications()
box = self.client.query_multicolor_box()
if box:
data = box.get("data") or {}
@ -5850,6 +6135,8 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/api/state", bridge.handle_api_state)
r.add_get("/api/settings", bridge.handle_api_settings_get)
r.add_post("/api/settings", bridge.handle_api_settings_post)
r.add_post("/api/notifications/test", bridge.handle_api_notifications_test)
r.add_post("/api/notifications/pause", bridge.handle_api_notifications_pause)
r.add_get("/api/update/check", bridge.handle_api_update_check)
r.add_post("/api/update/apply", bridge.handle_api_update_apply)
r.add_post("/api/file_ready/clear", bridge.handle_api_file_ready_clear)
@ -5964,6 +6251,7 @@ async def run_bridge(args):
client, args=per_args, store=store,
printer_id=pid, all_bridges=all_bridges,
)
bridge._loop = loop
# Adopt printer_name from config.ini if set
if p.get("name"):
bridge._state["printer_name"] = p["name"]

View File

@ -16,6 +16,12 @@ datas += _d
binaries += _b
hiddenimports += _h
# apprise — alle Notification-Plugins (dynamisch geladen via entry_points)
_d, _b, _h = collect_all("apprise")
datas += _d
binaries += _b
hiddenimports += _h
a = Analysis(
["kobrax_moonraker_bridge.py"],
pathex=[],

1158
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "kx-bridge",
"version": "0.9.0",
"description": "Moonraker-compatible bridge for Anycubic Kobra X",
"type": "module",
"devDependencies": {
"@eslint/js": "^9.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"globals": "^15.0.0",
"prettier": "^3.0.0",
"prettier-plugin-sort-json": "^4.2.0"
},
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format:js": "prettier --write \"web/**/*.js\" \"*.js\"",
"format:json": "prettier --write \"*.json\" \"web/**/*.json\"",
"format:web": "prettier --write \"web/**/*.{js,json,html,css,yml,yaml,md}\"",
"format": "npm run format:web"
}
}

2
requirements-dev.txt Normal file
View File

@ -0,0 +1,2 @@
# Code formatting
black>=24.1.0

View File

@ -2,3 +2,4 @@ aiohttp>=3.9
imageio-ffmpeg>=0.4.9
requests>=2.30.0
pycryptodome>=3.20.0
apprise>=1.8

View File

@ -2,135 +2,135 @@
Referenzliste für CSS-/Layout-Anpassungen.
| ID | Verwendung |
|---|---|
| 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 |
| `#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

@ -2,89 +2,89 @@
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 |
| 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -91,7 +91,6 @@
"fd_print": "▶ Drucken",
"fd_slot": "Slot",
"fd_slots_hint": "GCode-Kanal → AMS-Slot zuweisen:",
"fd_title": "Slot-Zuweisung",
"fd_used": "BELEGT",
"file_cancel_btn": "✕ Abbrechen",
"file_ready_btn": "▶ Druck starten",
@ -184,6 +183,9 @@
"nav_print": "Druck",
"nav_printers": "Drucker",
"nav_settings": "Einstellungen",
"obico_config_file_label": "Config-Datei:",
"obico_info_configured_via": "Obico wird über den <code>moonraker-obico</code>-Container konfiguriert.",
"panel_motion_z": "Z-Achse",
"nav_temps": "Temperaturen",
"orca_profile_done": "Importiert",
"orca_profile_dropmsg": "Hierher ziehen oder klicken",
@ -237,6 +239,7 @@
"settings_cat_display": "Darstellung",
"settings_cat_filament": "Filament",
"settings_cat_language": "Sprache",
"settings_cat_notifications": "Benachrichtigungen",
"settings_cat_printer": "Drucker",
"settings_cat_system": "System",
"settings_cat_theme": "Hell / Dunkel umschalten",
@ -249,6 +252,26 @@
"settings_filament_mapping_hint": "Festes Orca-Profil pro AMS-Slot. Bei der Slicer-Synchronisierung sendet die Bridge dieses Profil statt \"Generic\".",
"settings_filament_mapping_label": "Filament-Profil-Mapping (pro Slot)",
"settings_filament_mapping_save": "Mapping speichern",
"settings_notif_active": "Aktiv",
"settings_notif_add": "Benachrichtigung hinzufügen",
"settings_notif_empty": "Keine Benachrichtigungen konfiguriert.",
"settings_notif_ev_cancelled": "Abgebrochen",
"settings_notif_ev_failed": "Fehler",
"settings_notif_ev_finished": "Fertig",
"settings_notif_ev_paused": "Pausiert",
"settings_notif_ev_progress": "Fortschritt",
"settings_notif_ev_started": "Gestartet",
"settings_notif_interval_lbl": "Wiederholungsintervall",
"settings_notif_layers_unit": "Schichten",
"settings_notif_min_unit": "Min",
"settings_notif_paused": "Pausiert",
"settings_notif_send_image": "Bild",
"settings_notif_test": "Test",
"settings_notif_test_fail": "Fehler",
"settings_notif_test_ok": "Gesendet",
"settings_notif_zero_off": "(0 = aus)",
"settings_notifications": "Benachrichtigungen",
"settings_notifications_hint": "Sende Benachrichtigungen über <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">Apprise-URLs</a> (discord://, telegram://, gotify://, slack://, …)",
"settings_filament_mapping_save_label": "Mapping speichern",
"settings_file_ready_banner": "Druckleiste",
"settings_file_ready_dialog": "Druckdialog",
@ -282,7 +305,6 @@
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
"settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_save": "Auswahl speichern",
"settings_visible_vendors_save_label": "Auswahl speichern",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"sf_all": "Alle",
"sf_err": "✗ Fehler",
@ -314,6 +336,8 @@
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Leise",
"speed_sport": "🚀 Sport",
"spoolman_status_configured": "verbunden",
"spoolman_status_not_configured": "nicht konfiguriert",
"ss_date": "↓ Datum",
"ss_dur": "⏱ Druckzeit",
"ss_name": "AZ Name",

View File

@ -25,8 +25,8 @@
"ace_dry_status_off": "Status: Off",
"ace_dry_status_on": "Status: Active",
"ace_dry_status_remaining": "Remaining",
"ace_dry_stop": "■ Stop",
"ace_dry_temp": "Temperature (°C)",
"ace_dry_stop": "■ Stop",
"ace_dry_temp_line": "Drying Temperature",
"ace_dry_time_line": "Drying Time",
"ace_dry_ui_pending": "(UI only, backend next)",
@ -45,7 +45,6 @@
"browser_tab_printer": "On Printer",
"browser_tab_uploaded": "Uploaded",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Camera",
"btn_cam_stop2": "◼ Stop",
"btn_cancel": "✕ Stop",
@ -91,7 +90,6 @@
"fd_print": "▶ Print",
"fd_slot": "Slot",
"fd_slots_hint": "Assign GCode channel to AMS slot:",
"fd_title": "Slot Assignment",
"fd_used": "USED",
"file_cancel_btn": "✕ Cancel",
"file_ready_btn": "▶ Start Print",
@ -185,6 +183,8 @@
"nav_printers": "Printers",
"nav_settings": "Settings",
"nav_temps": "Temperatures",
"obico_config_file_label": "Config file:",
"obico_info_configured_via": "Obico is configured via the <code>moonraker-obico</code> container.",
"orca_profile_done": "Imported",
"orca_profile_dropmsg": "Drop here or click",
"orca_profile_help_html": "Upload a <b>ZIP</b> of your OrcaSlicer filament folder or single <b>.json</b> files.<br>In OrcaSlicer: <i>Help → Show Configuration Folder → user/&lt;id&gt;/filament/</i>",
@ -204,7 +204,8 @@
"panel_extras_camera": "Camera",
"panel_extras_fan": "Fan",
"panel_extras_light": "Light",
"panel_motion_xy": "Axes Control",
"panel_motion_xy": "XY Axes",
"panel_motion_z": "Z Axis",
"panel_print_btn_cancel": "✕ Cancel",
"panel_print_btn_pause": "⏸ Pause",
"panel_print_btn_resume": "▶ Resume",
@ -236,10 +237,9 @@
"settings_cat_connection": "Connection",
"settings_cat_display": "Appearance",
"settings_cat_filament": "Filament",
"settings_cat_language": "Language",
"settings_cat_notifications": "Notifications",
"settings_cat_printer": "Printer",
"settings_cat_system": "System",
"settings_cat_theme": "Toggle light / dark",
"settings_connection": "Connection",
"settings_default_slot": "Default Slot (single color)",
"settings_device_id": "Device ID",
@ -259,6 +259,26 @@
"settings_mode_id_placeholder": "20030",
"settings_mqtt_port": "MQTT Port",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_notif_active": "Active",
"settings_notif_add": "Add notification",
"settings_notif_empty": "No notifications configured.",
"settings_notif_ev_cancelled": "Cancelled",
"settings_notif_ev_failed": "Failed",
"settings_notif_ev_finished": "Finished",
"settings_notif_ev_paused": "Paused",
"settings_notif_ev_progress": "Progress",
"settings_notif_ev_started": "Started",
"settings_notif_interval_lbl": "Repeat interval",
"settings_notif_layers_unit": "layers",
"settings_notif_min_unit": "min",
"settings_notif_paused": "Paused",
"settings_notif_send_image": "Image",
"settings_notif_test": "Test",
"settings_notif_test_fail": "Failed",
"settings_notif_test_ok": "Sent",
"settings_notif_zero_off": "(0 = off)",
"settings_notifications": "Notifications",
"settings_notifications_hint": "Send notifications via <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">Apprise URLs</a> (discord://, telegram://, gotify://, slack://, …)",
"settings_orca_profiles_import": "Import profiles",
"settings_orca_profiles_label": "OrcaSlicer Profiles",
"settings_password": "MQTT Password",
@ -292,9 +312,6 @@
"skip_btn_label": "Objects",
"skip_cancel": "Cancel",
"skip_confirm": "Skip",
"skip_confirm_btn": "Skip",
"skip_hint": "Uncheck objects you no longer want to print:",
"skip_no_objects": "No objects in this print.",
"skip_select_at_least_one": "Please pick at least one object.",
"skip_sending": "Sending …",
"skip_success": "Objects will be skipped.",
@ -314,6 +331,8 @@
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Silent",
"speed_sport": "🚀 Sport",
"spoolman_status_configured": "connected",
"spoolman_status_not_configured": "not configured",
"ss_date": "↓ Date",
"ss_dur": "⏱ Print time",
"ss_name": "AZ Name",

View File

@ -91,7 +91,6 @@
"fd_print": "▶ Imprimir",
"fd_slot": "Ranura",
"fd_slots_hint": "Asignar canal GCode a la ranura AMS:",
"fd_title": "Asignación de ranura",
"fd_used": "USADO",
"file_cancel_btn": "✕ Cancelar",
"file_ready_btn": "▶ Iniciar impresión",
@ -184,6 +183,9 @@
"nav_print": "Impresión",
"nav_printers": "Impresoras",
"nav_settings": "Ajustes",
"obico_config_file_label": "Archivo de configuración:",
"obico_info_configured_via": "Obico se configura mediante el contenedor <code>moonraker-obico</code>.",
"panel_motion_z": "Eje Z",
"nav_temps": "Temperaturas",
"orca_profile_done": "Importado",
"orca_profile_dropmsg": "Suelta aquí o haz clic",
@ -237,6 +239,7 @@
"settings_cat_display": "Apariencia",
"settings_cat_filament": "Filamento",
"settings_cat_language": "Idioma",
"settings_cat_notifications": "Notificaciones",
"settings_cat_printer": "Impresora",
"settings_cat_system": "Sistema",
"settings_cat_theme": "Alternar claro / oscuro",
@ -249,6 +252,26 @@
"settings_filament_mapping_hint": "Perfil Orca fijo por ranura AMS. Al sincronizar con el slicer, el bridge envía este perfil en lugar de \"Generic\".",
"settings_filament_mapping_label": "Asignación de perfil de filamento (por ranura)",
"settings_filament_mapping_save": "Guardar asignación",
"settings_notif_active": "Activo",
"settings_notif_add": "Agregar notificación",
"settings_notif_empty": "Sin notificaciones configuradas.",
"settings_notif_ev_cancelled": "Cancelado",
"settings_notif_ev_failed": "Error",
"settings_notif_ev_finished": "Finalizado",
"settings_notif_ev_paused": "Pausado",
"settings_notif_ev_progress": "Progreso",
"settings_notif_ev_started": "Iniciado",
"settings_notif_interval_lbl": "Intervalo de repetición",
"settings_notif_layers_unit": "capas",
"settings_notif_min_unit": "min",
"settings_notif_paused": "Pausado",
"settings_notif_send_image": "Imagen",
"settings_notif_test": "Prueba",
"settings_notif_test_fail": "Error",
"settings_notif_test_ok": "Enviado",
"settings_notif_zero_off": "(0 = desactivado)",
"settings_notifications": "Notificaciones",
"settings_notifications_hint": "Envía notificaciones a través de <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">URLs de Apprise</a> (discord://, telegram://, gotify://, slack://, …)",
"settings_filament_mapping_save_label": "Guardar asignación",
"settings_file_ready_banner": "Barra de impresión",
"settings_file_ready_dialog": "Diálogo de impresión",
@ -282,7 +305,6 @@
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
"settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_save": "Guardar selección",
"settings_visible_vendors_save_label": "Guardar selección",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"sf_all": "Todos",
"sf_err": "✗ Fallido",
@ -314,6 +336,8 @@
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Silencioso",
"speed_sport": "🚀 Sport",
"spoolman_status_configured": "conectado",
"spoolman_status_not_configured": "no configurado",
"ss_date": "↓ Fecha",
"ss_dur": "⏱ Tiempo de impresión",
"ss_name": "AZ Nombre",

View File

@ -172,6 +172,9 @@
"nav_print": "Impression",
"nav_printers": "Imprimantes",
"nav_settings": "Paramètres",
"obico_config_file_label": "Fichier de config :",
"obico_info_configured_via": "Obico se configure via le conteneur <code>moonraker-obico</code>.",
"panel_motion_z": "Axe Z",
"nav_temps": "Températures",
"orca_profile_done": "Importé",
"orca_profile_dropmsg": "Déposez ici ou cliquez",
@ -225,6 +228,7 @@
"settings_cat_display": "Apparence",
"settings_cat_filament": "Filament",
"settings_cat_language": "Langue",
"settings_cat_notifications": "Notifications",
"settings_cat_printer": "Imprimante",
"settings_cat_system": "Système",
"settings_cat_theme": "Basculer clair / sombre",
@ -237,6 +241,26 @@
"settings_filament_mapping_hint": "Profil Orca fixe par emplacement AMS. Lors de la synchronisation du slicer, le bridge envoie ce profil au lieu de « Generic ».",
"settings_filament_mapping_label": "Mappage du profil de filament (par emplacement)",
"settings_filament_mapping_save": "Enregistrer le mappage",
"settings_notif_active": "Actif",
"settings_notif_add": "Ajouter une notification",
"settings_notif_empty": "Aucune notification configurée.",
"settings_notif_ev_cancelled": "Annulé",
"settings_notif_ev_failed": "Erreur",
"settings_notif_ev_finished": "Terminé",
"settings_notif_ev_paused": "Pausé",
"settings_notif_ev_progress": "Progression",
"settings_notif_ev_started": "Démarré",
"settings_notif_interval_lbl": "Intervalle de répétition",
"settings_notif_layers_unit": "couches",
"settings_notif_min_unit": "min",
"settings_notif_paused": "En pause",
"settings_notif_send_image": "Image",
"settings_notif_test": "Test",
"settings_notif_test_fail": "Erreur",
"settings_notif_test_ok": "Envoyé",
"settings_notif_zero_off": "(0 = désactivé)",
"settings_notifications": "Notifications",
"settings_notifications_hint": "Envoyer des notifications via des <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">URLs Apprise</a> (discord://, telegram://, gotify://, slack://, …)",
"settings_filament_mapping_save_label": "Enregistrer le mappage",
"settings_file_ready_banner": "Barre d'impression",
"settings_file_ready_dialog": "Dialogue d'impression",
@ -268,7 +292,6 @@
"settings_visible_vendors_hint": "Seuls ces fabricants apparaissent dans la liste des profils d'emplacement. Rien de sélectionné = tout afficher. « Generic » et vos propres profils sont toujours visibles.",
"settings_visible_vendors_label": "Fabricants visibles (liste des profils)",
"settings_visible_vendors_save": "Enregistrer la sélection",
"settings_visible_vendors_save_label": "Enregistrer la sélection",
"settings_web_upload_warning": "Afficher un avertissement lors de l'impression de fichiers web",
"sf_all": "Tout",
"sf_err": "✗ Échoués",
@ -300,6 +323,8 @@
"speed_normal": "⚡ Normal",
"speed_silent": "🐢 Silencieux",
"speed_sport": "🚀 Sport",
"spoolman_status_configured": "connecté",
"spoolman_status_not_configured": "non configuré",
"ss_date": "↓ Date",
"ss_dur": "⏱ Durée d'impression",
"ss_name": "AZ Nom",

View File

@ -172,6 +172,9 @@
"nav_print": "Stampa",
"nav_printers": "Stampanti",
"nav_settings": "Impostazioni",
"obico_config_file_label": "File di configurazione:",
"obico_info_configured_via": "Obico viene configurato tramite il container <code>moonraker-obico</code>.",
"panel_motion_z": "Asse Z",
"nav_temps": "Temperature",
"orca_profile_done": "Importato",
"orca_profile_dropmsg": "Trascina qui o fai clic",
@ -225,7 +228,7 @@
"settings_cat_display": "Aspetto",
"settings_cat_filament": "Filamento",
"settings_cat_language": "Lingua",
"settings_cat_printer": "Stampante",
"settings_cat_notifications": "Notifiche",
"settings_cat_system": "Sistema",
"settings_cat_theme": "Alterna chiaro / scuro",
"settings_connection": "Connessione",
@ -237,6 +240,26 @@
"settings_filament_mapping_hint": "Profilo Orca fisso per slot AMS. Durante la sincronizzazione dello slicer, il bridge invia questo profilo al posto di \"Generic\".",
"settings_filament_mapping_label": "Mappatura profilo filamento (per slot)",
"settings_filament_mapping_save": "Salva mappatura",
"settings_notif_active": "Attivo",
"settings_notif_add": "Aggiungi notifica",
"settings_notif_empty": "Nessuna notifica configurata.",
"settings_notif_ev_cancelled": "Annullato",
"settings_notif_ev_failed": "Errore",
"settings_notif_ev_finished": "Completato",
"settings_notif_ev_paused": "In pausa",
"settings_notif_ev_progress": "Progresso",
"settings_notif_ev_started": "Iniziato",
"settings_notif_interval_lbl": "Intervallo di ripetizione",
"settings_notif_layers_unit": "strati",
"settings_notif_min_unit": "min",
"settings_notif_paused": "In pausa",
"settings_notif_send_image": "Immagine",
"settings_notif_test": "Test",
"settings_notif_test_fail": "Errore",
"settings_notif_test_ok": "Inviato",
"settings_notif_zero_off": "(0 = disabilitato)",
"settings_notifications": "Notifiche",
"settings_notifications_hint": "Invia notifiche tramite <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">URL Apprise</a> (discord://, telegram://, gotify://, slack://, …)",
"settings_filament_mapping_save_label": "Salva mappatura",
"settings_file_ready_banner": "Barra di stampa",
"settings_file_ready_dialog": "Finestra di dialogo stampa",
@ -268,7 +291,6 @@
"settings_visible_vendors_hint": "Solo questi produttori appariranno nel menu del profilo dello slot. Se non selezioni nulla = mostra tutti. I profili \"Generici\" e i tuoi personali sono sempre visibili.",
"settings_visible_vendors_label": "Produttori visibili (menu del profilo)",
"settings_visible_vendors_save": "Salva selezione",
"settings_visible_vendors_save_label": "Salva selezione",
"settings_web_upload_warning": "Mostra un avviso quando si stampano caricamenti web",
"sf_all": "Tutti",
"sf_err": "✗ Fallito",
@ -300,6 +322,8 @@
"speed_normal": "⚡ Normale",
"speed_silent": "🐢 Silenzioso",
"speed_sport": "🚀 Sport",
"spoolman_status_configured": "connesso",
"spoolman_status_not_configured": "non configurato",
"ss_date": "↓ Data",
"ss_dur": "⏱ Tempo di stampa",
"ss_name": "Nome AZ",

View File

@ -91,7 +91,6 @@
"fd_print": "▶ 打印",
"fd_slot": "槽位",
"fd_slots_hint": "将 GCode 通道分配到 AMS 槽位:",
"fd_title": "槽位分配",
"fd_used": "已用",
"file_cancel_btn": "✕ 取消",
"file_ready_btn": "▶ 开始打印",
@ -184,6 +183,9 @@
"nav_print": "打印",
"nav_printers": "打印机",
"nav_settings": "设置",
"obico_config_file_label": "配置文件:",
"obico_info_configured_via": "Obico 通过 <code>moonraker-obico</code> 容器进行配置。",
"panel_motion_z": "Z 轴",
"nav_temps": "温度",
"orca_profile_done": "已导入",
"orca_profile_dropmsg": "拖到此处或点击",
@ -237,6 +239,7 @@
"settings_cat_display": "外观",
"settings_cat_filament": "耗材",
"settings_cat_language": "语言",
"settings_cat_notifications": "通知",
"settings_cat_printer": "打印机",
"settings_cat_system": "系统",
"settings_cat_theme": "切换浅色 / 深色",
@ -249,6 +252,26 @@
"settings_filament_mapping_hint": "每个 AMS 槽位的固定 Orca 配置。在切片器同步时Bridge 会发送此配置而不是“Generic”。",
"settings_filament_mapping_label": "耗材配置映射(每槽位)",
"settings_filament_mapping_save": "保存映射",
"settings_notif_active": "已启用",
"settings_notif_add": "添加通知",
"settings_notif_empty": "未配置任何通知。",
"settings_notif_ev_cancelled": "已取消",
"settings_notif_ev_failed": "失败",
"settings_notif_ev_finished": "已完成",
"settings_notif_ev_paused": "已暂停",
"settings_notif_ev_progress": "进度",
"settings_notif_ev_started": "已开始",
"settings_notif_interval_lbl": "重复间隔",
"settings_notif_layers_unit": "层",
"settings_notif_min_unit": "分钟",
"settings_notif_paused": "已暂停",
"settings_notif_send_image": "图像",
"settings_notif_test": "测试",
"settings_notif_test_fail": "失败",
"settings_notif_test_ok": "已发送",
"settings_notif_zero_off": "(0 = 关闭)",
"settings_notifications": "通知",
"settings_notifications_hint": "通过 <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">Apprise URL</a> 发送通知 (discord://, telegram://, gotify://, slack://, …)",
"settings_filament_mapping_save_label": "保存映射",
"settings_file_ready_banner": "打印栏",
"settings_file_ready_dialog": "打印对话框",
@ -282,7 +305,6 @@
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
"settings_visible_vendors_label": "可见厂商(配置下拉框)",
"settings_visible_vendors_save": "保存选择",
"settings_visible_vendors_save_label": "保存选择",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"sf_all": "全部",
"sf_err": "✗ 失败",
@ -314,6 +336,8 @@
"speed_normal": "⚡ 标准",
"speed_silent": "🐢 静音",
"speed_sport": "🚀 运动",
"spoolman_status_configured": "已连接",
"spoolman_status_not_configured": "未配置",
"ss_date": "↓ 日期",
"ss_dur": "⏱ 打印时间",
"ss_name": "AZ 名称",