Compare commits

..

26 Commits

Author SHA1 Message Date
99bc1797c8 fix(camera): share one ffmpeg connection for /api/camera/stream
All checks were successful
Nightly Build / build (push) Successful in 9m34s
Manually integrated from PR #91 (Pavulon87) - the PR branch itself had
unresolved git conflict markers in handle_camera_stream from a stale
rebase against nightly, so it couldn't be merged directly.

/api/camera/stream previously spawned a dedicated ffmpeg process and
printer connection per HTTP client. The printer only tolerates a very
limited number of concurrent camera connections, so two simultaneous
viewers (dashboard + a Moonraker client, two browser tabs, ...) could
already exhaust that limit and cause intermittent 429/"stream
unavailable" failures.

Adds a third CameraCache fanout channel (mjpeg_subscribers), mirroring
the existing h264 pattern, so all /api/camera/stream consumers share one
ffmpeg process. Also carries over two related fixes found in the PR:
ensure_running() checked self._proc_* instead of self._task_*, leaving a
race window where two callers could each spawn a duplicate ffmpeg
process before the first task got scheduled; and reset() didn't cancel
the owning task, so a loop stuck in its exponential backoff sleep (up to
300s) wouldn't restart immediately.
2026-07-16 22:33:50 +02:00
e1b9480098 Merge branch 'pr92' into nightly
Some checks failed
Nightly Build / build (push) Has been cancelled
2026-07-16 21:37:09 +02:00
Pavulon87
03089db6af fix: self._db did not exist, use self._store
Some checks failed
Nightly Build / build (push) Has been cancelled
PR Check / lint-and-test (pull_request) Has been cancelled
2026-07-16 14:48:23 +02:00
Pavulon87
cd4a8ce48e feat(dashboard): show printer pause reason on the dashboard
Some checks failed
PR Check / lint-and-test (pull_request) Failing after 2s
2026-07-16 14:42:48 +02:00
bf3f043888 chore: add missing PR #88 entry to nightly changelog
The stale-slot-profile-guard fix (walterioo, merged as bf94a8f) was never
mentioned in a nightly release body — it landed between two changelog
overwrites.
2026-07-16 11:36:09 +02:00
b9594d4a22 feat(dashboard): free drag+resize grid with custom presets (Issue #89)
All checks were successful
Nightly Build / build (push) Successful in 13m35s
Replace the fixed dashboard layout with a fully customizable GridStack.js
grid (12-col snap grid, vendored + inlined so it also works in OrcaSlicer's
embedded webview). Cards can be dragged, resized, hidden and rearranged;
layout persists per browser. Includes two built-in presets (Standard,
Wide desktop per the original Issue #89 proposal) plus the ability to
save/apply/delete named custom presets.

fix(camera): stream freeze after ~15-30min from non-monotonic FLV
timestamps — ffmpeg's realtime pacing stalls on PTS jumps in the printer's
stream. Fixed with -use_wallclock_as_timestamps on both ffmpeg call sites
(CameraCache._input_args, _run_h264_loop). Issue #90.

test: fix tests/conftest.py referencing a stale bridge/ subfolder path and
missing args.data_dir (pre-existing breakage, unrelated to this feature);
rewrite the two test_settings.py cases that still mocked the removed
_find_env_path from the old .env-based settings storage.
2026-07-16 11:34:34 +02:00
Walter Almada B
bf94a8f563 fix(filament): suppress stale slot profile when AMS material family changes
Some checks failed
PR Check / lint-and-test (pull_request) Has been cancelled
Nightly Build / build (push) Successful in 14m49s
A per-slot filament profile override (config.ini [filament_profiles]) stores
only {vendor, name, id} and is sticky: swapping the physical filament updates
the AMS colour + type live, but the saved profile persisted. Loading yellow PLA
into a slot that held "KINGROON PETG Basic" kept showing/sending PETG in the
panel and the OrcaSlicer lane hint, and survived restarts (config.ini).

Resolve the effective profile per slot as the saved override only when its
material *family* still matches the loaded AMS material — PLA / PLA+ / PLA SILK /
PLA MATTE are one family, so within-family swaps never invalidate a valid
profile (guards against the earlier over-strict material compare). On a family
change the override is suppressed (slot falls back to the generic default) but
NOT deleted, so re-loading the original material reactivates it. Profile
material is resolved from the Orca filament library; unknown → never suppress.

Wired into the three resolution sites: handle_kx_filament_slots (panel),
_build_lane_data (OrcaSlicer AMS array), _build_mmu_object (gate_filament_name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:09:16 -07:00
eda14db897 feat: pull image from registry instead of local build, ask stable/nightly
All checks were successful
Nightly Build / build (push) Successful in 13m34s
start.sh now pulls gitea.it-drui.de/viewit/kx-bridge:latest or :nightly
depending on user choice (interactive prompt or ./start.sh <channel>
argument), instead of building the image locally from source.
2026-07-07 15:11:19 +02:00
dcf852db49 chore: update nightly changelog
All checks were successful
Nightly Build / build (push) Successful in 9m59s
2026-07-07 01:26:29 +02:00
d2c92c2deb fix(settings): stale env vars survived bridge restart, reverting saved changes
_restart_bridge() cleared a hardcoded, manually-maintained list of env
keys before restarting — every setting added since (vibration_compensation,
host_ip, poll_interval, verbose_http_log) was missing from it. The old
process's env var therefore survived into the new process and silently
overrode the freshly written config.ini value, making toggles appear to
revert right after saving.

Fixed at the root: config_loader.CONFIG_ENV_MAPPING is now the single
source of truth for which env keys back which config.ini options, and
_restart_bridge() derives its cleanup list from it. A newly added
setting can't be forgotten here again.
2026-07-07 01:23:01 +02:00
b541aafc74 feat(settings): make the HTTP access log toggleable in the UI
Previous commit hard-disabled aiohttp's per-request access log via
setLevel(WARNING), with no way to turn it back on. Added a
'verbose_http_log' setting (default off) — toggle in Settings, persisted
to config.ini, applied on bridge start via _set_verbose_http_log().
2026-07-07 01:20:34 +02:00
f2f4447809 chore: quiet aiohttp per-request access log
Every HTTP request logged an INFO line via aiohttp's access logger,
drowning out the bridge's own logs given the frontend's 2s poll
interval. Raised the aiohttp.access logger threshold to WARNING.
2026-07-07 01:12:30 +02:00
a3deb33b97 fix(spoolman): status dot showed green when Spoolman was unreachable
/kx/spoolman/status only exposed 'configured' (server URL is set),
not actual reachability — health_check() ran once at boot and was
only logged, never surfaced to the API or rechecked afterwards.

Now the poll loop rechecks reachability every 30s, the status endpoint
returns 'reachable', and the frontend dot shows red + '(unreachable)'
when Spoolman is configured but not responding.
2026-07-07 01:08:41 +02:00
2e2061a269 fix(settings): poll_interval was saved but never applied
The setting was persisted to config.ini and returned by /api/settings,
but --poll-interval was never registered as an argparse argument and
POLL_INTERVAL was missing from config_loader's env mapping, so
self._args.poll_interval never existed. The poll loop used a hardcoded
3.0s wait regardless of the configured value.
2026-07-07 01:04:44 +02:00
e4bf2c9b95 chore: nightly changelog
All checks were successful
Nightly Build / build (push) Successful in 16m2s
2026-07-07 00:13:34 +02:00
aea6e457f3 refactor: dedupe upload response, log user-relevant swallowed errors
- OctoPrint upload response was built twice in handle_file_upload
- gcode_filaments cache load failure on dashboard reprint now logs a
  warning (silent failure would degrade the Issue #84 fix unnoticed)
- filament metadata backfill failures now log at debug level
2026-07-06 22:39:09 +02:00
0ae8ae59be refactor: extract shared print payload builder and skip-state reset
The print/start payload was duplicated in three places (upload path,
KX store, Moonraker API) and had drifted: the settings-based
vibration_compensation value was missing in the upload path. All three
paths now use _build_print_payload() and _reset_skip_state().
2026-07-06 22:35:56 +02:00
0322ade606 refactor: translate all logs, comments and API error strings to English
Logs are user-facing across all locales; comments and docstrings switch
to English for external contributors. No behavior change.
2026-07-06 22:33:25 +02:00
786fa08ca0 fix: dashboard reprint slot-shift, startup IP log, resonance compensation toggle
Some checks failed
Nightly Build / build (push) Failing after 1m5s
- Dashboard reprint now delegates to _start_print with gcode_filaments from DB
  so the used_paint_indices filter applies correctly (Issue #84)
- Startup log no longer shows 0.0.0.0 — actual LAN IP is displayed (Issue #86)
- New vibration_compensation setting: toggle in Settings UI activates resonance
  compensation before each print, follows exact auto_leveling pattern (Issue #85)
2026-07-06 13:54:44 +02:00
cd11542352 fix(filament): PLA-Varianten (PLA+, Silk, Matte) korrekt erkennen und an OrcaSlicer übermitteln
All checks were successful
Nightly Build / build (push) Successful in 12m36s
- _normalize_material() normalisiert Drucker-Typen auf kanonische Keys
- _TRAY_INFO_IDX erweitert um Silk/Matte/CF-Varianten und Schreibweisen
- _default_filament_name() mappt Varianten auf korrekte Generic-Profile
- Filament-Dropdown zeigt Hersteller-Profile der jeweiligen Variante
- Material-Buttons: PLA+, PLA Silk, PLA Matte hinzugefügt

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

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

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

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

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

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

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

Fixes #78
2026-06-30 22:56:34 +02:00
25 changed files with 2172 additions and 1030 deletions

View File

@@ -3,6 +3,17 @@
## [Unreleased]
### Fixed
- **Slot kept showing/printing a stale filament type after a spool swap.** The
per-slot profile override (config.ini `[filament_profiles]`) stores only
vendor+name and was sticky: swapping the physical filament updated the AMS
colour and type live, but the saved profile persisted, so a slot that held
e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the
OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts.
The override is now applied only while its material *family* still matches the
loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so
within-family swaps never invalidate a valid profile). On a family change the
slot falls back to the generic default; the override is not deleted, so
reloading the original material reactivates it.
- **Filament profiles not isolated between printers in a multi-printer bridge**
(issue #74). The slot→profile mapping and `visible_vendors` were stored in a
single global `[filament_profiles]` section, so configuring one printer

View File

@@ -1,11 +1,5 @@
## Changes in this build
- Unified axes control panel: XY and Z merged into one card, shared step size selector (0.1 / 1 / 5 / 10 mm) plus custom mm input field, Home XY/Z buttons placed directly below their respective pads
- Language selector moved from header bar to Settings → Appearance
- Filament mismatch detection: Upload-and-Print is intercepted when GCode material differs from the loaded AMS slot — slot mapper dialog opens automatically to correct the assignment before printing
- Spoolman: assign a spool per AMS slot directly in the AMS status tab (dropdown per slot tile) and in the Filaments settings tab (dedicated assignment card)
- Fix: filament profiles now isolated per printer in multi-printer setups — configuring one printer no longer overwrites the other (PR #75 by @walterioo)
- Fix: printer dropdown and switch link now navigate to each printer's own bridge URL (same-origin, no cross-instance profile bleed)
- Fix: Spoolman sync rate label corrected — 0 means sync at end of print, not disabled (Issue #76)
- Slot color editor: Pickr color picker (HSV wheel + hex input), recent color swatches (up to 16, saved in browser), and "Copy color from slot" dropdown for identical backup spool setup (Issue #73)
- UI: unified dropdown and input field styling across all settings panels
- Fix: dashboard reprint from the cache path crashed with an AttributeError (`self._db` did not exist) instead of applying the filament slot filter (PR #93, thanks @Pavulon87)
- Feat: the dashboard now shows a banner with the reason and error code when a print is paused, with a direct link to the Anycubic error-code docs (PR #92, thanks @Pavulon87)
- Fix: `/api/camera/stream` (the MJPEG live view used by the dashboard and every Moonraker-compatible client) opened a brand-new ffmpeg process and printer connection per HTTP client — two simultaneous viewers could already exhaust the printer's very limited camera connection slots and cause intermittent "stream unavailable"/429 failures. All consumers now share one ffmpeg process via the same fanout pattern already used for the H.264 stream (credit to @Pavulon87 for the fix and for finding two related race conditions along the way)

View File

@@ -41,6 +41,21 @@ web_upload_warning = 1
# Poll-Intervall in Sekunden
poll_interval = 3
# ─── Spoolman (optional) ───────────────────────────────────────────────────────
# Verfolgt den Filamentverbrauch je AMS-Slot und bucht ihn automatisch vom
# passenden Spool ab (mm-basiert, wie Moonraker; Spoolman rechnet mm→Gramm).
# [spoolman]
# # Server-URL der Spoolman-Instanz (aus Sicht des Bridge-Containers erreichbar):
# server = http://192.168.x.x:7912
# # 0 = nur am Druckende abbuchen, >0 = alle N Sekunden während des Drucks:
# sync_rate = 0
#
# Die AMS-Slot → Spool-Zuordnung wird in der Weboberfläche gesetzt und je Drucker
# automatisch persistiert (nicht von Hand eintragen):
# Einzeldrucker : [spoolman] slot_spools = 0:42,1:17
# Multi-Printer : [spoolman_1] slot_spools = 0:42,1:17
# [spoolman_2] slot_spools = 0:5,1:6
# ─── Multi-Printer (optional) ──────────────────────────────────────────────────
# Mehrere Drucker können als [printer_1], [printer_2], … definiert werden.
# Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port).

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,12 +2,13 @@
Shared fixtures für KX-Bridge Tests.
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
"""
import sys, types, argparse, pytest, pytest_asyncio
import sys, types, argparse, tempfile, pytest, pytest_asyncio
from unittest.mock import MagicMock
from aiohttp.test_utils import TestClient, TestServer
# ── Pfad ──────────────────────────────────────────────────────────────────────
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
env_mod = types.ModuleType("env_loader")
@@ -41,6 +42,7 @@ def make_args(**overrides):
device_id = "",
host = "127.0.0.1",
port = 7125,
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
)
for k, v in overrides.items():
setattr(args, k, v)

View File

@@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured):
@pytest.mark.asyncio
async def test_settings_post_writes_env(client):
"""POST /api/settings schreibt Werte in .env-Datei."""
async def test_settings_post_writes_config_ini(client):
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("")
bridge._find_env_path = lambda: env_path
config_path = pathlib.Path(tmpdir) / "config.ini"
bridge._find_config_path = lambda: config_path
bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process
resp = await c.post("/api/settings", json={
"printer_ip": "10.0.0.5",
@@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client):
})
assert resp.status == 200
content = env_path.read_text()
assert "PRINTER_IP=10.0.0.5" in content
assert "MQTT_USERNAME=userABCD" in content
assert "DEVICE_ID=deadbeef01234567" in content
content = config_path.read_text()
assert "printer_ip = 10.0.0.5" in content
assert "username = userABCD" in content
assert "device_id = deadbeef01234567" in content
@pytest.mark.asyncio
async def test_settings_post_preserves_existing_keys(client):
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
"""POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server)."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
bridge._find_env_path = lambda: env_path
config_path = pathlib.Path(tmpdir) / "config.ini"
config_path.write_text(
"[spoolman]\nserver = http://192.168.1.50:7912\n\n"
"[connection]\nprinter_ip = old\n"
)
bridge._find_config_path = lambda: config_path
bridge._restart_bridge = lambda: None
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
content = env_path.read_text()
assert "GITEA_TOKEN=mytoken" in content
assert "PRINTER_IP=10.0.0.99" in content
content = config_path.read_text()
assert "server = http://192.168.1.50:7912" in content
assert "printer_ip = 10.0.0.99" in content

View File

@@ -0,0 +1,157 @@
"""Stale slot-profile guard: suppress a saved per-slot filament profile when the
AMS now reports a *different material family* than the profile was assigned for.
Real-world bug (KX1): slot 1 held a PETG spool and got the profile
"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the
colour (live) but the saved profile stuck on PETG, so the panel + the slicer
hint kept showing/using PETG. Restarting did not help — the override lives in
config.ini.
Fix = non-destructive suppression (Option A): resolve the effective profile as
"the saved override only if its material *family* matches the current AMS
material; otherwise none (fall back to the generic default)". The override is
never deleted, so putting the original material back reactivates it.
Comparison must be by *family*, never strict string equality — PLA / PLA+ /
PLA SILK / PLA MATTE are the same family and must NOT invalidate each other
(regression guard for the earlier over-strict material compare).
"""
import argparse
import json
import tempfile
from unittest.mock import MagicMock
# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader.
from kobrax_moonraker_bridge import KobraXBridge
# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type).
LIBRARY = [
{"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"},
{"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"},
{"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"},
]
PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"}
SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"}
UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"}
def _bridge():
c = MagicMock()
c.callbacks = {}
c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxguard-"),
)
b = KobraXBridge(c, args=args)
b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is
return b
# ── _material_family ──────────────────────────────────────────────────────────
def test_material_family_collapses_pla_variants():
fam = KobraXBridge._material_family
assert fam("PLA") == "PLA"
assert fam("PLA+") == "PLA"
assert fam("PLA SILK") == "PLA"
assert fam("PLA MATTE") == "PLA"
assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction
def test_material_family_collapses_petg_variants():
fam = KobraXBridge._material_family
assert fam("PETG") == "PETG"
assert fam("PETG+") == "PETG"
def test_material_family_distinguishes_pla_from_petg():
fam = KobraXBridge._material_family
assert fam("PLA") != fam("PETG")
assert fam("PLA SILK") != fam("PETG")
def test_material_family_empty_for_empty_input():
assert KobraXBridge._material_family("") == ""
assert KobraXBridge._material_family(None) == ""
# ── _effective_slot_profile ───────────────────────────────────────────────────
def test_suppressed_when_family_changes_petg_profile_pla_loaded():
"""The exact KX1 bug: PETG profile, AMS now reports PLA → suppress."""
b = _bridge()
b._filament_profiles = {0: dict(PETG_PROFILE)}
assert b._effective_slot_profile(0, "PLA") == {}
def test_kept_when_family_matches_petg_profile_petg_loaded():
b = _bridge()
b._filament_profiles = {0: dict(PETG_PROFILE)}
assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE
def test_kept_for_pla_variant_no_false_positive():
"""PLA+ profile with a PLA SILK spool loaded is the same family → keep."""
b = _bridge()
b._filament_profiles = {1: dict(SILK_PROFILE)}
assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE
def test_kept_when_profile_material_unknown_failsafe():
"""If the profile is not in the library we cannot know its family → never
suppress on uncertainty (fail-safe keeps the user's choice)."""
b = _bridge()
b._filament_profiles = {2: dict(UNKNOWN_PROFILE)}
assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE
def test_empty_when_no_override():
b = _bridge()
b._filament_profiles = {}
assert b._effective_slot_profile(3, "PLA") == {}
# ── Integration: display endpoint (the visible panel) ─────────────────────────
async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded():
"""/kx/filament/slots must not show the stale PETG identity once PLA loads."""
b = _bridge()
b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
assert row["material"] == "PLA" # AMS truth, always
assert row["filament_name"] == "" # stale PETG identity gone
assert row["filament_vendor"] == ""
async def test_display_endpoint_keeps_profile_when_family_matches():
b = _bridge()
b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
assert row["filament_name"] == "KINGROON PETG Basic"
assert row["filament_vendor"] == "KINGROON"
# ── Integration: print path (lane_data sent to OrcaSlicer) ────────────────────
async def test_lane_data_does_not_leak_stale_petg_identity():
b = _bridge()
b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
tray = b._build_lane_data()["ams"][0]["tray"][0]
assert tray["tray_type"] == "PLA"
assert "PETG" not in tray["name"].upper()
assert tray["vendor_name"] != "KINGROON"
async def test_lane_data_keeps_profile_when_family_matches():
b = _bridge()
b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
tray = b._build_lane_data()["ams"][0]["tray"][0]
assert tray["name"] == "KINGROON PETG Basic"
assert tray["vendor_name"] == "KINGROON"

View File

@@ -0,0 +1,100 @@
"""Per-printer Spoolman slot-map isolation + persistence (config_loader).
Regression test for two bugs in the Spoolman slot→spool persistence:
1. The bridge referenced ``config_loader`` while the module alias is
``env_loader`` → ``NameError`` swallowed by a bare ``except``, so the map
was never loaded nor saved (persistence looked implemented but was dead).
2. The map lived in a single global ``[spoolman] slot_spools`` key, so two
printers/two AMS units overwrote each other (same class as issue #74/#75).
Each printer now uses its own ``[spoolman_<id>]`` section, with a read-fallback
to the legacy global key for backward compatibility. The global ``[spoolman]``
section keeps ``server`` / ``sync_rate``.
"""
import sys
import pathlib
import configparser
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root
import config_loader # noqa: E402
BASE_INI = (
"[printer_1]\nname = K1\n\n"
"[printer_2]\nname = K2\n\n"
"[spoolman]\n"
"server = http://192.168.3.200:7912\n"
"sync_rate = 0\n"
"slot_spools = 0:1,1:2\n"
)
def _use_ini(monkeypatch, tmp_path, text=BASE_INI):
path = tmp_path / "config.ini"
path.write_text(text, encoding="utf-8")
monkeypatch.setattr(config_loader, "_find_config_file", lambda: path)
return path
def test_legacy_global_read(tmp_path, monkeypatch):
"""No printer_id -> original global [spoolman] slot_spools (back-compat)."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_spool_map() == {0: 1, 1: 2}
def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch):
"""Before any per-printer save, both printers see the global mapping."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_spool_map("1") == {0: 1, 1: 2}
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch):
"""Core regression: mapping printer 1 must not change printer 2."""
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42, 1: 17}, "1")
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
# printer 2 has no own section yet -> still the global fallback
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
# legacy global key preserved untouched
assert config_loader.list_spool_map() == {0: 1, 1: 2}
def test_both_printers_isolated_after_each_saves(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42, 1: 17}, "1")
config_loader.save_spool_map({0: 5, 1: 6}, "2")
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
assert config_loader.list_spool_map("2") == {0: 5, 1: 6}
def test_save_preserves_server_and_sync_rate(tmp_path, monkeypatch):
"""Writing a per-printer map must not clobber [spoolman] server/sync_rate."""
path = _use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42}, "1")
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
assert cfg.get("spoolman", "server") == "http://192.168.3.200:7912"
assert cfg.get("spoolman", "sync_rate") == "0"
assert cfg.get("spoolman_1", "slot_spools") == "0:42"
def test_persistence_round_trips(tmp_path, monkeypatch):
"""Save then read back (simulates a bridge restart) — the map survives."""
_use_ini(monkeypatch, tmp_path, text="[spoolman]\nserver = http://x:7912\n")
config_loader.save_spool_map({0: 7, 2: 9}, "1")
assert config_loader.list_spool_map("1") == {0: 7, 2: 9}
def test_empty_map_clears_the_key(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42}, "1")
config_loader.save_spool_map({}, "1") # clear
# per-printer key gone -> falls back to the legacy global map
assert config_loader.list_spool_map("1") == {0: 1, 1: 2}
def test_parse_ignores_malformed_and_nonpositive(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path,
text="[spoolman]\nslot_spools = 0:1, x:y, 2:0, 3:-4, 4:5, junk\n")
assert config_loader.list_spool_map() == {0: 1, 4: 5}

View File

@@ -45,7 +45,7 @@ var ACE_DRY_PRESETS={
};
// Spoolman state
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
var _spoolmanStatus={configured:false,reachable:false,server:'',sync_rate:0,slot_spools:{}};
var _spoolmanSpools=[];
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
@@ -67,10 +67,12 @@ function _updateSpoolmanStatusDot(){
var dot=document.getElementById('spoolman-status-dot');
var lbl=document.getElementById('spoolman-status-lbl');
if(!dot||!lbl)return;
if(_spoolmanStatus.configured){
if(!_spoolmanStatus.configured){
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
} else if(_spoolmanStatus.reachable){
dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden';
} else {
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
dot.style.color='var(--err)';lbl.textContent=(_spoolmanStatus.server||'')+' (nicht erreichbar)';
}
}
@@ -100,7 +102,7 @@ function _buildSpoolmanSection(){
var currentSpool=_slotSpoolMap[String(idx)]||'';
var opts='<option value=""></option>'+_spoolmanSpools.map(function(sp){
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor+' ':'';
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor.name+' ':'';
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
@@ -406,6 +408,16 @@ function applyLang(){
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)');
setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)');
var dashLbl=document.getElementById('dash-lbl-edit');
if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
setText('dash-lbl-reset',T.dash_reset||'Reset');
setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset');
var dashPresetStd=document.getElementById('dash-preset-standard');
if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard';
var dashPresetWide=document.getElementById('dash-preset-wide89');
if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop';
if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar();
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
@@ -433,6 +445,7 @@ function applyLang(){
setText('lbl-default-slot',T.settings_default_slot);
setText('opt-slot-auto',T.settings_slot_auto);
setText('lbl-auto-leveling',T.settings_auto_leveling);
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
setText('lbl-file-ready-mode',T.settings_file_ready_mode);
setText('opt-file-ready-dialog',T.settings_file_ready_dialog);
setText('opt-file-ready-banner',T.settings_file_ready_banner);
@@ -733,6 +746,17 @@ function applyState(){
// connection error banner nur wenn überhaupt ein Drucker konfiguriert ist
var banner=document.getElementById('conn-error-banner');
if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}}
var pauseBanner=document.getElementById('pause-msg-banner');
if(pauseBanner){
if(s.pause_msg && s.print_state==='paused'){
var codePart = (s.error_code==0) ? ' ' :
' [<a href="https://wiki.anycubic.com/en/error-codes/'+s.error_code+'-code" target="_blank">'+s.error_code+'</a>] ';
pauseBanner.innerHTML='⏸ '+tr('lbl_pause_reason')+codePart+s.pause_msg;
pauseBanner.style.display='block';
}else{
pauseBanner.style.display='none';
}
}
var bannerVisible=false;
var frb=document.getElementById('file-ready-banner');
if(frb){
@@ -1086,6 +1110,7 @@ function openSettings(){
document.getElementById('s-mode-id').value=d.mode_id||'';
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
@@ -1095,6 +1120,7 @@ function openSettings(){
var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3;
pi.value=sec;
}
var vhl=document.getElementById('s-verbose-http-log');if(vhl)vhl.checked=!!d.verbose_http_log;
renderFilamentMapping(d.filament_profiles||{});
renderSpoolmanSlotCard();
// Spoolman
@@ -1413,7 +1439,14 @@ function doProfileImportUpload(files){
// ── AMS Slot Edit ──
var _slotEditIndex=-1;
var _slotEditLoaded=false;
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
var _MAT_PRESETS=['PLA','PLA+','PLA SILK','PLA MATTE','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
function _normalizeMat(m){
var s=m.toUpperCase().trim().replace(/-/g,' ').replace(/_/g,' ');
var aliases={'PLAPLUS':'PLA+','PLA PLUS':'PLA+','SILK PLA':'PLA SILK','PLASILK':'PLA SILK',
'PLA MATTE':'PLA','PLA MARBLE':'PLA','PLA WOOD':'PLA','TPE':'TPU',
'PETG PLUS':'PETG+','PA6':'PA','PA12':'PA','PA66':'PA'};
return aliases[s]||s;
}
var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK'];
function updateSlotEditFeedButton(){
var btn=document.getElementById('btn-slot-edit-feed');
@@ -1454,10 +1487,21 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){
_loadOrcaFilaments(function(profiles){
// Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten)
var matU=(material||'').toUpperCase().trim();
// PLA-Varianten: Drucker meldet "PLA SILK"/"PLA+"/"PLA MATTE", OrcaSlicer
// speichert alle unter type=PLA — Namens-Keyword als Zusatzfilter.
var _PLA_VARIANT_KW={'PLA SILK':'silk','PLA+':'pla+','PLA MATTE':'matte',
'PLA MARBLE':'marble','PLA WOOD':'wood'};
var variantKw=_PLA_VARIANT_KW[matU]||null;
var baseMat=variantKw?'PLA':matU;
var matched=profiles.filter(function(p){
var pt=(p.type||'').toUpperCase();
// PLA-CF, PLA-SILK etc. zählen auch zu PLA
return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' ');
var nameL=(p.name||'').toLowerCase();
// Basis-Typ muss passen
var typeOk=baseMat===''||pt===baseMat||pt.startsWith(baseMat+'-')||pt.startsWith(baseMat+' ');
if(!typeOk) return false;
// Bei Variante: Name muss Keyword enthalten (z.B. "silk", "matte", "pla+")
if(variantKw) return nameL.indexOf(variantKw)!==-1;
return true;
});
sel.innerHTML='<option value="">'+tr('slot_edit_profile_default')+'</option>';
// User-Profile (is_user) zuerst — eigene Optgroup '★ Eigene' an erster Stelle.
@@ -1619,11 +1663,14 @@ function openSlotEdit(i){
_renderCopyFromSlot(globalIdx);
var mat=(slot.type||'PLA').toUpperCase();
document.getElementById('slot-edit-mat').value=mat;
// Normalisieren für Button-Highlighting: PLA-Varianten auf nächsten Preset mappen
var matNorm=_normalizeMat(mat);
var btns=document.getElementById('slot-mat-btns');
btns.innerHTML=_MAT_PRESETS.map(function(m){
var active=m===mat||m===matNorm;
return '<button class="mat-preset-btn" data-mat="'+m+'" onclick="selectMatPreset(\''+m+'\')" '
+'style="padding:4px 10px;border-radius:6px;border:1px solid var(--border);cursor:pointer;font-size:12px;'
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
+(active?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
}).join('');
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
// aus /kx/filament/slots holen (enthält vendor+name+id).
@@ -1830,11 +1877,13 @@ function saveSettings(){
device_id: document.getElementById('s-device-id').value,
mode_id: document.getElementById('s-mode-id').value,
default_ams_slot: document.getElementById('s-default-slot').value,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
web_upload_warning:webUploadWarning,
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0,
spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'',
spoolman_sync_rate: Math.max(0,parseInt((document.getElementById('s-spoolman-sync-rate')||{}).value||'30',10)),
}).then(function(){
@@ -1941,8 +1990,314 @@ var pollTimer;
});
}).catch(function(){});
poll();pollTimer=setInterval(poll,ms);
setInterval(_loadSpoolmanStatus,30000);
// initDashGrid() is called at the very end of the file, after all the
// DASH_* var declarations below have executed (they are hoisted but not yet
// assigned at this point in the IIFE).
})();
// ── Dashboard Free Grid (Issue #89) ────────────────────────────────────────
// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js).
// Cards move + resize on a 12-column snap grid; layout persisted per browser
// in localStorage. Built on documented APIs only:
// - float:false (default) -> compact packing, no holes between cards
// - staticGrid:true -> locked by default, setStatic(false) in edit mode
// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id
// - draggable.cancel -> inputs/buttons/img excluded from drag start
// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware)
var DASH_STORAGE_KEY='dashLayout';
var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states
var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams'];
// Layout arrays in GridStack save()/load() format. cellHeight=60px.
var DASH_DEFAULT_LAYOUT=[
{id:'camera', x:0,y:0, w:12,h:7},
{id:'progress',x:0,y:7, w:12,h:6},
{id:'temps', x:0,y:13,w:6, h:7},
{id:'motion', x:6,y:13,w:6, h:7},
{id:'speed', x:0,y:20,w:6, h:3},
{id:'fan', x:6,y:20,w:6, h:3},
{id:'ams', x:0,y:23,w:12,h:4},
];
// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center,
// motion right.
var DASH_PRESET_WIDE=[
{id:'progress',x:0, y:0, w:12,h:4},
{id:'camera', x:0, y:4, w:6, h:11},
{id:'temps', x:6, y:4, w:3, h:7},
{id:'speed', x:6, y:11,w:3, h:2},
{id:'fan', x:6, y:13,w:3, h:2},
{id:'motion', x:9, y:4, w:3, h:11},
{id:'ams', x:0, y:15,w:12,h:4},
];
var _dashGrid=null;
var _dashEditing=false;
var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show)
// GridStack requires .grid-stack-item > .grid-stack-item-content around each
// card — wrap the existing .card elements in place (IDs/event handlers survive,
// appendChild moves nodes without recreating them).
function _dashWrapCards(){
var grid=document.getElementById('dash-grid');
if(!grid)return;
DASH_CARD_KEYS.forEach(function(key){
var card=grid.querySelector('[data-card="'+key+'"]');
if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return;
var item=document.createElement('div');
item.className='grid-stack-item';
item.setAttribute('gs-id',key);
var content=document.createElement('div');
content.className='grid-stack-item-content';
grid.insertBefore(item,card);
item.appendChild(content);
content.appendChild(card);
});
}
function _dashItem(key){
return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]');
}
function _dashLoadState(){
try{
var raw=localStorage.getItem(DASH_STORAGE_KEY);
if(!raw)return null;
var s=JSON.parse(raw);
if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null;
return s;
}catch(e){return null;}
}
function _dashSaveState(){
if(!_dashGrid)return;
localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({
version:DASH_LAYOUT_VERSION,
layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only
hidden:_dashHidden.slice(),
}));
}
function initDashGrid(){
var el=document.getElementById('dash-grid');
if(!el||typeof GridStack==='undefined')return;
_dashWrapCards();
_dashGrid=GridStack.init({
cellHeight:60,
margin:8,
staticGrid:true, // locked by default; setStatic(false) enables drag+resize
columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware)
draggable:{cancel:'input,textarea,button,select,option,img,.slider'},
},el);
var state=_dashLoadState();
if(state){
_dashHidden=Array.isArray(state.hidden)?state.hidden:[];
_dashGrid.load(state.layout,false); // update matching ids, no add/remove
_dashHidden.forEach(function(n){
var item=_dashItem(n.id);
if(item){_dashGrid.removeWidget(item,false);item.style.display='none';}
});
}else{
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
}
_dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();});
_dashRefreshPresetDropdown();
}
function toggleDashEdit(){
if(!_dashGrid)return;
_dashEditing=!_dashEditing;
_dashGrid.setStatic(!_dashEditing);
document.getElementById('dash-grid').classList.toggle('editing',_dashEditing);
document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none';
document.getElementById('dash-preset').style.display=_dashEditing?'':'none';
document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none';
_dashUpdatePresetDeleteBtn();
if(_dashEditing)_dashInjectControls(); else _dashRemoveControls();
_dashRenderHiddenBar();
if(!_dashEditing)_dashSaveState();
}
// ── Custom presets (user-saved layouts) ────────────────────────────────────
var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets';
var DASH_BUILTIN_PRESET_IDS=['standard','wide89'];
function _dashLoadCustomPresets(){
try{
var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY);
var obj=raw?JSON.parse(raw):{};
return (obj&&typeof obj==='object')?obj:{};
}catch(e){return {};}
}
function _dashSaveCustomPresets(presets){
localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets));
}
function _dashRefreshPresetDropdown(){
var sel=document.getElementById('dash-preset');
if(!sel)return;
var current=sel.value;
var customs=_dashLoadCustomPresets();
// Remove previously-rendered custom <option>s, keep the two built-ins.
Array.prototype.slice.call(sel.querySelectorAll('option')).forEach(function(o){
if(DASH_BUILTIN_PRESET_IDS.indexOf(o.value)===-1)sel.removeChild(o);
});
Object.keys(customs).sort().forEach(function(name){
var opt=document.createElement('option');
opt.value='custom:'+name;
opt.textContent=name;
sel.appendChild(opt);
});
if(Array.prototype.some.call(sel.options,function(o){return o.value===current;}))sel.value=current;
_dashUpdatePresetDeleteBtn();
}
function _dashUpdatePresetDeleteBtn(){
var sel=document.getElementById('dash-preset');
var delBtn=document.getElementById('dash-preset-delete-btn');
if(!sel||!delBtn)return;
var isCustom=sel.value.indexOf('custom:')===0;
delBtn.style.display=(_dashEditing&&isCustom)?'':'none';
}
function saveCurrentAsDashPreset(){
if(!_dashGrid)return;
var name=(prompt(T.dash_preset_name_prompt||'Preset name:')||'').trim();
if(!name)return;
var customs=_dashLoadCustomPresets();
customs[name]=_dashGrid.save(false);
_dashSaveCustomPresets(customs);
_dashRefreshPresetDropdown();
var sel=document.getElementById('dash-preset');
if(sel)sel.value='custom:'+name;
_dashUpdatePresetDeleteBtn();
}
function deleteCurrentDashPreset(){
var sel=document.getElementById('dash-preset');
if(!sel||sel.value.indexOf('custom:')!==0)return;
var name=sel.value.slice(7);
if(!confirm((T.dash_preset_delete_confirm||'Delete preset "{name}"?').replace('{name}',name)))return;
var customs=_dashLoadCustomPresets();
delete customs[name];
_dashSaveCustomPresets(customs);
sel.value='standard';
_dashRefreshPresetDropdown();
resetDashLayout();
}
function _dashInjectControls(){
DASH_CARD_KEYS.forEach(function(key){
if(_dashHidden.some(function(n){return n.id===key;}))return;
var content=document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"] .grid-stack-item-content');
if(!content||content.querySelector('.dash-card-controls'))return;
var ctrl=document.createElement('div');
ctrl.className='dash-card-controls';
var hideBtn=document.createElement('button');
hideBtn.className='dash-card-ctrl-btn';
hideBtn.title=T.dash_hide||'Hide';
hideBtn.textContent='👁';
hideBtn.addEventListener('click',function(e){e.stopPropagation();dashHideCard(key);});
ctrl.appendChild(hideBtn);
content.appendChild(ctrl);
});
}
function _dashRemoveControls(){
var nodes=document.querySelectorAll('#dash-grid .dash-card-controls');
Array.prototype.forEach.call(nodes,function(n){n.parentNode&&n.parentNode.removeChild(n);});
}
function _dashCardLabel(key){
var card=document.querySelector('[data-card="'+key+'"]');
if(!card)return key;
var t=card.querySelector('.card-title');
return t?t.textContent.trim():key;
}
function _dashRenderHiddenBar(){
var bar=document.getElementById('dash-hidden-bar');
if(!bar)return;
bar.innerHTML='';
if(!_dashEditing||!_dashHidden.length){bar.classList.remove('show');return;}
bar.classList.add('show');
var label=document.createElement('span');
label.textContent=(T.dash_hidden_cards||'Hidden cards')+': ';
bar.appendChild(label);
_dashHidden.forEach(function(n){
var chip=document.createElement('span');
chip.className='dash-hidden-chip';
chip.appendChild(document.createTextNode(_dashCardLabel(n.id)+' '));
var btn=document.createElement('button');
btn.textContent='👁';
btn.title=T.dash_show||'Show';
btn.addEventListener('click',function(){dashShowCard(n.id);});
chip.appendChild(btn);
bar.appendChild(chip);
});
}
function dashHideCard(key){
if(_dashHidden.some(function(n){return n.id===key;}))return;
var item=_dashItem(key);
if(!item||!_dashGrid)return;
// Remember position/size so re-show can restore it.
var n=item.gridstackNode||{};
_dashHidden.push({id:key,x:n.x,y:n.y,w:n.w,h:n.h});
_dashGrid.removeWidget(item,false); // keep DOM element, drop the widget
item.style.display='none';
_dashSaveState();
_dashRenderHiddenBar();
}
function dashShowCard(key){
var entry=null;
_dashHidden=_dashHidden.filter(function(n){if(n.id===key){entry=n;return false;}return true;});
var item=_dashItem(key);
if(item&&_dashGrid){
item.style.display='';
_dashGrid.makeWidget(item);
if(entry&&entry.w)_dashGrid.update(item,{x:entry.x,y:entry.y,w:entry.w,h:entry.h});
if(_dashEditing)_dashInjectControls();
}
_dashSaveState();
_dashRenderHiddenBar();
}
function _dashShowAllHidden(){
_dashHidden.slice().forEach(function(n){dashShowCard(n.id);});
_dashHidden=[];
}
function resetDashLayout(){
if(!_dashGrid)return;
localStorage.removeItem(DASH_STORAGE_KEY);
var sel=document.getElementById('dash-preset');if(sel)sel.value='standard';
_dashShowAllHidden();
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
if(_dashEditing){_dashInjectControls();_dashSaveState();}
_dashRenderHiddenBar();
}
function applyDashPreset(name){
if(name==='standard'){resetDashLayout();return;}
if(!_dashGrid)return;
var layout=null;
if(name==='wide89'){
layout=DASH_PRESET_WIDE;
}else if(name.indexOf('custom:')===0){
var customs=_dashLoadCustomPresets();
layout=customs[name.slice(7)];
}
if(!layout)return;
var sel=document.getElementById('dash-preset');
if(sel)sel.value=name; // keep the dropdown in sync even when called directly
_dashShowAllHidden();
_dashGrid.load(layout,false);
_dashUpdatePresetDeleteBtn();
if(_dashEditing)_dashInjectControls();
_dashSaveState();
_dashRenderHiddenBar();
}
// Init now — all DASH_* declarations above have run, and app.js loads at
// </body>, so the DOM is ready.
initDashGrid();
// ── Print actions ──
function printAction(a){
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})

View File

@@ -6,10 +6,13 @@
<title>KX-Bridge</title>
<link rel="stylesheet" href="/kx/ui/style.css">
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">
<script src="/kx/ui/lib/pickr.min.js"></script>
<script src="/kx/ui/lib/gridstack-all.min.js"></script>
<body>
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
<div id="pause-msg-banner" style="display:none;background:#b8860b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:997;"></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()"
@@ -137,9 +140,19 @@
<main>
<!-- ═══ DASHBOARD ═══ -->
<div class="panel active" id="panel-dashboard">
<div class="grid">
<div id="dash-toolbar" style="display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-bottom:10px">
<select id="dash-preset" onchange="applyDashPreset(this.value)" style="display:none;padding:4px 8px;font-size:12px;background:var(--raised);color:var(--txt);border:1px solid var(--border);border-radius:6px">
<option value="standard" id="dash-preset-standard">Standard</option>
<option value="wide89" id="dash-preset-wide89">Desktop breit</option>
</select>
<button id="dash-preset-delete-btn" onclick="deleteCurrentDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--err);border:1px solid var(--border);border-radius:6px;cursor:pointer">🗑</button>
<button id="dash-preset-save-btn" onclick="saveCurrentAsDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">💾 <span id="dash-lbl-save-preset">Als Preset speichern</span></button>
<button id="dash-reset-btn" onclick="resetDashLayout()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer"><span id="dash-lbl-reset">Zurücksetzen</span></button>
<button id="dash-edit-btn" onclick="toggleDashEdit()" style="padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">🖉 <span id="dash-lbl-edit">Dashboard anpassen</span></button>
</div>
<div class="grid-stack" id="dash-grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="card-camera" data-card="camera" gs-w="12" gs-h="7">
<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">
@@ -154,7 +167,7 @@
<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">
<img id="cam-img" draggable="false" 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>
@@ -164,7 +177,7 @@
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="card-progress" data-card="progress" gs-w="12" gs-h="6">
<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>
@@ -210,7 +223,7 @@
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="card-temps" data-card="temps" gs-w="6" gs-h="7">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
@@ -253,7 +266,7 @@
</div>
<!-- Achsensteuerung -->
<div class="card">
<div class="card" id="card-motion" data-card="motion" gs-w="6" gs-h="7">
<div class="card-title"><span></span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
<!-- XY -->
@@ -301,7 +314,7 @@
</div>
<!-- Print Speed -->
<div class="card">
<div class="card" id="card-speed" data-card="speed" gs-w="6" gs-h="3">
<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)">
@@ -323,7 +336,7 @@
</div>
<!-- Lüfter -->
<div class="card">
<div class="card" id="card-fan" data-card="fan" gs-w="6" gs-h="3">
<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()">
@@ -338,18 +351,19 @@
</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" id="d-ams-card" data-card="ams" gs-w="12" gs-h="4">
<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>
<!-- ACE-Trocknung: außerhalb des GridStack-Grids, per Drucker-State eingeblendet -->
<div id="d-ace-dry-wrap" class="grid" style="display:none;margin-top:16px">
<div id="d-ace-dry-grid" style="display:contents"></div>
</div>
<div id="dash-hidden-bar"></div>
</div>
<!-- ═══ CONSOLE ═══ -->
@@ -509,6 +523,10 @@
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance compensation before print</label>
</div>
<div class="modal-field">
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
<select id="s-file-ready-mode">
@@ -550,6 +568,10 @@
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-verbose-http-log" style="width:auto;margin:0">
<label id="lbl-verbose-http-log" style="margin:0;cursor:pointer" for="s-verbose-http-log">Log every HTTP request (verbose)</label>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%}

View File

@@ -95,18 +95,63 @@ main{flex:1;overflow-y:auto;padding:20px}
/* ── CARD ── */
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
padding:18px;transition:box-shadow .15s,transform .15s}
padding:18px;transition:box-shadow .15s,transform .15s;container-type:inline-size}
.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}
/* ── DASHBOARD FREE GRID (GridStack) ── */
/* .grid-stack-item-content is already position:absolute + fills the cell
(GridStack's own CSS). The card inside just needs to fill that box — it
must NOT be position:absolute itself, or it can sit above/intercept
GridStack's resize-handle hit area and drag listeners. */
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
the cell smaller than the content needs. */
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box}
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
as the user starts dragging, throwing off GridStack's position math. Kill
the hover transform for dashboard cards specifically. */
.grid-stack-item-content>.card:hover{transform:none}
/* Edit mode: dashed outline + grab cursor on each item */
#dash-grid.editing .grid-stack-item-content>.card{cursor:grab;
outline:1px dashed var(--accent);outline-offset:-1px;user-select:none}
/* GridStack placeholder styled to theme */
.grid-stack>.grid-stack-placeholder>.placeholder-content{
background:rgba(120,150,255,.12);border:1px dashed var(--accent);border-radius:12px}
/* Resize handles only visible in edit mode */
#dash-grid:not(.editing) .ui-resizable-handle{display:none!important}
/* Per-card controls (hide button) */
.dash-card-controls{display:none;position:absolute;top:8px;right:8px;gap:4px;z-index:6}
#dash-grid.editing .dash-card-controls{display:flex}
.dash-card-ctrl-btn{width:24px;height:24px;border-radius:6px;border:1px solid var(--border);
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:12px;
display:flex;align-items:center;justify-content:center;line-height:1}
.dash-card-ctrl-btn:hover{color:var(--accent);border-color:var(--accent)}
#dash-hidden-bar{display:none;flex-wrap:wrap;gap:8px;margin-top:12px;padding:10px;
border:1px dashed var(--border);border-radius:10px}
#dash-hidden-bar.show{display:flex}
.dash-hidden-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px;
background:var(--raised);border:1px solid var(--border);border-radius:20px;color:var(--txt2)}
.dash-hidden-chip button{background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0}
@media(max-width:768px){
#dash-toolbar{display:none}
}
/* ── 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-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain;
-webkit-user-drag:none;user-select:none}
/* Inside the dashboard grid the camera card can be resized taller than the
default 320px cap — flex column: title row keeps its height, cam view
flexes to fill whatever cell height the user chose. */
.grid-stack-item-content>#card-camera{display:flex;flex-direction:column}
.grid-stack-item-content .cam-wrap{flex:1;min-height:0;max-height:none}
.grid-stack-item-content .cam-wrap img,.grid-stack-item-content .cam-wrap video{max-height:100%;height:100%}
.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);
@@ -161,6 +206,12 @@ main{flex:1;overflow-y:auto;padding:20px}
/* ── TEMPS ── */
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@container (max-width:340px){
.temp-card-inner{grid-template-columns:1fr}
.temp-val{font-size:24px}
.temp-edit{flex-wrap:wrap}
.temp-input{width:100%}
}
.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}

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Einziehen",
"lbl_layers": "Layer",
"lbl_light": "💡 Licht",
"lbl_pause_reason": "Druck pausiert:",
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
@@ -212,6 +213,7 @@
"progress_action_slots": "Slots zuordnen",
"settings_auto_leveling": "Auto-Leveling vor Druck",
"settings_auto_leveling_label": "Auto-Leveling vor dem Druck",
"settings_vibration_compensation": "Resonanzkompensation",
"settings_btn_tooltip": "Einstellungen",
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
"settings_cat_connection": "Verbindung",
@@ -245,6 +247,19 @@
"settings_password": "MQTT-Passwort",
"settings_poll": "Poll-Intervall (Sekunden)",
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"dash_edit": "Dashboard anpassen",
"dash_done": "Fertig",
"dash_reset": "Zurücksetzen",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Desktop breit",
"dash_hidden_cards": "Ausgeblendete Karten",
"dash_show": "Einblenden",
"dash_hide": "Ausblenden",
"dash_toggle_width": "Breite umschalten",
"dash_save_preset": "Als Preset speichern",
"dash_preset_name_prompt": "Preset-Name:",
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
"settings_print": "Druckeinstellungen",
"settings_printer_ip": "Drucker-IP",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Load",
"lbl_layers": "Layer",
"lbl_light": "💡 Light",
"lbl_pause_reason": "Print paused:",
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
@@ -212,6 +213,7 @@
"progress_action_slots": "Map slots",
"settings_auto_leveling": "Auto-Leveling Default",
"settings_auto_leveling_label": "Auto-Leveling before print",
"settings_vibration_compensation": "Resonance Compensation",
"settings_btn_tooltip": "Settings",
"settings_camera_on_print": "Turn camera on at print start",
"settings_cat_connection": "Connection",
@@ -245,6 +247,19 @@
"settings_password": "MQTT Password",
"settings_poll": "Poll Interval (seconds)",
"settings_poll_interval_hint": "How often the bridge queries printer status",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"dash_edit": "Customize dashboard",
"dash_done": "Done",
"dash_reset": "Reset",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Wide desktop",
"dash_hidden_cards": "Hidden cards",
"dash_show": "Show",
"dash_hide": "Hide",
"dash_toggle_width": "Toggle width",
"dash_save_preset": "Save as preset",
"dash_preset_name_prompt": "Preset name:",
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
"settings_poll_interval_label": "Poll Interval (seconds)",
"settings_print": "Print Settings",
"settings_printer_ip": "Printer IP",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Cargar",
"lbl_layers": "Capa",
"lbl_light": "💡 Luz",
"lbl_pause_reason": "Impresión pausada:",
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
@@ -212,6 +213,7 @@
"progress_action_slots": "Asignar ranuras",
"settings_auto_leveling": "Autonivelado antes de imprimir",
"settings_auto_leveling_label": "Autonivelado antes de imprimir",
"settings_vibration_compensation": "Compensación de resonancia",
"settings_btn_tooltip": "Ajustes",
"settings_camera_on_print": "Encender cámara al iniciar impresión",
"settings_cat_connection": "Conexión",
@@ -245,6 +247,19 @@
"settings_password": "Contraseña MQTT",
"settings_poll": "Intervalo de sondeo (segundos)",
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"dash_edit": "Personalizar panel",
"dash_done": "Listo",
"dash_reset": "Restablecer",
"dash_preset_standard": "Estándar",
"dash_preset_wide89": "Escritorio ancho",
"dash_hidden_cards": "Tarjetas ocultas",
"dash_show": "Mostrar",
"dash_hide": "Ocultar",
"dash_toggle_width": "Cambiar ancho",
"dash_save_preset": "Guardar como preset",
"dash_preset_name_prompt": "Nombre del preset:",
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
"settings_print": "Ajustes de impresión",
"settings_printer_ip": "IP de impresora",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Charger",
"lbl_layers": "Couche",
"lbl_light": "💡 Lumière",
"lbl_pause_reason": "Impression en pause :",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "Carica",
"lbl_layers": "Layer",
"lbl_light": "💡 Luce",
"lbl_pause_reason": "Stampa in pausa:",
"lbl_remaining": "Rimanente:",
"lbl_slicer_time": "Stima slicer:",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",

View File

@@ -121,6 +121,7 @@
"lbl_feed": "进料",
"lbl_layers": "层",
"lbl_light": "💡 灯光",
"lbl_pause_reason": "打印已暂停:",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_spoolman_sync_rate": "同步频率0=打印结束)",
@@ -212,6 +213,7 @@
"progress_action_slots": "分配槽位",
"settings_auto_leveling": "打印前自动调平",
"settings_auto_leveling_label": "打印前自动调平",
"settings_vibration_compensation": "打印前共振补偿",
"settings_btn_tooltip": "设置",
"settings_camera_on_print": "打印开始时开启相机",
"settings_cat_connection": "连接",
@@ -245,6 +247,19 @@
"settings_password": "MQTT 密码",
"settings_poll": "轮询间隔(秒)",
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"dash_edit": "自定义仪表盘",
"dash_done": "完成",
"dash_reset": "重置",
"dash_preset_standard": "标准",
"dash_preset_wide89": "宽屏桌面",
"dash_hidden_cards": "隐藏的卡片",
"dash_show": "显示",
"dash_hide": "隐藏",
"dash_toggle_width": "切换宽度",
"dash_save_preset": "另存为预设",
"dash_preset_name_prompt": "预设名称:",
"dash_preset_delete_confirm": "删除预设 \"{name}\"",
"settings_poll_interval_label": "轮询间隔(秒)",
"settings_print": "打印设置",
"settings_printer_ip": "打印机 IP",