Compare commits

...

23 Commits

Author SHA1 Message Date
86adde0a45 chore: Version auf 0.9.29 erhöhen
All checks were successful
Stable Release / release (push) Successful in 7m28s
2026-07-27 19:24:11 +02:00
gitea-actions
c4f321c9b0 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly48 release 2026-07-27 12:33:09 +00:00
1f1d60d571 feat(browser): show real GCode thumbnails in the "On Printer" tab
All checks were successful
Nightly Build / build (push) Successful in 6m53s
The printer's file/fileDetails MQTT action extracts and base64-encodes
the embedded "; thumbnail begin" block from a GCode file's header on
demand and returns it inline as data.file_details.thumbnail - verified
live against a real Kobra X (a valid 230x110 PNG came back for an
existing print file).

New endpoint GET /kx/printer-files/{filename}/thumbnail wraps this via
the existing _wait_for_file_action() helper (same fire-and-forget +
file/report-callback pattern as listLocal/deleteBatch). Results are
cached in-memory per filename (self._printer_thumbnail_cache) - a
file's thumbnail never changes while it exists on the printer, and the
tab can list 100+ files at once.

Frontend fetches thumbnails lazily through a small sequential queue
(_printerThumbQueue/_pumpPrinterThumbQueue) after rendering the file
list, one request at a time rather than firing 100+ concurrent MQTT
roundtrips, and swaps each card's placeholder printer icon for the
real <img> once its thumbnail arrives. Client-side cache
(_printerThumbCache) avoids re-fetching on re-render (e.g. after a
selection change).

Verified end-to-end against the real printer and visually via
Playwright: all 10 listed files rendered their actual, distinct print
preview thumbnails instead of the generic icon.
2026-07-27 14:25:50 +02:00
gitea-actions
9f76d28622 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly47 release 2026-07-27 12:13:12 +00:00
cbcb17f45a feat(browser): add second tab for files on the printer's own storage
All checks were successful
Nightly Build / build (push) Successful in 6m50s
The GCode browser previously only showed files the bridge itself had
stored (its own SQLite GCodeStore, uploaded through the bridge). Files
printed directly via Anycubic Slicer Next (bypassing the bridge) land
on the printer's internal storage instead, and were only visible/
manageable from the printer's own display.

Adds a second sub-tab ("On Printer") using the same master-detail
tab pattern already used for Settings categories (showSettingsCat),
backed by the printer's file/listLocal and file/deleteBatch MQTT
actions - verified live against a real Kobra X, see memory
reference_mqtt_listlocal.md.

Key implementation detail: publish()'s own return value for these
actions is just a generic immediate ACK skeleton (code=0, empty
fields) - the real response arrives asynchronously via the file/report
callback (_on_file), same as the existing fileDetails fire-and-forget
pattern. Added _wait_for_file_action() as a small reusable bridge
between that async callback delivery and the synchronous HTTP handler,
via a per-action threading.Event registered in _on_file.

New endpoints: GET /kx/printer-files, POST /kx/printer-files/delete
(single endpoint for both single- and multi-select delete, since
deleteBatch natively accepts a filename list).

Frontend mirrors the existing store multi-select pattern (Issue #94):
select mode, select-all (scoped to what's loaded), bulk delete with
confirmation. No print/download actions in this tab for now - printing
a file already on the printer without re-uploading needs its own MQTT
schema that hasn't been verified yet.

Verified end-to-end against the real printer: listed 146 files,
deleted one, confirmed via a follow-up list that it was gone and
nothing else was affected. Also verified visually (Playwright):
tab switching, card rendering, multi-select mode, and cancel all work
as expected.
2026-07-27 14:04:22 +02:00
gitea-actions
4e7f851799 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly46 release 2026-07-27 02:07:48 +00:00
1d5ac8dc4e chore(ci): reset NIGHTLY_CHANGELOG.md after each nightly release
All checks were successful
Nightly Build / build (push) Successful in 7m25s
NIGHTLY_CHANGELOG.md was never cleared by either nightly.yml or
release.yml - it only grew across every push, so each nightly release
kept re-listing every entry since the last manual reset instead of
just what changed since the previous build.

Add a step after the Gitea nightly release is created that resets the
file to its empty header and pushes the reset commit to nightly. Not
part of the workflow's own push-trigger paths, so it doesn't
re-trigger a nightly build.
2026-07-27 00:18:44 +02:00
64c8bc32d7 README.de.md aktualisiert 2026-07-27 00:09:15 +02:00
0ca7618c85 fix(moonraker): metadata state-leak + terminal-state reset gaps (Issue #102)
All checks were successful
Nightly Build / build (push) Successful in 7m9s
Three related gaps found via careful moonraker-obico observation:

1. _build_file_metadata() read layer_height/total_layers/estimated_time
   from live self._state before falling back to the queried file's own
   GCodeStore row - so querying metadata for any file other than the
   currently/last tracked job leaked that job's values into the response.
   Live state is now only used when the query targets that same tracked
   file; any other filename relies solely on its own stored row.

2. curr_layer/total_layers were never reset at print end in either
   _on_print or _on_info - only explicitly overwritten if a later
   payload happened to carry those keys, otherwise stuck indefinitely.
   Additionally, a successful "finished" print only ever cleared
   file_ready (Issue #29's fix), while stoped/canceled reset every
   other per-job field (progress, filename, duration, ...) - finished
   now gets the same full reset. Found and fixed a knock-on bug this
   surfaced: the unconditional `self._state["filename"] = d.get(...)`
   right after the reset block would immediately undo the filename
   reset, since the printer's own finished/stoped/canceled payload
   still carries the just-ended job's filename - now only applied
   outside terminal states.

3. The printer's own "progress" during pre-print phases (auto_leveling/
   preheating/checking/updated/init) was forwarded as-is to
   virtual_sdcard.progress/display_status.progress, causing a
   non-monotonic jump-then-reset once real printing began. These phases
   no longer update the tracked progress value.

Reported by @fmontagna, who correctly identified all three as genuine
gaps rather than intentional behavior.
2026-07-26 23:29:32 +02:00
6d6df59ff2 feat(filament): auto-match combined ACE-RFID vendor+type strings (Issue #101)
Anycubic's ACE RFID system concatenates vendor + material + a truncated
serial into one `type` string for custom (third-party) RFID tags, e.g.
"GEEETECH PLA Bas" for a Geeetech PLA spool. The bridge previously
treated this whole string as an unknown material and fell back to a
neutral "Generic <type>" profile, even when the user had already
imported a matching OrcaSlicer profile via the ZIP import feature
(Issue #41) - forcing a manual per-slot reassignment every time that
spool was loaded. Anycubic Slicer Next resolves the same tag correctly.

Add two helpers next to the existing _normalize_material/_material_family:
- _parse_combined_rfid_type(): splits the raw type string, recognizes a
  known vendor as the first token (checked against the merged
  system+user filament library, so custom vendors like "Geeetech" that
  only exist in the user's imported profiles are included), and
  extracts the material family from the remainder via the existing
  _material_family() prefix search. Returns ("", "") for a vendorless
  string like plain "PLA", leaving normal spool reports untouched.
- _match_profile_by_vendor_family(): looks up an imported/system profile
  by (vendor, family) rather than exact name, since the truncated RFID
  string never contains the full profile name verbatim.

Wired into _build_lane_data() as a third resolution layer, after the
existing manual per-slot override and before the Generic-library
fallback - not persisted to config.ini, so it re-derives fresh on every
call and can't go stale if a differently-tagged spool is loaded later.
2026-07-26 23:19:36 +02:00
33b42e64cc fix(dashboard): tile resize clipping and phantom scrollbars (Issue #97)
All checks were successful
Nightly Build / build (push) Successful in 7m14s
Two dashboard tile CSS issues reported after resizing tiles in the
GridStack-based dashboard:

1. Shrinking the Progress tile's height clipped the lower content
   (time grid, filename, buttons) out of the visible area instead of
   making it scale or scroll - #card-progress had no flex layout, so
   its fixed-size children just overflowed silently under the generic
   overflow:auto rule. Applied the same display:flex;flex-direction:
   column pattern already used for #card-camera, with flex-shrink:0
   on the direct children so the container can properly scroll instead
   of hiding content off-screen.

2. Tiles could show a scrollbar even with nothing to scroll, from
   sub-pixel horizontal overflow under the blanket
   overflow-y:auto/overflow-x:hidden rule (was overflow:auto covering
   both axes) - split it so only vertical scroll is offered, which is
   the only axis dashboard content actually needs.

Verified visually via Playwright: resizing the progress tile to
h=2/h=4 no longer clips content, and a fan-card resize with
scrollHeight==clientHeight now shows no scrollbar.
2026-07-24 15:13:19 +02:00
16c1a8ee73 fix(camera): recover automatically after printer reboot rotates stream token (Issue #99)
The printer rotates its FLV/RTSP stream token on reboot. CameraCache.set_url()
was a bare assignment, so the three running ffmpeg loops never noticed - they
only re-read self._url at the top of their outer loop, which they never reach
while permanently blocked in a stdout read on the now-silent, stale-token
connection (TCP stays ESTABLISHED with no data and no FIN, so a passive
reader can't tell "peer is quiet" from "peer is gone").

Three-part fix, all per the excellent root-cause analysis and reproduction
in the issue (thanks @fmontagna):

1. set_url() now detects a URL change and calls reset() to tear down the
   stale ffmpeg loops, so the next ensure_running() respawns them against
   the new URL.
2. ffmpeg's -timeout option (10s, applies to both RTSP and HTTP-FLW since
   both share _input_args) as a second line of defense for a source that
   goes silent while the URL stays the same (network loss, camera hang).
3. handle_camera_stream now waits up to 5s for the first frame BEFORE
   calling resp.prepare() and returns 503 on timeout. Previously it had no
   first-frame timeout at all, and prepare() commits the response status
   to 200 - so a stalled source could never surface as an error to the
   client, only as an infinite hang.
2026-07-24 14:10:54 +02:00
884bdfc0c3 fix(ams): log rejected multiColorBox setInfo with the triggering request
The printer's failure payload for a rejected slot assignment carries
no slot/type/color info of its own, only an opaque
["multi_color_box", [{"filaments": {"id": N}, "id": box_id}]] shape -
useless for diagnosing why an assignment was rejected.

Remember the last setInfo request (_last_ams_set_request) and log it
alongside the failure so bridge logs alone can answer "what exact
type/color was sent, and to which slot" without asking the user to
dig it out manually or run extra reproduction steps.
2026-07-24 14:01:04 +02:00
8f14580e30 fix(ams): don't crash on rejected multiColorBox setInfo (Issue #100)
The printer replies with state="failed" and data as a 2-element list
(["multi_color_box", [...]]) instead of the usual dict when it rejects
a manual ACE slot filament assignment (e.g. custom-RFID/third-party
material). _on_multicolor_box called data.get(...) unconditionally,
crashing with AttributeError and silently dropping the report -
including the regular slot-state update that would otherwise follow.

Add a state="failed" guard plus a defensive isinstance check, and
surface the rejection via _state["last_ams_set_error"] so the caller
of handle_api_ams_set_slot's optimistic cache update isn't left
showing a false success.

Note: this fixes the crash, not necessarily the underlying rejection
itself - the printer/ACE may still refuse assignments for material
types it doesn't recognize.
2026-07-24 13:57:17 +02:00
20daa6b6b8 Merge PR #98: fix(ams) don't map AMS placeholders to empty trays
Printing via OrcaSlicer "Upload and Print" failed (or fed the wrong
spool) when a slot below the used filament was empty. Gap placeholders
in _build_auto_ams_box_mapping now point their ams_index at a loaded
tray instead of the gap's own empty index. All-slots-full path
unchanged.
2026-07-24 13:48:08 +02:00
Walter Almada B
2f222a0b93 fix(ams): point gap placeholders at a loaded tray, not an empty one
Some checks failed
PR Check / lint-and-test (pull_request) Failing after 1s
Printing via OrcaSlicer "Upload and print" failed or fed the wrong spool
when the slot below the used filament was empty (e.g. Filament 4 with slot
3 empty); all-slots-full worked.

_start_print -> _build_auto_ams_box_mapping keeps the AMS mapping positional
(entry N = TN) by inserting a placeholder at each gap. The placeholder's
ams_index pointed at the gap's own index, which for an empty slot references
a physically empty tray. The printer rejects a mapping entry aimed at an
empty tray even for a tool the GCode never calls, so the print broke.

Point gap placeholders (ams_index/color/material) at a definitely-loaded
fallback tray (the highest loaded slot) instead. Positional alignment is
preserved; the all-full path is unchanged.

Adds tests/test_auto_ams_box_mapping_empty_slot.py (invariant: every mapping
entry references a loaded/status-5 tray).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:31:10 -07:00
6e72346129 fix(ams): support multiple daisy-chained ACE units (Issue #95)
All checks were successful
Nightly Build / build (push) Successful in 7m17s
ace_direct mode (no toolhead buffer) only kept the first reported ACE
unit and silently dropped any others. Affects e.g. the Kobra S1 with two
ACE Pro units - the dashboard and OrcaSlicer sync only ever saw 4 of 8
slots. Global slot index is now box_id * 4 + local slot across all
units, matching the existing //4-%4 fallback in _global_to_box_slot.
Single-ACE (Kobra X) behavior is unchanged (unit id 0 -> same indices
as before).

feat(store): multi-select + bulk delete in the GCode browser (Issue #94)

Checkbox on every file card enters select mode; clicking anywhere on a
selected-mode card toggles it, existing per-file actions keep working via
stopPropagation. 'Select All' only affects the currently filtered/visible
files. Bulk delete is N parallel calls to the existing single-file DELETE
endpoint (no new backend route) with one confirmation dialog.
2026-07-19 18:44:46 +02:00
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
25 changed files with 2373 additions and 263 deletions

View File

@@ -174,3 +174,20 @@ jobs:
--data-binary @/tmp/release_body.json \
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases"
rm -f "$BODY_FILE" /tmp/release_body.json
- name: Reset NIGHTLY_CHANGELOG.md for the next build
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
. /tmp/nightly_version.env
# The changelog just consumed above must not carry over into the
# next nightly - otherwise every build re-lists all prior entries
# since the last manual reset instead of just what's new. Not in
# nightly.yml's own push-trigger paths, so this commit does not
# re-trigger this workflow.
printf '## Changes in this build\n\n' > NIGHTLY_CHANGELOG.md
git config user.name "gitea-actions"
git config user.email "actions@gitea.it-drui.de"
git add NIGHTLY_CHANGELOG.md
git commit -m "chore: reset NIGHTLY_CHANGELOG.md after nightly-${VERSION} release" || exit 0
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git HEAD:nightly

View File

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

View File

@@ -29,9 +29,7 @@ Feedback willkommen.</sub>
</div>
> [!CAUTION]
> **Laufende Wartungsarbeiten** — Wir strukturieren das Repository um (Branch-Modell, CI-Workflows, Beitragsprozess). Es kann zu Änderungen bei Branch-Namen, PR-Templates und der Art der Veröffentlichungen kommen. Wir entschuldigen uns für etwaige Unannehmlichkeiten. Handhabung, Workflow und langfristige Wartbarkeit werden dadurch deutlich verbessert.
>
> 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen.
---

View File

@@ -1 +1 @@
0.9.27
0.9.29

View File

@@ -588,15 +588,23 @@ class GCodeStore:
class CameraCache:
"""Zentraler Kamera-Demuxer.
Keeps ONE ffmpeg process open that reads the FLV stream from the printer
and produces two outputs in parallel:
Keeps ONE ffmpeg process per output type open that reads the FLV stream
from the printer and produces:
- MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot
- MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers
- MJPEG @ 15fps/640px -> fanout to all /api/camera/stream subscribers
(the live-view used by the dashboard AND by every Moonraker-compatible
client, since server.webcams.list advertises this same stream_url)
Damit:
* Only ONE FLV connection to the printer (solves the single-client limit / 429)
* Only ONE FLV connection to the printer per output type (solves the
single-client limit / 429) - previously /api/camera/stream opened a
brand-new, uncached ffmpeg + printer connection per HTTP client, which
competed with the cached jpeg/h264 connections for the printer's very
limited number of concurrent camera clients and caused intermittent
"stream unavailable" failures.
* Snapshots are instant (memory read, no ffmpeg spawn per request)
* Multiple parallel H.264 consumers possible (plugin + web UI + ...)
* Multiple parallel H.264/MJPEG consumers possible (plugin + web UI + ...)
Lazy start on the first consumer, auto-restart on ffmpeg crash.
"""
@@ -610,40 +618,91 @@ class CameraCache:
self.latest_jpeg: bytes = b""
self.latest_jpeg_ts: float = 0.0
self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set()
self.mjpeg_subscribers: "set[asyncio.Queue[bytes]]" = set()
self._proc_jpeg: "asyncio.subprocess.Process | None" = None
self._proc_h264: "asyncio.subprocess.Process | None" = None
self._proc_mjpeg: "asyncio.subprocess.Process | None" = None
self._task_jpeg: "asyncio.Task | None" = None
self._task_h264: "asyncio.Task | None" = None
self._task_mjpeg: "asyncio.Task | None" = None
self._lock = asyncio.Lock()
self._fail_count_jpeg: int = 0
self._fail_count_h264: int = 0
self._fail_count_mjpeg: int = 0
def set_url(self, url: str):
# A changed URL means the printer rotated its stream token (typically
# after a reboot). Running ffmpeg processes still hold the stale URL
# and will never pick it up on their own - they only re-read self._url
# at the top of their outer loop, which they never reach while blocked
# in a stdout read on the old, now-silent connection. Tear them down;
# the next ensure_running() respawns them against the new URL.
changed = bool(url and self._url and url != self._url)
self._url = url
if changed:
self.reset()
def reset(self):
"""Reset backoff counters and kill running ffmpeg processes."""
"""Reset backoff counters and forcefully tear down any running
ffmpeg loops - including cancelling their background tasks.
Only killing the ffmpeg subprocess is not enough: the owning task
might currently be sitting in `await asyncio.sleep(delay)` from a
previous exponential backoff (up to 300s) after an earlier failure.
Resetting the fail-count doesn't wake it up early, so a user
clicking "reset" could see nothing happen for minutes. Cancelling
the task guarantees an immediate, clean restart on the next
ensure_running() call.
"""
self._fail_count_jpeg = 0
self._fail_count_h264 = 0
for proc in (self._proc_jpeg, self._proc_h264):
self._fail_count_mjpeg = 0
for task in (self._task_jpeg, self._task_h264, self._task_mjpeg):
if task is not None and not task.done():
task.cancel()
for proc in (self._proc_jpeg, self._proc_h264, self._proc_mjpeg):
if proc is not None:
try:
proc.kill()
except Exception:
pass
self._task_jpeg = self._task_h264 = self._task_mjpeg = None
self._proc_jpeg = self._proc_h264 = self._proc_mjpeg = None
async def ensure_running(self):
if self._proc_jpeg is None or self._proc_jpeg.returncode is not None:
# NOTE: we check the *task* state, not self._proc_* - the process
# handle is only assigned later, inside the task body, once ffmpeg
# has actually been spawned. Checking self._proc_* here left a race
# window: two callers arriving before the newly-created task got a
# chance to run would both see "no process yet" and each spawn a
# duplicate ffmpeg + duplicate printer connection, silently
# orphaning the older one (whichever task's coroutine runs last
# overwrites the shared self._proc_* reference, so nobody keeps a
# handle to kill the earlier orphaned process). Task creation is
# synchronous, so checking self._task_* here is race-free.
if self._task_jpeg is None or self._task_jpeg.done():
self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
if self._proc_h264 is None or self._proc_h264.returncode is not None:
if self._task_h264 is None or self._task_h264.done():
self._task_h264 = asyncio.create_task(self._run_h264_loop())
if self._task_mjpeg is None or self._task_mjpeg.done():
self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop())
def _input_args(self, url: str) -> list[str]:
args = ["-fflags", "nobuffer", "-flags", "low_delay"]
args = ["-fflags", "nobuffer", "-flags", "low_delay",
# Bail out if the source goes silent. A printer reboot or
# network loss leaves the TCP connection ESTABLISHED with no
# data and no FIN, so a passive stdout read blocks forever
# without this (Issue #99). Value is microseconds.
"-timeout", "10000000"]
if url.lower().startswith("rtsp://"):
args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else:
args += ["-probesize", "500000", "-analyzeduration", "500000"]
# The printer's FLV source occasionally emits non-monotonic container
# timestamps (PTS jumps of days) while the video data itself stays
# valid. Without this flag ffmpeg's realtime pacing breaks on such a
# jump and the stream stalls after ~15-30 min (Issue #90).
args += ["-use_wallclock_as_timestamps", "1",
"-probesize", "500000", "-analyzeduration", "500000"]
return args
async def _run_jpeg_loop(self):
@@ -790,6 +849,98 @@ class CameraCache:
self._fail_count_h264 = 0
await asyncio.sleep(2.0)
async def _run_mjpeg_loop(self):
"""Keeps an ffmpeg process alive that fans out MJPEG@15fps/640px
(complete JPEG frames) to all /api/camera/stream subscribers."""
while True:
url = self._url
if not url:
await asyncio.sleep(2.0)
continue
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-vf", "fps=15,scale=640:-1",
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
"-flush_packets", "1", "pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._proc_mjpeg = proc
except Exception as e:
log.warning(f"CameraCache: ffmpeg-mjpeg start failed: {e}")
await asyncio.sleep(3.0)
continue
buf = b""
rc = None
try:
while True:
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
buf += chunk
# extract complete JPEG frames and fan them out whole
# (so every subscriber gets clean multipart boundaries,
# not arbitrary byte chunks like the h264/mpegts fanout)
while True:
start = buf.find(self.JPEG_SOI)
if start == -1:
buf = b""
break
end = buf.find(self.JPEG_EOI, start + 2)
if end == -1:
buf = buf[start:]
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
for q in list(self.mjpeg_subscribers):
if q.full():
try:
q.get_nowait()
except Exception:
pass
try:
q.put_nowait(frame)
except Exception:
pass
except Exception as e:
log.debug(f"CameraCache: mjpeg-loop unterbrochen: {e}")
finally:
# NOTE: cleanup operates on the local `proc` reference, not
# on self._proc_mjpeg. If this task got cancelled (e.g. by
# reset()) a new task may already have started and assigned
# its own process to self._proc_mjpeg by the time we reach
# here - killing that shared attribute instead of our own
# local proc would kill the WRONG (newer) process.
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode
if rc:
try:
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-mjpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
if self._proc_mjpeg is proc:
self._proc_mjpeg = None
if rc:
self._fail_count_mjpeg += 1
delay = min(2.0 * (2 ** self._fail_count_mjpeg), 300.0)
log.warning(f"CameraCache: ffmpeg-mjpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_mjpeg})")
await asyncio.sleep(delay)
else:
self._fail_count_mjpeg = 0
await asyncio.sleep(2.0)
class SpoolmanClient:
"""Thin synchronous HTTP client for Spoolman filament tracking.
@@ -852,6 +1003,7 @@ class KobraXBridge:
except Exception:
self._visible_vendors = []
self._last_state: dict = {}
self._last_ams_set_request: dict | None = None
self._state = {
"nozzle_temp": 0.0,
"nozzle_target": 0.0,
@@ -888,6 +1040,8 @@ class KobraXBridge:
"filament_mode": "toolhead",
"supplies_usage": 0,
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
"error_code": 0,
"pause_msg": "",
}
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot
@@ -897,6 +1051,17 @@ class KobraXBridge:
self._head_tools_model: int = -1
self._filament_mode: str = "toolhead"
self._last_uploaded_file: str = ""
# Pending waiters for a specific file/report `action` (e.g. "listLocal",
# "deleteBatch"). publish()'s own return value for these actions is just
# a generic immediate ACK skeleton (code=0, all fields empty) - the real
# answer arrives later via the file/report callback (_on_file), same
# as the existing fileDetails fire-and-forget pattern. Format:
# {action: {"event": threading.Event(), "result": dict|None}}.
self._file_action_waiters: dict[str, dict] = {}
# Thumbnail cache for files on the printer's own storage (filename ->
# base64 PNG string, "" if the file has no embedded thumbnail).
# In-memory only - not persisted, cleared on restart.
self._printer_thumbnail_cache: dict[str, str] = {}
self._store = store if store is not None else GCodeStore(args.data_dir)
self._serve_dir_path: str = self._store._gcode_dir
self._current_job_id: str = ""
@@ -1200,6 +1365,17 @@ class KobraXBridge:
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # release for the next print
if kobra_state in ("pause", "paused"):
pause_msg = payload.get("msg", "")
if pause_msg:
error_code = payload.get("code", 0)
self._state["error_code"] = error_code
self._state["pause_msg"] = pause_msg
log.warning(f"Printer paused: [{error_code}] {pause_msg}")
elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"):
self._state["error_code"] = 0
self._state["pause_msg"] = ""
# Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id:
@@ -1229,12 +1405,12 @@ class KobraXBridge:
self._spoolman_notify_end()
self._current_job_id = ""
# Hide the upload banner after the print finishes (Issue #29): the
# printer reports "finished" after a successful print - file_ready used to be
# cleared only on stoped/canceled, which brought the banner back.
if kobra_state == "finished":
self._state["file_ready"] = ""
if kobra_state in ("stoped", "canceled"):
# Terminal states (successful finish AND stop/cancel) must leave the
# same clean end state - a "finished" print used to only clear
# file_ready (Issue #29), leaving progress/filename/duration/layer
# fields stuck at the last job's values until the *next* print
# happened to overwrite them (Issue #102).
if kobra_state in ("finished", "stoped", "canceled"):
self._state["progress"] = 0.0
self._state["filename"] = ""
self._state["file_ready"] = ""
@@ -1244,9 +1420,20 @@ class KobraXBridge:
self._state["layer_height"] = 0.0
self._state["first_layer_height"] = 0.0
self._state["supplies_usage"] = 0
self._state["curr_layer"] = 0
self._state["total_layers"] = 0
self._thumbnail_b64 = ""
self._state["filename"] = d.get("filename", self._state["filename"])
if "progress" in d:
else:
# Only adopt the payload's filename outside terminal states - the
# printer often still reports the just-finished job's filename in
# the same "finished"/"stoped"/"canceled" message that triggered
# the reset above, which would otherwise immediately undo it.
self._state["filename"] = d.get("filename", self._state["filename"])
# Pre-print phases (leveling/preheating/checking) report their own
# "progress" - passing it through would make display_status.progress/
# virtual_sdcard.progress jump non-monotonically once real printing
# starts and the value resets (Issue #102).
if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"):
self._state["progress"] = float(d["progress"]) / 100.0
if "print_time" in d:
self._state["print_duration"] = int(d["print_time"]) * 60
@@ -1282,8 +1469,13 @@ class KobraXBridge:
self._state["kobra_state"] = kobra_state
# Hide the upload banner after the print ends (Issue #29) - the state also
# arrives via info/report (project.state) depending on the printer, not only print/report.
# Layer fields must reset here too (Issue #102) - info/report is the
# only source for curr_layer/total_layers on some printers, and they
# otherwise stay stuck at the last job's values indefinitely.
if kobra_state in ("finished", "stoped", "canceled"):
self._state["file_ready"] = ""
self._state["curr_layer"] = 0
self._state["total_layers"] = 0
# Camera auto-start here as well (OrcaSlicer often reports the start via info/report).
# The _camera_autostarted guard prevents a double start with _on_print.
if kobra_state == "printing":
@@ -1302,7 +1494,8 @@ class KobraXBridge:
if project:
if "filename" in project:
self._state["filename"] = project["filename"]
if "progress" in project:
# Same non-monotonic-progress guard as _on_print (Issue #102).
if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"):
self._state["progress"] = float(project["progress"]) / 100.0
if "print_time" in project:
self._state["print_duration"] = int(project["print_time"]) * 60
@@ -1377,7 +1570,41 @@ class KobraXBridge:
if payload.get("state") == "done" or payload.get("code") == 200:
log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}")
def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None:
"""Sends a file/* MQTT request (via send_fn, which must call
self.client.publish(..., timeout=0) fire-and-forget) and blocks the
calling thread until a matching file/report with this `action`
arrives via _on_file, or the timeout elapses.
Needed because the printer's publish() return value for actions like
listLocal/deleteBatch is just a generic immediate ACK skeleton
(code=0, empty fields) - the real response is a separate, later
file/report message, same as the existing fileDetails pattern.
Must be called from a worker thread (e.g. via run_in_executor), not
the asyncio event loop, since it blocks on a threading.Event.
"""
event = threading.Event()
waiter = {"event": event, "result": None}
self._file_action_waiters[action] = waiter
try:
send_fn()
event.wait(timeout)
return waiter["result"]
finally:
if self._file_action_waiters.get(action) is waiter:
del self._file_action_waiters[action]
def _on_file(self, payload: dict):
# Deliver to any pending listLocal/deleteBatch waiter first (see
# _wait_for_file_action) - these actions carry no file_details/
# thumbnail payload of their own, so this doesn't interfere with the
# handling below.
action = payload.get("action") or ""
waiter = self._file_action_waiters.get(action)
if waiter is not None:
waiter["result"] = payload
waiter["event"].set()
d = payload.get("data") or {}
details = d.get("file_details") or {}
thumb = details.get("thumbnail") or details.get("png_image") or ""
@@ -1435,7 +1662,10 @@ class KobraXBridge:
Modes:
- toolhead: only toolhead slots
- ace_direct: ACE channels directly mapped (no toolhead box present)
- ace_direct: ACE channels directly mapped, no toolhead box present.
Covers one unit (Kobra X) as well as multiple daisy-chained units
(Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a
block of 4 global slots at box_id * 4.
- ace_hub: toolhead + ACE via hub (slot 4 as hub path)
"""
toolhead = any(b.get("id") == -1 for b in boxes)
@@ -1471,19 +1701,22 @@ class KobraXBridge:
return global_slots, global_loaded
if mode == "ace_direct":
# ace_direct exposes exactly 4 channels total.
# If firmware reports multiple ACE boxes, keep only the first one.
if ace_boxes:
ace = ace_boxes[0]
ace_id = ace["id"]
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
s = dict(s)
s["global_index"] = local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if 0 <= ace_loaded < 4:
global_loaded = ace_loaded
# One or more ACE units, no toolhead buffer (Kobra X: 1 unit,
# Kobra S1: up to 2+ units, Issue #95). Global index =
# box_id * 4 + local slot, so the numbering matches
# _global_to_box_slot's //4-%4 fallback and stays stable
# regardless of report order.
for ace in ace_boxes:
ace_id = int(ace["id"])
base = ace_id * 4
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
s = dict(s)
s["global_index"] = base + local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if 0 <= ace_loaded < 4:
global_loaded = base + ace_loaded
return global_slots, global_loaded
# ace_hub
@@ -1606,6 +1839,14 @@ class KobraXBridge:
max_idx = max(loaded_map.keys())
# The printer interprets ams_box_mapping as an ordered list (entry N = TN).
# Missing slots must be inserted as placeholders, otherwise everything shifts.
# A placeholder must NOT reference a physically empty tray: the printer
# rejects such an entry even for a tool the GCode never calls (printing
# Filament 4 with the slot below it empty fails; all-full works). Point
# gap placeholders at a definitely-loaded tray instead of the gap's own
# (empty) index.
fallback_gidx = max_idx # highest loaded slot -> loaded + printable
fallback_slot = loaded_map[fallback_gidx]
fallback_ams = self._slot_to_print_ams_index(fallback_gidx)
result = []
for i in range(max_idx + 1):
if i in loaded_map:
@@ -1620,10 +1861,10 @@ class KobraXBridge:
else:
result.append({
"paint_index": i,
"ams_index": self._slot_to_print_ams_index(i),
"ams_index": fallback_ams,
"paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255],
"material_type": "PLA",
"ams_color": self._slot_color_rgba(fallback_slot),
"material_type": fallback_slot.get("type", "PLA"),
})
return result
@@ -1674,20 +1915,18 @@ class KobraXBridge:
if box_id == -1:
return local_slot
if self._filament_mode == "ace_direct":
return local_slot
# Multi-ACE (Issue #95): each unit occupies its own block of 4.
# Identical to the old `return local_slot` for a single unit (id 0).
return box_id * 4 + local_slot
return 3 + box_id * 4 + local_slot
def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
"""Build {global_slot_index: loading|unloading} from feed_status data."""
# Note: all boxes are considered — the old primary_ace_id filter (skip
# every ACE box except the first in ace_direct mode) is gone since the
# slot aggregation now handles multiple ACE units (Issue #95).
activity: dict = {}
primary_ace_id = -1
if self._filament_mode == "ace_direct":
ace_ids = sorted(int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0)
if ace_ids:
primary_ace_id = ace_ids[0]
for box in boxes:
if self._filament_mode == "ace_direct" and primary_ace_id >= 0 and int(box.get("id", -1)) != primary_ace_id:
continue
fs = box.get("feed_status") or {}
current_status = int(fs.get("current_status", -1))
local_slot = int(fs.get("slot_index", -1))
@@ -1717,10 +1956,21 @@ class KobraXBridge:
return activity
def _on_multicolor_box(self, payload: dict):
if payload.get("state") == "failed":
req = getattr(self, "_last_ams_set_request", None)
log.warning(
f"multiColorBox setInfo rejected by printer: request={req} raw_response={payload.get('data')}"
)
self._state["last_ams_set_error"] = True
return
data = payload.get("data") or {}
if not isinstance(data, dict):
log.warning(f"multiColorBox/report: unexpected data shape: {data!r}")
return
boxes = data.get("multi_color_box") or []
if not boxes:
return
self._state["last_ams_set_error"] = False
self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model))
self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model)
self._state["filament_mode"] = self._filament_mode
@@ -1935,6 +2185,51 @@ class KobraXBridge:
return fam
return m
def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]:
"""Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
"GEEETECH PLA Bas", written via third-party RFID tools) into
(vendor, material_family).
Anycubic's ACE RFID system concatenates vendor + material + a
truncated serial/variant into one `type` string for custom tags -
unlike a normal spool report where `type` is just "PLA"/"PETG"/etc.
Returns ("", "") when the first token isn't a known vendor (from the
merged system+user filament library), which leaves plain type
strings like "PLA" completely unaffected (Issue #101).
"""
tokens = raw_type.split()
if len(tokens) < 2:
return "", ""
first = tokens[0].strip().lower()
vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()}
vendor = vendors.get(first)
if not vendor:
return "", ""
family = self._material_family(" ".join(tokens[1:]))
if not family:
return "", ""
return vendor, family
def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict:
"""Find an imported/system filament profile by (vendor, material
family) - used to auto-resolve a combined ACE-RFID type string to
the user's already-imported OrcaSlicer profile (Issue #101), since
the exact profile `name` never appears verbatim in the truncated
RFID string."""
matches = [
p for p in self._load_orca_filaments()
if p.get("vendor", "").lower() == vendor.lower()
and self._material_family(p.get("type", "")) == family
]
if not matches:
return {}
if len(matches) > 1:
log.debug(
f"_match_profile_by_vendor_family: {len(matches)} profiles match "
f"vendor={vendor!r} family={family!r}, using first: {matches[0].get('name')}"
)
return matches[0]
def _profile_material(self, profile: dict) -> str:
"""Material type (e.g. "PETG") of a saved slot profile, resolved by
(vendor, name) from the Orca filament library. Returns "" when the
@@ -2018,6 +2313,21 @@ class KobraXBridge:
# family still matches the loaded filament (PETG profile + PLA
# loaded -> dropped).
user_profile = self._effective_slot_profile(slot_index, material)
if not user_profile.get("name"):
# Third layer: auto-resolve a combined ACE-RFID "VENDOR TYPE
# SERIAL" string (e.g. "GEEETECH PLA Bas", from third-party
# RFID tools) against the user's already-imported profile
# library, instead of falling through to the neutral Generic
# fallback (Issue #101). Not persisted to config.ini - this
# re-derives on every _build_lane_data() call, so a
# differently-tagged spool loaded later isn't stuck with a
# stale match.
vendor_guess, family_guess = self._parse_combined_rfid_type(slot.get("type", ""))
if vendor_guess:
auto_profile = self._match_profile_by_vendor_family(vendor_guess, family_guess)
if auto_profile.get("name"):
user_profile = auto_profile
material = family_guess
if user_profile.get("name"):
vendor = user_profile.get("vendor", "")
fila_name = user_profile.get("name", "")
@@ -2511,6 +2821,93 @@ class KobraXBridge:
return self._json_cors({"result": "ok"})
return self._json_cors({"error": "not found"}, status=404)
async def handle_kx_printer_files(self, request):
"""GET /kx/printer-files - lists files on the printer's OWN internal
storage (file/listLocal MQTT action), as opposed to /kx/files which
lists what the bridge itself has stored. Needed because prints
started directly from Anycubic Slicer Next (bypassing the bridge)
leave files on the printer that were previously only visible/
deletable from the printer's own display (Issue #102 context)."""
loop = asyncio.get_event_loop()
def _fetch():
return self._wait_for_file_action(
"listLocal",
lambda: self.client.publish(
"file", "listLocal",
{"page_num": 1, "page_size": 200, "path": "/"},
timeout=0,
),
timeout=8.0,
)
result = await loop.run_in_executor(None, _fetch)
if not result or result.get("code") != 200:
return self._json_cors({"error": "printer unreachable or query failed"}, status=502)
records = (result.get("data") or {}).get("records") or []
files = [r for r in records if not r.get("is_dir")]
return self._json_cors({"result": files})
async def handle_kx_printer_file_delete(self, request):
"""POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}.
Single endpoint for both single and multi-select delete - the
printer's file/deleteBatch MQTT action natively accepts a list."""
try:
body = await request.json()
except Exception:
body = {}
filenames = body.get("filenames") or []
if not filenames:
return self._json_cors({"error": "no filenames given"}, status=400)
files = [{"path": "/", "filename": fn} for fn in filenames if fn]
loop = asyncio.get_event_loop()
def _delete():
return self._wait_for_file_action(
"deleteBatch",
lambda: self.client.publish(
"file", "deleteBatch",
{"root": "local", "files": files},
timeout=0,
),
timeout=8.0,
)
result = await loop.run_in_executor(None, _delete)
if not result or result.get("state") != "success":
return self._json_cors({"error": "delete failed", "detail": result}, status=502)
return self._json_cors({"result": "ok"})
async def handle_kx_printer_file_thumbnail(self, request):
"""GET /kx/printer-files/{filename}/thumbnail - fetches the embedded
GCode thumbnail for a file on the printer's own storage, via
file/fileDetails. The printer extracts and base64-encodes the
"; thumbnail begin"-block from the GCode header on demand and
returns it inline in data.file_details.thumbnail - no separate
download/presigned-URL step needed (verified live against a real
Kobra X). Cached in-memory per filename since a file's thumbnail
never changes while it exists on the printer, and re-querying on
every render/scroll would mean one MQTT roundtrip per visible card."""
filename = request.match_info.get("filename", "")
if not filename:
return self._json_cors({"error": "no filename given"}, status=400)
cached = self._printer_thumbnail_cache.get(filename)
if cached is not None:
return self._json_cors({"result": {"thumbnail": cached}})
loop = asyncio.get_event_loop()
def _fetch():
return self._wait_for_file_action(
"fileDetails",
lambda: self.client.publish(
"file", "fileDetails",
{"root": "local", "filename": filename},
timeout=0,
),
timeout=8.0,
)
result = await loop.run_in_executor(None, _fetch)
if not result or result.get("code") != 200:
return self._json_cors({"error": "printer unreachable or query failed"}, status=502)
thumb = ((result.get("data") or {}).get("file_details") or {}).get("thumbnail") or ""
self._printer_thumbnail_cache[filename] = thumb
return self._json_cors({"result": {"thumbnail": thumb}})
async def handle_kx_file_download(self, request):
file_id = request.match_info["file_id"]
f = self._store.get_file(file_id)
@@ -3157,10 +3554,16 @@ class KobraXBridge:
`modified` are non-nullable in GCodeFile; `print_start_time` and the
Slicer-Felder optional."""
s = self._state
layer_h = float(s.get("layer_height") or 0.0)
first_h = float(s.get("first_layer_height") or 0.0)
total_layers = int(s.get("total_layers") or 0)
est_time = int(s.get("slicer_time") or 0)
# Live _state values are only relevant for the currently/last tracked
# job's own file - using them as a starting point for a DIFFERENT
# filename leaked the tracked job's layer count/time into unrelated
# metadata queries (Issue #102). For any other filename, rely solely
# on that file's own GCodeStore row.
is_tracked_file = bool(filename) and filename == s.get("filename")
layer_h = float(s.get("layer_height") or 0.0) if is_tracked_file else 0.0
first_h = float(s.get("first_layer_height") or 0.0) if is_tracked_file else 0.0
total_layers = int(s.get("total_layers") or 0) if is_tracked_file else 0
est_time = int(s.get("slicer_time") or 0) if is_tracked_file else 0
size_bytes = 0
try:
gf = self._store.get_file_by_name(filename) or {}
@@ -3629,7 +4032,7 @@ class KobraXBridge:
# filter applies and empty/shifted slots are not mapped incorrectly.
gcode_filaments = None
try:
db_file = self._db.get_file_by_name(filename)
db_file = self._store.get_file_by_name(filename)
if db_file and db_file.get("gcode_filaments"):
gcode_filaments = json.loads(db_file["gcode_filaments"])
except Exception as e:
@@ -3759,21 +4162,33 @@ class KobraXBridge:
# (only the bare HTML) -> without inlining neither
# a single button works there (Issue #29). It is equally correct in a normal browser.
base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme)
try:
css = pathlib.Path(os.path.join(base, "style.css")).read_text(encoding="utf-8")
page = page.replace(
'<link rel="stylesheet" href="/kx/ui/style.css">',
"<style>\n" + css + "\n</style>")
except OSError:
pass
try:
js = pathlib.Path(os.path.join(base, "app.js")).read_text(encoding="utf-8")
js = js.replace("'__VERSION__'", f"'{self._read_version()}'")
page = page.replace(
'<script src="/kx/ui/app.js"></script>',
"<script>\n" + js + "\n</script>")
except OSError:
pass
# Inline vendored lib CSS/JS too — the OrcaSlicer webview loads no
# external <link>/<script src>, so GridStack (and its stylesheet) must
# be embedded like style.css/app.js. Order matters: GridStack's <script>
# sits in <head>, before app.js, so it is defined when app.js inits.
def _inline_css(rel_path: str, link_tag: str):
nonlocal page
try:
data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
page = page.replace(link_tag, "<style>\n" + data + "\n</style>")
except OSError:
pass
def _inline_js(rel_path: str, script_tag: str, version_sub: bool = False):
nonlocal page
try:
data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
if version_sub:
data = data.replace("'__VERSION__'", f"'{self._read_version()}'")
page = page.replace(script_tag, "<script>\n" + data + "\n</script>")
except OSError:
pass
_inline_css("lib/gridstack.min.css", '<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">')
_inline_js("lib/gridstack-all.min.js", '<script src="/kx/ui/lib/gridstack-all.min.js"></script>')
_inline_css("style.css", '<link rel="stylesheet" href="/kx/ui/style.css">')
_inline_js("app.js", '<script src="/kx/ui/app.js"></script>', version_sub=True)
return web.Response(text=page, content_type="text/html",
headers={"Cache-Control": "no-store, no-cache, must-revalidate"})
@@ -3863,6 +4278,11 @@ class KobraXBridge:
return web.json_response({"error": "color must be [r,g,b]"}, status=400)
box_id, local_slot = self._global_to_box_slot(index)
loop = asyncio.get_event_loop()
self._state["last_ams_set_error"] = False
# Remembered so a later state="failed" report (which carries no slot
# info of its own, see _on_multicolor_box) can be logged alongside the
# request that triggered it - otherwise the failure is unattributable.
self._last_ams_set_request = {"global": index, "box": box_id, "local_slot": local_slot, "type": mat, "color": color}
# setInfo goes via the web/printer topic (like tempature/set). Verified via
# Workbench-Vue mqtt_setInfo verifiziert — via slicer/printer/ wurden
# slot changes are ignored by the printer and overwritten with the old
@@ -4121,10 +4541,15 @@ class KobraXBridge:
Useful after a 429 lock (Retry-After expired) or after a printer restart."""
self.camera_cache.reset()
url = self._state.get("camera_url", "")
if url:
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
return web.json_response({"result": "ok"})
if not url:
log.warning("Camera reset requested but no camera_url is known yet (waiting for printer status)")
return web.json_response({
"result": "no_url",
"message": "No camera URL known yet - wait for the next printer status update, or start a print/enable the camera first.",
})
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
return web.json_response({"result": "ok", "url": url})
async def handle_api_camera_snapshot(self, request):
"""Last JPEG frame from the CameraCache - instant from RAM,
@@ -4132,10 +4557,10 @@ class KobraXBridge:
printer and is ~1 s faster)."""
url = self._state.get("camera_url", "")
if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt")
return web.Response(status=503, text="No camera URL known")
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
# Initialer Warmup: bis zu 5 s auf ersten Frame warten
# Initial warmup: wait up to 5s for the first frame
deadline = time.time() + 5.0
while not self.camera_cache.latest_jpeg and time.time() < deadline:
await asyncio.sleep(0.1)
@@ -4151,43 +4576,36 @@ class KobraXBridge:
return web.Response(body=jpeg, content_type="image/jpeg", headers=headers)
async def handle_camera_stream(self, request):
"""MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace."""
"""MJPEG live view, served as multipart/x-mixed-replace.
Fed from the central CameraCache fanout (same pattern as
handle_camera_h264) instead of spawning a dedicated ffmpeg process
per HTTP client. The printer's camera server only tolerates a very
limited number of concurrent connections (see CameraCache docstring)
- previously every consumer of this endpoint (dashboard, OrcaSlicer,
moonraker-obico, a second browser tab, ...) opened its own separate
connection, so two simultaneous viewers could already exhaust the
printer's connection limit and cause intermittent "stream
unavailable" failures. Now all consumers share one connection.
"""
url = self._state.get("camera_url", "")
if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt")
return web.Response(status=503, text="No camera URL known")
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
is_rtsp = url.lower().startswith("rtsp://")
ffmpeg_input_args = [
"-fflags", "nobuffer",
"-flags", "low_delay",
]
if is_rtsp:
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else:
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8)
self.camera_cache.mjpeg_subscribers.add(q)
# Start ffmpeg BEFORE the StreamResponse is opened
# (so we can still send a normal HTTP response on error)
# Wait for the first frame BEFORE resp.prepare() - once prepare() sends
# the response headers the status is committed to 200, so a stalled
# source (Issue #99) must be caught here to actually return a 503
# instead of hanging the client forever with no frame ever arriving.
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "quiet",
*ffmpeg_input_args,
"-i", url,
"-vf", "fps=15,scale=640:-1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "3",
"-flush_packets", "1",
"pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except (FileNotFoundError, OSError) as e:
log.warning("Camera: ffmpeg not found camera stream unavailable")
return web.Response(status=503, text="ffmpeg not found")
except Exception as e:
log.warning(f"Camera: could not start ffmpeg: {e}")
return web.Response(status=503, text=str(e))
first_frame = await asyncio.wait_for(q.get(), timeout=5.0)
except asyncio.TimeoutError:
self.camera_cache.mjpeg_subscribers.discard(q)
return web.Response(status=503, text="No frame in cache yet")
boundary = "kobraxframe"
resp = web.StreamResponse(headers={
@@ -4196,46 +4614,25 @@ class KobraXBridge:
"Connection": "keep-alive",
})
await resp.prepare(request)
buf = b""
try:
frame = first_frame
while True:
chunk = await proc.stdout.read(65536)
if not chunk:
header = (
f"--{boundary}\r\n"
f"Content-Type: image/jpeg\r\n"
f"Content-Length: {len(frame)}\r\n\r\n"
).encode()
try:
await resp.write(header + frame + b"\r\n")
except (ConnectionResetError, asyncio.CancelledError):
break
buf += chunk
# Extract complete JPEG frames (SOI=FFD8, EOI=FFD9)
while True:
start = buf.find(b"\xff\xd8")
if start == -1:
buf = b""
break
end = buf.find(b"\xff\xd9", start + 2)
if end == -1:
buf = buf[start:]
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
header = (
f"--{boundary}\r\n"
f"Content-Type: image/jpeg\r\n"
f"Content-Length: {len(frame)}\r\n\r\n"
).encode()
try:
await resp.write(header + frame + b"\r\n")
except Exception:
return resp
except Exception:
break
frame = await q.get()
except Exception as e:
log.warning(f"Camera stream interrupted: {e}")
finally:
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
self.camera_cache.mjpeg_subscribers.discard(q)
return resp
@@ -4245,7 +4642,7 @@ class KobraXBridge:
additional FLV connection to the printer (single-client limit)."""
url = self._state.get("camera_url", "")
if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt")
return web.Response(status=503, text="No camera URL known")
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
@@ -4340,6 +4737,8 @@ class KobraXBridge:
"file_ready": s["file_ready"],
"print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)),
"version": self._read_version(),
"pause_msg": s.get("pause_msg", ""),
"error_code": s.get("error_code", 0),
})
async def handle_moonraker_database(self, request):
@@ -5466,6 +5865,9 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete)
r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download)
r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify)
r.add_get("/kx/printer-files", bridge.handle_kx_printer_files)
r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete)
r.add_get("/kx/printer-files/{filename}/thumbnail", bridge.handle_kx_printer_file_thumbnail)
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)

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

@@ -0,0 +1,111 @@
"""Auto-matching for custom ACE-RFID filament tags (Issue #101).
Anycubic's ACE RFID system concatenates vendor + material + a truncated
serial into one `type` string for custom (third-party) tags, e.g.
"GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for
"Basic"). Previously the bridge treated this whole string as an unknown
material and fell back to a neutral "Generic <type>" profile, even though
the user had already imported a matching OrcaSlicer profile via the ZIP
import feature (Issue #41) - requiring a manual per-slot reassignment every
time that spool was loaded.
_parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this
automatically against the merged system+user filament library.
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
USER_PROFILES = [
{"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""},
{"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
]
def _bridge(profiles=USER_PROFILES):
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="kxrfid-"),
)
b = KobraXBridge(c, args=args)
b._orca_filaments_cache = profiles
return b
def test_combined_rfid_type_parses_known_vendor_and_family():
b = _bridge()
vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas")
assert vendor == "Geeetech"
assert family == "PLA"
def test_plain_type_string_is_unaffected():
"""Regression guard: a normal type="PLA" report (no vendor prefix) must
not be mistaken for a combined RFID string."""
b = _bridge()
vendor, family = b._parse_combined_rfid_type("PLA")
assert vendor == ""
assert family == ""
def test_unknown_vendor_prefix_returns_no_match():
b = _bridge()
vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas")
assert vendor == ""
assert family == ""
def test_match_profile_by_vendor_family_finds_imported_profile():
b = _bridge()
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
assert profile.get("name") == "Geeetech PLA Basic"
def test_match_profile_by_vendor_family_no_match_returns_empty():
b = _bridge()
profile = b._match_profile_by_vendor_family("Geeetech", "PETG")
assert profile == {}
def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing():
profiles = USER_PROFILES + [
{"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True},
]
b = _bridge(profiles)
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk")
def test_build_lane_data_auto_resolves_combined_rfid_slot():
"""End-to-end: a slot reporting the combined RFID string should surface
the imported Geeetech profile in lane_data instead of the Generic
fallback."""
b = _bridge()
b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path
b._filament_mode = "ace_hub"
b._ams_slots = [
{"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]},
]
lane = b._build_lane_data()
tray = lane["ams"][0]["tray"][0]
assert tray["vendor_name"] == "Geeetech"
assert tray["name"] == "Geeetech PLA Basic"
def test_build_lane_data_plain_type_still_uses_generic_fallback():
"""Regression guard: everyday type="PLA" slots must keep using the
existing Generic-library fallback, unaffected by the new matching path."""
b = _bridge()
b._filament_profiles = {} # no manual per-slot override - isolate the fallback path
b._filament_mode = "ace_hub"
b._ams_slots = [
{"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]},
]
lane = b._build_lane_data()
tray = lane["ams"][0]["tray"][0]
assert tray["name"] == "Generic PLA"
assert tray["vendor_name"] == "Generic"

View File

@@ -0,0 +1,85 @@
"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path.
Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it
EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping`
inserts a positional placeholder at each gap whose `ams_index` points at the
gap's own (physically EMPTY) tray. The printer rejects a mapping entry that
references an empty tray, even for a tool the GCode never calls.
Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray.
Positional alignment (entry N = TN, from a16062f) must be preserved.
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3).
AMS_SLOTS = [
{"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]},
{"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]},
{"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY
{"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]},
]
def _bridge(slots=AMS_SLOTS):
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="kxauto-"),
)
b = KobraXBridge(c, args=args)
b._filament_mode = "toolhead"
b._ams_slots = [dict(s) for s in slots]
return b
def _loaded_ams_indices(b):
return {b._slot_to_print_ams_index(int(s["global_index"]))
for s in b._ams_slots if s["status"] == 5}
def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded():
"""_start_print filters loaded to the used slot only: loaded=[(3, slot3)].
Positions 0..2 are placeholders and must NOT reference empty tray idx 2."""
b = _bridge()
loaded = [(3, b._ams_slots[3])]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept
loaded_ams = _loaded_ams_indices(b)
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set():
"""All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at
position 2 must not reference the empty tray idx 2."""
b = _bridge()
loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3]
loaded_ams = _loaded_ams_indices(b)
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
# Real loaded slots keep their own ams_index.
assert mapping[0]["ams_index"] == 0
assert mapping[1]["ams_index"] == 1
assert mapping[3]["ams_index"] == 3
def test_all_full_is_unchanged():
"""All slots loaded -> no placeholders, identity mapping (the working case)."""
slots = [dict(s, status=5) for s in AMS_SLOTS]
b = _bridge(slots)
loaded = [(i, b._ams_slots[i]) for i in range(4)]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3]

View File

@@ -0,0 +1,51 @@
"""Camera stream hangs forever after printer reboot (Issue #99).
The printer rotates its stream token on reboot, changing the camera URL.
CameraCache.set_url() used to be a bare assignment - the running ffmpeg loops
never noticed since they only re-read self._url at the top of their outer
loop, which they never reach while permanently blocked reading stdout from
the now-silent, stale-token connection. set_url() must detect the change and
tear the loops down so the next ensure_running() respawns them against the
new URL.
"""
from kobrax_moonraker_bridge import CameraCache
def test_set_url_first_time_does_not_reset():
"""No prior URL - nothing stale to tear down."""
c = CameraCache()
calls = []
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert c._url == "http://printer/live/tokenA"
assert not calls
def test_set_url_same_value_does_not_reset():
c = CameraCache()
calls = []
c.set_url("http://printer/live/tokenA")
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert not calls
def test_set_url_changed_triggers_reset():
"""The actual bug scenario: token rotates after a printer reboot."""
c = CameraCache()
calls = []
c.set_url("http://printer/live/tokenA")
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenB")
assert c._url == "http://printer/live/tokenB"
assert calls == [True]
def test_set_url_empty_to_value_does_not_reset():
"""Startup case: no URL known yet, first status push sets it - nothing
running to tear down."""
c = CameraCache()
calls = []
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert not calls

View File

@@ -0,0 +1,151 @@
"""server/files/metadata state-leak + terminal-state reset gaps (Issue #102).
Reported by @fmontagna via moonraker-obico:
1. Querying metadata for a file OTHER than the currently/last tracked job
leaked that job's live layer count / estimated time into the response,
because _build_file_metadata() read from self._state first and only fell
back to the file's own GCodeStore row when the state value was falsy.
2. curr_layer/total_layers (and, for a successful "finished" print, every
other per-job field) were never reset at print end - they stayed at the
last job's values until the next print happened to overwrite them.
3. The printer reports its own "progress" during pre-print phases
(preheating/auto_leveling/checking/...), which used to pass straight
through to display_status.progress/virtual_sdcard.progress and then jump
non-monotonically once real printing started and progress reset.
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
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="kxmeta-"),
)
return KobraXBridge(c, args=args)
def _insert_store_row(b, filename, layer_count=None, est_time=None, size_bytes=0):
with b._store._lock:
b._store._conn.execute(
"INSERT OR REPLACE INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) "
"VALUES (?,?,?,?,?,?,?)",
(filename, filename, "/tmp/" + filename, size_bytes, "2026-01-01T00:00:00Z", layer_count, est_time),
)
b._store._conn.commit()
# --- Fix 1: metadata state-leak -------------------------------------------
def test_metadata_for_untracked_file_does_not_leak_running_job_state():
b = _bridge()
# A job is "running": live state has its own layer/time values.
b._state["filename"] = "running.gcode"
b._state["total_layers"] = 999
b._state["slicer_time"] = 12345
b._state["layer_height"] = 0.3
# Querying a DIFFERENT, unrelated file must use ITS OWN store row, not
# the running job's live state.
_insert_store_row(b, "other.gcode", layer_count=42, est_time=600, size_bytes=1000)
meta = b._build_file_metadata("other.gcode")
assert meta["layer_count"] == 42
assert meta["estimated_time"] == 600
assert meta["size"] == 1000
def test_metadata_for_tracked_file_still_uses_live_state():
"""The currently-tracked file's OWN metadata query should still prefer
live state (fresher than what was known at upload time)."""
b = _bridge()
b._state["filename"] = "running.gcode"
b._state["total_layers"] = 55
b._state["slicer_time"] = 999
_insert_store_row(b, "running.gcode", layer_count=1, est_time=1)
meta = b._build_file_metadata("running.gcode")
assert meta["layer_count"] == 55
assert meta["estimated_time"] == 999
def test_metadata_for_nonexistent_file_falls_back_cleanly():
b = _bridge()
b._state["filename"] = "running.gcode"
b._state["total_layers"] = 999
meta = b._build_file_metadata("DOES_NOT_EXIST.gcode")
assert meta["layer_count"] is None
assert meta["size"] == 1 # documented fallback, unrelated to this fix
# --- Fix 2: terminal-state reset -------------------------------------------
def _print_payload(state, **extra):
"""print/report envelope: `state` is top-level, everything else is under
`data` (see _on_print: kobra_state = payload.get("state", "")). """
d = {"filename": "job.gcode"}
d.update(extra)
return {"state": state, "data": d}
def test_finished_resets_layer_fields_like_stoped_canceled():
b = _bridge()
b._state["curr_layer"] = 10
b._state["total_layers"] = 20
b._state["progress"] = 0.5
b._state["filename"] = "job.gcode"
b._on_print(_print_payload("finished"))
assert b._state["curr_layer"] == 0
assert b._state["total_layers"] == 0
assert b._state["progress"] == 0.0
assert b._state["filename"] == ""
def test_canceled_resets_layer_fields():
b = _bridge()
b._state["curr_layer"] = 7
b._state["total_layers"] = 20
b._on_print(_print_payload("canceled"))
assert b._state["curr_layer"] == 0
assert b._state["total_layers"] == 0
def test_on_info_resets_layer_fields_on_terminal_state():
b = _bridge()
b._state["curr_layer"] = 7
b._state["total_layers"] = 20
b._on_info({"data": {"project": {"state": "finished"}}})
assert b._state["curr_layer"] == 0
assert b._state["total_layers"] == 0
# --- Fix 3: progress clamping during pre-print phases ----------------------
def test_progress_not_updated_during_auto_leveling():
b = _bridge()
b._state["progress"] = 0.0
b._on_print(_print_payload("auto_leveling", progress=87))
assert b._state["progress"] == 0.0
def test_progress_not_updated_during_preheating():
b = _bridge()
b._state["progress"] = 0.0
b._on_print(_print_payload("preheating", progress=42))
assert b._state["progress"] == 0.0
def test_progress_updates_normally_once_printing():
b = _bridge()
b._on_print(_print_payload("printing", progress=33))
assert b._state["progress"] == 0.33
def test_on_info_progress_clamped_during_checking():
b = _bridge()
b._state["progress"] = 0.0
b._on_info({"data": {"project": {"state": "checking", "progress": 55}}})
assert b._state["progress"] == 0.0

View File

@@ -0,0 +1,117 @@
"""Multi-ACE aggregation in ace_direct mode (Issue #95, Kobra S1).
A Kobra S1 with two daisy-chained ACE Pro units reports
multi_color_box = [{id:0, slots:[4]}, {id:1, slots:[4]}] with NO toolhead
entry (id:-1). The old ace_direct branch kept only ace_boxes[0], silently
dropping the second unit — the dashboard and the OrcaSlicer sync only ever
saw 4 of the 8 slots. Payloads below are trimmed from the real log attached
to the issue.
"""
from kobrax_moonraker_bridge import KobraXBridge
def _slot(index, type_="PLA", color=(1, 2, 3), status=5):
return {
"index": index, "sku": "", "type": type_, "color": list(color),
"edit_status": 0, "status": status,
"color_group": [list(color) + [255]], "icon_type": 0,
"consumables_percent": 50,
}
def _ace_box(box_id, loaded_slot=-1, n_slots=4):
return {
"id": box_id, "status": 1, "model_id": 0, "auto_feed": 1,
"loaded_slot": loaded_slot,
"feed_status": {"code": 200, "type": -1, "current_status": -1, "slot_index": -1},
"temp": 30, "humidity": 0,
"drying_status": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"slots": [_slot(i) for i in range(n_slots)],
}
def _toolhead_box(n_slots=4):
box = _ace_box(-1, n_slots=n_slots)
return box
# ── mode detection ───────────────────────────────────────────────────────────
def test_two_ace_units_no_toolhead_is_ace_direct():
boxes = [_ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_direct"
# ── ace_direct aggregation ───────────────────────────────────────────────────
def test_single_ace_unit_yields_4_slots():
"""Kobra X regression: one unit, global indices 0-3 exactly as before."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0)], "ace_direct")
assert len(slots) == 4
assert [s["global_index"] for s in slots] == [0, 1, 2, 3]
assert all(s["box_id"] == 0 for s in slots)
assert loaded == -1
def test_two_ace_units_yield_8_slots():
"""Issue #95: the second unit's slots must appear as global 4-7."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
assert len(slots) == 8
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_two_ace_units_report_order_does_not_matter():
"""Boxes sorted by id — global numbering stays stable if the firmware
reports unit 1 before unit 0."""
slots, _ = KobraXBridge._aggregate_slots([_ace_box(1), _ace_box(0)], "ace_direct")
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_loaded_slot_on_second_unit_maps_to_global():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0), _ace_box(1, loaded_slot=2)], "ace_direct")
assert loaded == 6 # 1*4 + 2
def test_loaded_slot_on_first_unit_unchanged():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0, loaded_slot=3), _ace_box(1)], "ace_direct")
assert loaded == 3
# ── ace_hub regression (unchanged behavior) ──────────────────────────────────
def test_ace_hub_numbering_unchanged():
boxes = [_toolhead_box(), _ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_hub"
slots, _ = KobraXBridge._aggregate_slots(boxes, "ace_hub")
# 3 toolhead + 4 + 4 ACE
assert len(slots) == 11
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert [s["box_id"] for s in slots][:3] == [-1, -1, -1]
# ── _box_local_to_global / _global_to_box_slot round-trip ────────────────────
def _bridge_with_mode(mode, slots):
b = object.__new__(KobraXBridge)
b._filament_mode = mode
b._ams_slots = slots
return b
def test_box_local_to_global_ace_direct_second_unit():
b = _bridge_with_mode("ace_direct", [])
assert b._box_local_to_global(0, 2, []) == 2
assert b._box_local_to_global(1, 2, []) == 6
def test_global_to_box_slot_round_trip_two_units():
slots, _ = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
b = _bridge_with_mode("ace_direct", slots)
for g in range(8):
box_id, local = b._global_to_box_slot(g)
assert (box_id, local) == (g // 4, g % 4)
assert b._box_local_to_global(box_id, local, []) == g

View File

@@ -0,0 +1,81 @@
"""AttributeError crash on multiColorBox/report failure (Issue #100).
Real KX2-Pro bug: manually assigning a filament profile to an ACE slot
(custom-RFID / third-party filament) gets rejected by the printer. Instead of
echoing the normal success shape, the printer replies with `state: "failed"`
and `data` as a 2-element LIST (`["multi_color_box", [...]]`) instead of the
usual dict (`{"multi_color_box": [...]}`). `_on_multicolor_box` called
`data.get(...)` unconditionally, crashing with
`AttributeError: 'list' object has no attribute 'get'` and silently dropping
the report (including the slot-state update it would otherwise have done).
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
# Exact failure payload from the Issue #100 log.
FAILED_PAYLOAD = {
"state": "failed",
"data": ["multi_color_box", [{"filaments": {"id": 2}, "id": 0}]],
}
SUCCESS_PAYLOAD = {
"state": "success",
"data": {
"head_tools_model": 1,
"multi_color_box": [
{"id": -1, "slots": []},
{
"id": 0,
"slots": [
{"index": 0, "type": "PLA", "color": [0, 156, 189], "status": 5},
],
},
],
},
}
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="kxmcb-"),
)
return KobraXBridge(c, args=args)
def test_failed_state_does_not_crash():
b = _bridge()
b._on_multicolor_box(FAILED_PAYLOAD) # must not raise
assert b._state["last_ams_set_error"] is True
def test_success_after_failure_clears_error_flag():
b = _bridge()
b._on_multicolor_box(FAILED_PAYLOAD)
assert b._state["last_ams_set_error"] is True
b._on_multicolor_box(SUCCESS_PAYLOAD)
assert b._state["last_ams_set_error"] is False
def test_non_dict_data_without_failed_state_does_not_crash():
"""Defensive guard: any future non-dict `data` shape must not crash,
even if the printer doesn't set state="failed" for it."""
b = _bridge()
b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]})
def test_failed_report_is_logged_with_the_triggering_request(caplog):
"""The failure payload alone carries no slot/type/color info - the log
must correlate it with the setInfo request that triggered it, otherwise
the failure reason can't be diagnosed from bridge logs alone."""
import logging
b = _bridge()
b._last_ams_set_request = {"global": 6, "box": 0, "local_slot": 3, "type": "PLA", "color": [33, 39, 33]}
with caplog.at_level(logging.WARNING):
b._on_multicolor_box(FAILED_PAYLOAD)
assert any("global" in r.message and "6" in r.message for r in caplog.records)

View File

@@ -0,0 +1,233 @@
"""
Tests für /kx/printer-files (list) und /kx/printer-files/delete —
der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt
(via MQTT file/listLocal + file/deleteBatch, live gegen den echten
Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md).
Important: publish()'s own return value for these actions is just a
generic immediate ACK skeleton (code=0, empty fields) - the real answer
arrives asynchronously via the file/report callback (_on_file), same as
the existing fileDetails fire-and-forget pattern. So publish() itself
returns None/skeleton here, and the "real" response is delivered by
firing bridge._on_file(...) from a background thread, simulating what
the MQTT reader thread would do when the printer's file/report arrives.
"""
import threading
import time
import pytest
LISTLOCAL_SUCCESS = {
"action": "listLocal",
"code": 200,
"state": "success",
"data": {
"list_mode": 0,
"records": [
{"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000},
{"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000},
{"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000},
],
},
}
LISTLOCAL_FAILED = {
"action": "listLocal",
"code": 10112,
"state": "failed",
"data": None,
}
DELETEBATCH_SUCCESS = {
"action": "deleteBatch",
"code": 200,
"state": "success",
"data": None,
"msg": "done",
}
DELETEBATCH_FAILED = {
"action": "deleteBatch",
"code": 10112,
"state": "failed",
"data": None,
}
FILEDETAILS_SUCCESS = {
"action": "fileDetails",
"code": 200,
"state": "done",
"data": {
"file_details": {
"thumbnail": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB",
"png_image": "",
"svg_image": "",
"objects_skip_parts": [],
},
"filename": "a.gcode",
"root": "local",
},
}
FILEDETAILS_NO_THUMBNAIL = {
"action": "fileDetails",
"code": 200,
"state": "done",
"data": {
"file_details": {"thumbnail": "", "png_image": "", "svg_image": "", "objects_skip_parts": []},
"filename": "a.gcode",
"root": "local",
},
}
FILEDETAILS_FAILED = {
"action": "fileDetails",
"code": 10112,
"state": "failed",
"data": None,
}
def _deliver_async(bridge, payload, delay=0.05):
"""Simulates the MQTT reader thread delivering a file/report a moment
after the fire-and-forget publish() call returns."""
def _fire():
time.sleep(delay)
bridge._on_file(payload)
threading.Thread(target=_fire, daemon=True).start()
@pytest.mark.asyncio
async def test_printer_files_lists_files_and_excludes_dirs(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
resp = await c.get("/kx/printer-files")
assert resp.status == 200
data = await resp.json()
filenames = [f["filename"] for f in data["result"]]
assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded
@pytest.mark.asyncio
async def test_printer_files_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
await c.get("/kx/printer-files")
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "listLocal"
assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"}
@pytest.mark.asyncio
async def test_printer_files_returns_502_on_printer_failure(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1]
resp = await c.get("/kx/printer-files")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_files_returns_502_on_timeout(client):
c, bridge = client
bridge.client.publish.return_value = None # no file/report ever arrives
resp = await c.get("/kx/printer-files")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_delete_success(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
assert resp.status == 200
data = await resp.json()
assert data["result"] == "ok"
@pytest.mark.asyncio
async def test_printer_file_delete_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]})
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "deleteBatch"
assert args[2] == {
"root": "local",
"files": [
{"path": "/", "filename": "a.gcode"},
{"path": "/", "filename": "b.gcode"},
],
}
@pytest.mark.asyncio
async def test_printer_file_delete_empty_filenames_returns_400(client):
c, _ = client
resp = await c.post("/kx/printer-files/delete", json={"filenames": []})
assert resp.status == 400
@pytest.mark.asyncio
async def test_printer_file_delete_returns_502_on_printer_rejection(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1]
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_thumbnail_success(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 200
data = await resp.json()
assert data["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@pytest.mark.asyncio
async def test_printer_file_thumbnail_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
await c.get("/kx/printer-files/a.gcode/thumbnail")
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "fileDetails"
assert args[2] == {"root": "local", "filename": "a.gcode"}
@pytest.mark.asyncio
async def test_printer_file_thumbnail_empty_when_no_thumbnail_embedded(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_NO_THUMBNAIL), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 200
data = await resp.json()
assert data["result"]["thumbnail"] == ""
@pytest.mark.asyncio
async def test_printer_file_thumbnail_returns_502_on_printer_failure(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_FAILED), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_thumbnail_is_cached_after_first_fetch(client):
"""Second request for the same filename must not call publish() again."""
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
resp1 = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp1.status == 200
call_count_after_first = bridge.client.publish.call_count
resp2 = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp2.status == 200
data2 = await resp2.json()
assert data2["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
assert bridge.client.publish.call_count == call_count_after_first # no new MQTT call

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

@@ -354,6 +354,14 @@ function applyLang(){
setText('store-web-verify-msg',T.store_web_verify_msg);
setText('store-web-verify-confirm',T.store_web_verify_confirm);
setText('store-web-verify-abort',T.store_web_verify_abort);
setText('store-lbl-select-all',T.store_select_all||'Select All');
setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
setText('store-lbl-exit-select',T.store_exit_select||'Cancel');
setText('btab-lbl-uploaded',T.browser_tab_uploaded||'Uploaded');
setText('btab-lbl-printer',T.browser_tab_printer||'On Printer');
setText('printer-store-lbl-select-all',T.store_select_all||'Select All');
setText('printer-store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
setText('printer-store-lbl-exit-select',T.store_exit_select||'Cancel');
// Dashboard card titles
setText('d-card-progress',T.card_progress);
setText('d-card-temps',T.card_temps);
@@ -409,6 +417,15 @@ function applyLang(){
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)');
@@ -582,6 +599,17 @@ function showSettingsCat(name){
var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active');
}
// Browser-Sub-Tab umschalten: hochgeladene Dateien (Bridge-Store) vs. Dateien
// auf dem Drucker selbst (interner Speicher, via listLocal MQTT).
var _printerFilesLoaded=false;
function showBrowserTab(name){
document.querySelectorAll('.browser-group').forEach(g=>g.classList.remove('active'));
document.querySelectorAll('.browser-tab').forEach(b=>b.classList.remove('active'));
var g=document.getElementById('browser-group-'+name);if(g)g.classList.add('active');
var t=document.getElementById('btab-'+name);if(t)t.classList.add('active');
if(name==='printer'&&!_printerFilesLoaded)loadPrinterFiles();
}
// ── Console log ──
var consoleLogs=[];
var logAutoScroll=true;
@@ -737,6 +765,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){
@@ -1971,8 +2010,313 @@ 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()})
@@ -2431,10 +2775,42 @@ function aceDryStop(aceId){
function loadStore(){
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
storeFiles=d.result||[];
// Reset any pending selection - stale ids from a deleted/renamed file
// should never carry over into a fresh file list.
storeExitSelectMode();
renderStore();
}).catch(function(e){clog('Store-Fehler: '+e,'msg-err')});
}
// Applies the store's search/filter/sort controls to storeFiles. Shared by
// renderStore() and storeToggleSelectAll() so "Select All" only selects what
// the user can currently see, not the entire (possibly filtered-out) list.
function _storeFilteredFiles(){
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
return files;
}
function uploadGcode(file){
if(!file) return;
var zone=document.getElementById('store-upload-zone');
@@ -2479,32 +2855,7 @@ function uploadGcode(file){
function renderStore(){
var grid=document.getElementById('store-grid');
var empty=document.getElementById('store-empty');
// Suche
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
// Filter
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
// Sortierung
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
var files=_storeFilteredFiles();
if(!storeFiles.length){
empty.textContent=T.store_empty;
@@ -2537,22 +2888,56 @@ function renderStore(){
} else if(!f.last_print_status){
lastInfo='<div style="font-size:11px;color:var(--txt2);margin-bottom:2px;opacity:.6">'+T.store_never+'</div>';
}
return '<div style="background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:10px;display:flex;flex-direction:column">'+
var isSelected=!!_storeSelected[f.id];
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
// Always visible (not just once select mode is on) - otherwise there is
// no way to ever enter select mode, since it's normally entered by
// clicking this very checkbox. White circle behind it so it stays
// legible against any thumbnail.
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
'align-items:center;justify-content:center">'+
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
' onclick="event.stopPropagation();storeToggleSelect(\''+f.id+'\')" '+
'style="width:16px;height:16px;margin:0"></span>';
var cardClick=_storeSelectMode?'onclick="storeToggleSelect(\''+f.id+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
return '<div '+cardClick+'>'+
checkbox+
thumb+
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+statusBadge+'</div>'+
lastInfo+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">⏱ '+T.store_estimate+': '+est+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
'<div style="display:flex;gap:6px;margin-top:auto">'+
'<button onclick="storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'<button onclick="event.stopPropagation();storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="flex:1;font-size:12px;padding:5px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer">'+T.store_print+'</button>'+
'<button onclick="storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
'<button onclick="event.stopPropagation();storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">⬇</button>'+
'<button onclick="storeDelete(\''+f.id+'\')" '+
'<button onclick="event.stopPropagation();storeDelete(\''+f.id+'\')" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
'</div>'+
'</div>';
}).join('');
_storeUpdateSelectBar(files);
}
// Reflects the current selection onto the select-all checkbox (incl.
// indeterminate state), the selected-count label, and the delete button's
// disabled state. `files` is the currently-filtered/visible list.
function _storeUpdateSelectBar(files){
var selectAll=document.getElementById('store-select-all');
var countEl=document.getElementById('store-selected-count');
var delBtn=document.getElementById('store-delete-selected-btn');
if(!selectAll||!countEl||!delBtn)return;
var visibleIds=files.map(function(f){return f.id;});
var selectedVisible=visibleIds.filter(function(id){return _storeSelected[id];});
var n=selectedVisible.length;
selectAll.checked=n>0&&n===visibleIds.length;
selectAll.indeterminate=n>0&&n<visibleIds.length;
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
delBtn.disabled=n===0;
}
function formatDur(sec){
@@ -2572,6 +2957,234 @@ var _pendingWebVerifyAutoOpen=false;
// wurde (Issue #29 / Theme-Auslagerung PR #27).
var storeFiles=[];
// Multi-select state for the GCode browser (Issue #94).
var _storeSelectMode=false;
var _storeSelected={}; // file.id -> true
function storeToggleSelect(id){
if(!_storeSelectMode){
_storeSelectMode=true;
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='flex';
}
if(_storeSelected[id])delete _storeSelected[id];
else _storeSelected[id]=true;
renderStore();
}
function storeToggleSelectAll(checked){
// Only the currently filtered/visible files are affected, so search/filter
// never causes "Select All" to silently pick up hidden files too.
var visible=_storeFilteredFiles();
_storeSelected={};
if(checked)visible.forEach(function(f){_storeSelected[f.id]=true;});
renderStore();
}
function storeExitSelectMode(){
_storeSelectMode=false;
_storeSelected={};
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='none';
renderStore();
}
function storeDeleteSelected(){
var ids=Object.keys(_storeSelected);
if(!ids.length)return;
if(!confirm((T.store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',ids.length)))return;
Promise.all(ids.map(function(id){
return fetch(_apiUrl('/kx/files/'+id),{method:'DELETE'}).then(function(r){return{id:id,ok:r.ok};});
})).then(function(results){
var failed=results.filter(function(r){return !r.ok;});
if(failed.length)clog((T.log_delete_failed||'Delete failed')+': '+failed.length,'msg-err');
storeExitSelectMode();
loadStore();
});
}
// ── Files on the printer's own internal storage (Issue: 2nd Browser tab) ──
// Uses filename as the identity key (the printer has no numeric file id like
// the bridge's own GCodeStore) and a single batch-delete call, since the
// printer's file/deleteBatch MQTT action natively accepts a filename list.
var printerFiles=[];
var _printerFilesSelectMode=false;
var _printerFilesSelected={}; // filename -> true
function loadPrinterFiles(){
var errEl=document.getElementById('printer-store-error');
if(errEl)errEl.style.display='none';
fetch(_apiUrl('/kx/printer-files')).then(function(r){return r.json()}).then(function(d){
_printerFilesLoaded=true;
if(d.error){
printerFiles=[];
if(errEl){errEl.textContent=T.printer_store_unreachable||d.error;errEl.style.display='block';}
renderPrinterFiles();
return;
}
printerFiles=d.result||[];
_printerFilesSelected={};
_printerFilesSelectMode=false;
var bar=document.getElementById('printer-store-select-bar');
if(bar)bar.style.display='none';
renderPrinterFiles();
}).catch(function(e){
_printerFilesLoaded=true;
if(errEl){errEl.textContent=T.printer_store_unreachable||String(e);errEl.style.display='block';}
});
}
function renderPrinterFiles(){
var grid=document.getElementById('printer-store-grid');
var empty=document.getElementById('printer-store-empty');
if(!grid||!empty)return;
if(!printerFiles.length){
empty.textContent=T.printer_store_empty||'No files on the printer.';
grid.innerHTML='';
empty.style.display='block';
return;
}
empty.style.display='none';
grid.innerHTML=printerFiles.map(function(f,idx){
var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename;
var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'';
var date=f.timestamp?new Date(f.timestamp).toISOString().replace('T',' ').slice(0,16):'';
var isSelected=!!_printerFilesSelected[f.filename];
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
'align-items:center;justify-content:center">'+
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
' onclick="event.stopPropagation();printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="width:16px;height:16px;margin:0"></span>';
var cardClick=_printerFilesSelectMode?'onclick="printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
var thumbId='pft-'+idx;
var cachedThumb=_printerThumbCache[f.filename];
var thumbHtml=cachedThumb
? '<img id="'+thumbId+'" src="data:image/png;base64,'+cachedThumb+'" style="width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px">'
: '<div id="'+thumbId+'" data-filename="'+encodeURIComponent(f.filename)+'" style="width:100%;height:60px;background:var(--raised);border-radius:6px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;font-size:28px">🖨</div>';
return '<div '+cardClick+'>'+
checkbox+
thumbHtml+
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">💾 '+sizeKb+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
'<div style="display:flex;gap:6px;margin-top:auto">'+
'<button onclick="event.stopPropagation();printerFileDelete(\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="flex:1;font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
'</div>'+
'</div>';
}).join('');
_printerFilesUpdateSelectBar();
_loadVisiblePrinterThumbnails();
}
// Thumbnails are fetched lazily, one at a time, only for cards not already
// cached - fetching all of them upfront would mean one MQTT roundtrip per
// file (145+ files is common), overwhelming the single MQTT connection.
var _printerThumbCache={}; // filename -> base64 PNG string ("" = no thumbnail)
var _printerThumbQueue=[];
var _printerThumbLoading=false;
function _loadVisiblePrinterThumbnails(){
var placeholders=document.querySelectorAll('#printer-store-grid [data-filename]');
_printerThumbQueue=Array.prototype.slice.call(placeholders);
_pumpPrinterThumbQueue();
}
function _pumpPrinterThumbQueue(){
if(_printerThumbLoading||!_printerThumbQueue.length)return;
var el=_printerThumbQueue.shift();
if(!el||!el.isConnected){_pumpPrinterThumbQueue();return;}
var encodedName=el.getAttribute('data-filename');
_printerThumbLoading=true;
fetch(_apiUrl('/kx/printer-files/'+encodedName+'/thumbnail'))
.then(function(r){return r.json()})
.then(function(d){
var thumb=(d.result&&d.result.thumbnail)||'';
_printerThumbCache[decodeURIComponent(encodedName)]=thumb;
if(thumb&&el.isConnected){
var img=document.createElement('img');
img.src='data:image/png;base64,'+thumb;
img.style.cssText='width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px';
img.id=el.id;
el.replaceWith(img);
}
})
.catch(function(){/* keep the placeholder icon on failure */})
.finally(function(){
_printerThumbLoading=false;
_pumpPrinterThumbQueue();
});
}
function _printerFilesUpdateSelectBar(){
var selectAll=document.getElementById('printer-store-select-all');
var countEl=document.getElementById('printer-store-selected-count');
var delBtn=document.getElementById('printer-store-delete-selected-btn');
if(!selectAll||!countEl||!delBtn)return;
var allNames=printerFiles.map(function(f){return f.filename;});
var selected=allNames.filter(function(n){return _printerFilesSelected[n];});
var n=selected.length;
selectAll.checked=n>0&&n===allNames.length;
selectAll.indeterminate=n>0&&n<allNames.length;
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
delBtn.disabled=n===0;
}
function printerFileToggleSelect(filename){
if(!_printerFilesSelectMode){
_printerFilesSelectMode=true;
var bar=document.getElementById('printer-store-select-bar');
if(bar)bar.style.display='flex';
}
if(_printerFilesSelected[filename])delete _printerFilesSelected[filename];
else _printerFilesSelected[filename]=true;
renderPrinterFiles();
}
function printerFileToggleSelectAll(checked){
_printerFilesSelected={};
if(checked)printerFiles.forEach(function(f){_printerFilesSelected[f.filename]=true;});
renderPrinterFiles();
}
function printerFileExitSelectMode(){
_printerFilesSelectMode=false;
_printerFilesSelected={};
var bar=document.getElementById('printer-store-select-bar');
if(bar)bar.style.display='none';
renderPrinterFiles();
}
function _printerFilesDeleteRequest(filenames){
return fetch(_apiUrl('/kx/printer-files/delete'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({filenames:filenames}),
}).then(function(r){return r.json().then(function(d){return{ok:r.ok,body:d};});});
}
function printerFileDelete(filename){
if(!confirm(T.printer_store_delete_confirm||'Delete file?'))return;
_printerFilesDeleteRequest([filename]).then(function(res){
if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;}
loadPrinterFiles();
});
}
function printerFileDeleteSelected(){
var names=Object.keys(_printerFilesSelected);
if(!names.length)return;
if(!confirm((T.printer_store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',names.length)))return;
_printerFilesDeleteRequest(names).then(function(res){
if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;}
printerFileExitSelectMode();
loadPrinterFiles();
});
}
var _gcodeFilaments=[];
function _setGcodeFilamentsFromFileObj(fileObj){

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 ═══ -->
@@ -374,36 +388,81 @@
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
<div class="browser-subtabs" style="display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--border)">
<button class="browser-tab active" id="btab-uploaded" onclick="showBrowserTab('uploaded')"
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
<span id="btab-lbl-uploaded">Hochgeladen</span></button>
<button class="browser-tab" id="btab-printer" onclick="showBrowserTab('printer')"
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
<span id="btab-lbl-printer">Auf dem Drucker</span></button>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
<div id="browser-group-uploaded" class="browser-group active">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
<span id="store-lbl-select-all">Alle auswählen</span>
</label>
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="storeExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
<div id="browser-group-printer" class="browser-group">
<div id="printer-store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="printer-store-error" style="display:none;color:var(--err);text-align:center;padding:20px 0;font-size:13px">
</div>
<div id="printer-store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="printer-store-select-all" onchange="printerFileToggleSelectAll(this.checked)">
<span id="printer-store-lbl-select-all">Alle auswählen</span>
</label>
<span id="printer-store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="printer-store-delete-selected-btn" disabled onclick="printerFileDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="printer-store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="printerFileExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="printer-store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="printer-store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></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-y:auto;overflow-x:hidden;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);
@@ -119,6 +164,11 @@ main{flex:1;overflow-y:auto;padding:20px}
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
/* Same pattern as #card-camera above: without this, shrinking the tile's
height just clips the lower content (time-grid/filename/buttons) outside
the visible area instead of making it scrollable (Issue #97). */
.grid-stack-item-content>#card-progress{display:flex;flex-direction:column;min-height:0}
.grid-stack-item-content>#card-progress>*{flex-shrink:0}
.hero-info{display:flex;flex-direction:column;gap:12px}
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}
@@ -161,6 +211,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}
@@ -266,6 +322,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
.set-cat .nav-text{display:inline}
}
/* ── BROWSER SUB-TABS (uploaded vs. on-printer files) ── */
.browser-tab.active{color:var(--accent);border-bottom-color:var(--accent)!important}
.browser-tab:hover{color:var(--txt)}
.browser-group{display:none}
.browser-group.active{display:block}
/* ── FILE BROWSER UPLOAD ZONE ── */
#store-upload-zone{
display:flex;flex-direction:column;align-items:center;justify-content:center;

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "z.B. Kobra X Wohnzimmer",
"apd_success": "Drucker hinzugefügt, Bridge startet neu…",
"apd_title": "Drucker hinzufügen",
"browser_tab_printer": "Auf dem Drucker",
"browser_tab_uploaded": "Hochgeladen",
"btn_cam_start": "▶ Kamera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Kamera",
@@ -68,6 +70,18 @@
"card_speed": "Druckgeschwindigkeit",
"card_temps": "Temperaturen",
"confirm_cancel": "Druck wirklich abbrechen?",
"dash_done": "Fertig",
"dash_edit": "Dashboard anpassen",
"dash_hidden_cards": "Ausgeblendete Karten",
"dash_hide": "Ausblenden",
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
"dash_preset_name_prompt": "Preset-Name:",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Desktop breit",
"dash_reset": "Zurücksetzen",
"dash_save_preset": "Als Preset speichern",
"dash_show": "Einblenden",
"dash_toggle_width": "Breite umschalten",
"fd_cancel": "Abbrechen",
"fd_no_matching_material": "Kein passendes Material",
"fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?",
@@ -121,6 +135,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)",
@@ -199,6 +214,10 @@
"panel_temps_chart": "Verlauf (letzte 60 Messungen)",
"panel_temps_nozzle": "Düse",
"print_auto_leveling": "Auto-Leveling für diesen Druck",
"printer_store_delete_confirm": "Datei vom Drucker löschen?",
"printer_store_delete_selected_confirm": "{n} ausgewählte Dateien vom Drucker löschen?",
"printer_store_empty": "Keine Dateien auf dem Drucker.",
"printer_store_unreachable": "Drucker nicht erreichbar oder Abfrage fehlgeschlagen.",
"printers_active": "● aktiv",
"printers_current": "Aktueller Drucker",
"printers_empty_hint": "Noch kein Drucker eingerichtet.",
@@ -212,7 +231,6 @@
"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",
@@ -246,7 +264,6 @@
"settings_password": "MQTT-Passwort",
"settings_poll": "Poll-Intervall (Sekunden)",
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
"settings_print": "Druckeinstellungen",
"settings_printer_ip": "Drucker-IP",
@@ -258,7 +275,9 @@
"settings_title": "Einstellungen",
"settings_username": "MQTT-Benutzername",
"settings_vendor_filter_placeholder": "Hersteller suchen…",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"settings_version": "Version",
"settings_vibration_compensation": "Resonanzkompensation",
"settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
"settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)",
@@ -280,6 +299,7 @@
"skip_sending": "Sende …",
"skip_success": "Objekte werden übersprungen.",
"skip_title": "✂ Objekte überspringen",
"slot_copy_from": "Farbe von Slot kopieren…",
"slot_edit_color": "Farbe",
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Einziehen",
@@ -298,15 +318,20 @@
"ss_dur": "⏱ Druckzeit",
"ss_name": "AZ Name",
"store_delete_confirm": "Datei löschen?",
"store_delete_selected": "Auswahl löschen",
"store_delete_selected_confirm": "{n} ausgewählte Dateien löschen?",
"store_download": "⬇ Download",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_estimate": "Schätzung",
"store_exit_select": "Abbrechen",
"store_never": "noch nicht gedruckt",
"store_no_results": "Keine Dateien gefunden.",
"store_print": "▶ Drucken",
"store_print_confirm": "Datei drucken?",
"store_refresh": "↻ Aktualisieren",
"store_search_placeholder": "🔍 Suche…",
"store_select_all": "Alle auswählen",
"store_selected_count": "{n} ausgewählt",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "durchsuchen",
@@ -326,6 +351,5 @@
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
"update_error": "Fehler",
"update_none": "Bereits aktuell",
"update_restarting": "Starte neu...",
"slot_copy_from": "Farbe von Slot kopieren…"
}
"update_restarting": "Starte neu..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "e.g. Kobra X Living Room",
"apd_success": "Printer added, bridge restarting…",
"apd_title": "Add printer",
"browser_tab_printer": "On Printer",
"browser_tab_uploaded": "Uploaded",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Camera",
@@ -68,6 +70,18 @@
"card_speed": "Print Speed",
"card_temps": "Temperatures",
"confirm_cancel": "Really cancel the print?",
"dash_done": "Done",
"dash_edit": "Customize dashboard",
"dash_hidden_cards": "Hidden cards",
"dash_hide": "Hide",
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
"dash_preset_name_prompt": "Preset name:",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Wide desktop",
"dash_reset": "Reset",
"dash_save_preset": "Save as preset",
"dash_show": "Show",
"dash_toggle_width": "Toggle width",
"fd_cancel": "Cancel",
"fd_no_matching_material": "No matching material",
"fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?",
@@ -121,6 +135,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)",
@@ -199,6 +214,10 @@
"panel_temps_chart": "History (last 60 readings)",
"panel_temps_nozzle": "Nozzle",
"print_auto_leveling": "Auto-Leveling",
"printer_store_delete_confirm": "Delete file from the printer?",
"printer_store_delete_selected_confirm": "Delete {n} selected files from the printer?",
"printer_store_empty": "No files on the printer.",
"printer_store_unreachable": "Printer unreachable or query failed.",
"printers_active": "● active",
"printers_current": "Current printer",
"printers_empty_hint": "No printer set up yet.",
@@ -212,7 +231,6 @@
"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",
@@ -246,7 +264,6 @@
"settings_password": "MQTT Password",
"settings_poll": "Poll Interval (seconds)",
"settings_poll_interval_hint": "How often the bridge queries printer status",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"settings_poll_interval_label": "Poll Interval (seconds)",
"settings_print": "Print Settings",
"settings_printer_ip": "Printer IP",
@@ -258,7 +275,9 @@
"settings_title": "Settings",
"settings_username": "MQTT Username",
"settings_vendor_filter_placeholder": "Search vendors…",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"settings_version": "Version",
"settings_vibration_compensation": "Resonance Compensation",
"settings_visible_vendors": "Visible vendors (profile dropdown)",
"settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.",
"settings_visible_vendors_label": "Visible vendors (profile dropdown)",
@@ -280,6 +299,7 @@
"skip_sending": "Sending …",
"skip_success": "Objects will be skipped.",
"skip_title": "✂ Skip objects",
"slot_copy_from": "Copy color from slot…",
"slot_edit_color": "Color",
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Load",
@@ -298,15 +318,20 @@
"ss_dur": "⏱ Print time",
"ss_name": "AZ Name",
"store_delete_confirm": "Delete file?",
"store_delete_selected": "Delete Selected",
"store_delete_selected_confirm": "Delete {n} selected files?",
"store_download": "⬇ Download",
"store_empty": "No files uploaded yet.",
"store_estimate": "Estimate",
"store_exit_select": "Cancel",
"store_never": "never printed",
"store_no_results": "No files found.",
"store_print": "▶ Print",
"store_print_confirm": "Print file?",
"store_refresh": "↻ Refresh",
"store_search_placeholder": "🔍 Search…",
"store_select_all": "Select All",
"store_selected_count": "{n} selected",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "browse",
@@ -326,6 +351,5 @@
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Already up to date",
"update_restarting": "Restarting...",
"slot_copy_from": "Copy color from slot…"
}
"update_restarting": "Restarting..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "p. ej. Kobra X Sala",
"apd_success": "Impresora añadida, reiniciando bridge…",
"apd_title": "Agregar impresora",
"browser_tab_printer": "En la impresora",
"browser_tab_uploaded": "Subidos",
"btn_cam_start": "▶ Cámara",
"btn_cam_start2": "▶ Iniciar",
"btn_cam_stop": "◼ Cámara",
@@ -68,6 +70,18 @@
"card_speed": "Velocidad de impresión",
"card_temps": "Temperaturas",
"confirm_cancel": "¿Realmente cancelar la impresión?",
"dash_done": "Listo",
"dash_edit": "Personalizar panel",
"dash_hidden_cards": "Tarjetas ocultas",
"dash_hide": "Ocultar",
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
"dash_preset_name_prompt": "Nombre del preset:",
"dash_preset_standard": "Estándar",
"dash_preset_wide89": "Escritorio ancho",
"dash_reset": "Restablecer",
"dash_save_preset": "Guardar como preset",
"dash_show": "Mostrar",
"dash_toggle_width": "Cambiar ancho",
"fd_cancel": "Cancelar",
"fd_no_matching_material": "No hay material compatible",
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
@@ -121,6 +135,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)",
@@ -199,6 +214,10 @@
"panel_temps_chart": "Historial (últimas 60 lecturas)",
"panel_temps_nozzle": "Boquilla",
"print_auto_leveling": "Autonivelado para esta impresión",
"printer_store_delete_confirm": "¿Eliminar archivo de la impresora?",
"printer_store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados de la impresora?",
"printer_store_empty": "No hay archivos en la impresora.",
"printer_store_unreachable": "Impresora inaccesible o consulta fallida.",
"printers_active": "● activa",
"printers_current": "Impresora actual",
"printers_empty_hint": "Aún no hay impresora configurada.",
@@ -212,7 +231,6 @@
"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",
@@ -246,7 +264,6 @@
"settings_password": "Contraseña MQTT",
"settings_poll": "Intervalo de sondeo (segundos)",
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
"settings_print": "Ajustes de impresión",
"settings_printer_ip": "IP de impresora",
@@ -258,7 +275,9 @@
"settings_title": "Configuración",
"settings_username": "Usuario MQTT",
"settings_vendor_filter_placeholder": "Buscar fabricantes…",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"settings_version": "Versión",
"settings_vibration_compensation": "Compensación de resonancia",
"settings_visible_vendors": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
"settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)",
@@ -280,6 +299,7 @@
"skip_sending": "Enviando …",
"skip_success": "Se omitirán los objetos.",
"skip_title": "✂ Omitir objetos",
"slot_copy_from": "Copiar color del slot…",
"slot_edit_color": "Color",
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Cargar",
@@ -298,15 +318,20 @@
"ss_dur": "⏱ Tiempo de impresión",
"ss_name": "AZ Nombre",
"store_delete_confirm": "¿Eliminar archivo?",
"store_delete_selected": "Eliminar seleccionados",
"store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados?",
"store_download": "⬇ Descargar",
"store_empty": "Aún no hay archivos subidos.",
"store_estimate": "Estimación",
"store_exit_select": "Cancelar",
"store_never": "nunca impreso",
"store_no_results": "No se encontraron archivos.",
"store_print": "▶ Imprimir",
"store_print_confirm": "¿Imprimir archivo?",
"store_refresh": "↻ Actualizar",
"store_search_placeholder": "🔍 Buscar…",
"store_select_all": "Seleccionar todo",
"store_selected_count": "{n} seleccionados",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "buscar",
@@ -326,6 +351,5 @@
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
"update_error": "Error",
"update_none": "Ya actualizado",
"update_restarting": "Reiniciando...",
"slot_copy_from": "Copiar color del slot…"
}
"update_restarting": "Reiniciando..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "ex. Kobra X Salon",
"apd_success": "Imprimante ajoutée, redémarrage du bridge…",
"apd_title": "Ajouter une imprimante",
"browser_tab_printer": "Sur l'imprimante",
"browser_tab_uploaded": "Téléversés",
"btn_cam_start": "▶ Caméra",
"btn_cam_start2": "▶ Démarrer",
"btn_cam_stop": "◼ Caméra",
@@ -121,6 +123,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)",
@@ -199,6 +202,10 @@
"panel_temps_chart": "Historique (60 dernières valeurs)",
"panel_temps_nozzle": "Buse",
"print_auto_leveling": "Mise à niveau auto pour cette impression",
"printer_store_delete_confirm": "Supprimer le fichier de l'imprimante ?",
"printer_store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés de l'imprimante ?",
"printer_store_empty": "Aucun fichier sur l'imprimante.",
"printer_store_unreachable": "Imprimante injoignable ou requête échouée.",
"printers_active": "● actif",
"printers_current": "Imprimante actuelle",
"printers_empty_hint": "Aucune imprimante configurée.",
@@ -278,6 +285,7 @@
"skip_sending": "Envoi …",
"skip_success": "Les objets seront ignorés.",
"skip_title": "✂ Ignorer des objets",
"slot_copy_from": "Copier la couleur du slot…",
"slot_edit_color": "Couleur",
"slot_edit_custom": "ex. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Charger",
@@ -296,15 +304,20 @@
"ss_dur": "⏱ Durée d'impression",
"ss_name": "AZ Nom",
"store_delete_confirm": "Supprimer le fichier ?",
"store_delete_selected": "Supprimer la sélection",
"store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés ?",
"store_download": "⬇ Télécharger",
"store_empty": "Aucun fichier uploadé.",
"store_estimate": "Estimation",
"store_exit_select": "Annuler",
"store_never": "jamais imprimé",
"store_no_results": "Aucun fichier trouvé.",
"store_print": "▶ Imprimer",
"store_print_confirm": "Imprimer le fichier ?",
"store_refresh": "↻ Actualiser",
"store_search_placeholder": "🔍 Rechercher…",
"store_select_all": "Tout sélectionner",
"store_selected_count": "{n} sélectionné(s)",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "parcourir",
@@ -324,6 +337,5 @@
"update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d",
"update_error": "Erreur",
"update_none": "Déjà à jour",
"update_restarting": "Redémarrage…",
"slot_copy_from": "Copier la couleur du slot…"
}
"update_restarting": "Redémarrage…"
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "es. Kobra X Soggiorno",
"apd_success": "Stampante aggiunta, riavvio del bridge in corso…",
"apd_title": "Aggiungi stampante",
"browser_tab_printer": "Sulla stampante",
"browser_tab_uploaded": "Caricati",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Avvia",
"btn_cam_stop": "◼ Camera",
@@ -121,6 +123,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)",
@@ -199,6 +202,10 @@
"panel_temps_chart": "Cronologia (ultime 60 letture)",
"panel_temps_nozzle": "Ugello",
"print_auto_leveling": "Livellamento automatico",
"printer_store_delete_confirm": "Eliminare il file dalla stampante?",
"printer_store_delete_selected_confirm": "Eliminare {n} file selezionati dalla stampante?",
"printer_store_empty": "Nessun file sulla stampante.",
"printer_store_unreachable": "Stampante non raggiungibile o richiesta fallita.",
"printers_active": "● attiva",
"printers_current": "Stampante corrente",
"printers_empty_hint": "Nessuna stampante ancora configurata.",
@@ -278,6 +285,7 @@
"skip_sending": "Invio in corso …",
"skip_success": "Gli oggetti verranno saltati.",
"skip_title": "✂ Salta oggetti",
"slot_copy_from": "Copia colore dallo slot…",
"slot_edit_color": "Colore",
"slot_edit_custom": "es. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Carica",
@@ -296,15 +304,20 @@
"ss_dur": "⏱ Tempo di stampa",
"ss_name": "Nome AZ",
"store_delete_confirm": "Eliminare il file?",
"store_delete_selected": "Elimina selezionati",
"store_delete_selected_confirm": "Eliminare {n} file selezionati?",
"store_download": "⬇ Scarica",
"store_empty": "Nessun file caricato.",
"store_estimate": "Stima",
"store_exit_select": "Annulla",
"store_never": "mai stampato",
"store_no_results": "Nessun file trovato.",
"store_print": "▶ Stampa",
"store_print_confirm": "Stampare il file?",
"store_refresh": "↻ Aggiorna",
"store_search_placeholder": "🔍 Cerca…",
"store_select_all": "Seleziona tutto",
"store_selected_count": "{n} selezionati",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "sfoglia",
@@ -324,6 +337,5 @@
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
"update_error": "Errore",
"update_none": "Già aggiornato",
"update_restarting": "Riavvio in corso...",
"slot_copy_from": "Copia colore dallo slot…"
}
"update_restarting": "Riavvio in corso..."
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "例如 Kobra X 客厅",
"apd_success": "打印机已添加Bridge 正在重启…",
"apd_title": "添加打印机",
"browser_tab_printer": "打印机上",
"browser_tab_uploaded": "已上传",
"btn_cam_start": "▶ 相机",
"btn_cam_start2": "▶ 启动",
"btn_cam_stop": "◼ 相机",
@@ -68,6 +70,18 @@
"card_speed": "打印速度",
"card_temps": "温度",
"confirm_cancel": "确定要取消打印吗?",
"dash_done": "完成",
"dash_edit": "自定义仪表盘",
"dash_hidden_cards": "隐藏的卡片",
"dash_hide": "隐藏",
"dash_preset_delete_confirm": "删除预设 \"{name}\"",
"dash_preset_name_prompt": "预设名称:",
"dash_preset_standard": "标准",
"dash_preset_wide89": "宽屏桌面",
"dash_reset": "重置",
"dash_save_preset": "另存为预设",
"dash_show": "显示",
"dash_toggle_width": "切换宽度",
"fd_cancel": "取消",
"fd_no_matching_material": "无匹配材料",
"fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?",
@@ -121,6 +135,7 @@
"lbl_feed": "进料",
"lbl_layers": "层",
"lbl_light": "💡 灯光",
"lbl_pause_reason": "打印已暂停:",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_spoolman_sync_rate": "同步频率0=打印结束)",
@@ -199,6 +214,10 @@
"panel_temps_chart": "历史 (最近 60 次读数)",
"panel_temps_nozzle": "喷嘴",
"print_auto_leveling": "本次打印自动调平",
"printer_store_delete_confirm": "从打印机删除文件?",
"printer_store_delete_selected_confirm": "从打印机删除选中的 {n} 个文件?",
"printer_store_empty": "打印机上没有文件。",
"printer_store_unreachable": "无法连接打印机或查询失败。",
"printers_active": "● 活动",
"printers_current": "当前打印机",
"printers_empty_hint": "尚未设置打印机。",
@@ -212,7 +231,6 @@
"progress_action_slots": "分配槽位",
"settings_auto_leveling": "打印前自动调平",
"settings_auto_leveling_label": "打印前自动调平",
"settings_vibration_compensation": "打印前共振补偿",
"settings_btn_tooltip": "设置",
"settings_camera_on_print": "打印开始时开启相机",
"settings_cat_connection": "连接",
@@ -246,7 +264,6 @@
"settings_password": "MQTT 密码",
"settings_poll": "轮询间隔(秒)",
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"settings_poll_interval_label": "轮询间隔(秒)",
"settings_print": "打印设置",
"settings_printer_ip": "打印机 IP",
@@ -258,7 +275,9 @@
"settings_title": "设置",
"settings_username": "MQTT 用户名",
"settings_vendor_filter_placeholder": "搜索厂商…",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"settings_version": "版本",
"settings_vibration_compensation": "打印前共振补偿",
"settings_visible_vendors": "可见厂商(配置下拉框)",
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
"settings_visible_vendors_label": "可见厂商(配置下拉框)",
@@ -280,6 +299,7 @@
"skip_sending": "发送中 …",
"skip_success": "对象将被跳过。",
"skip_title": "✂ 跳过对象",
"slot_copy_from": "从插槽复制颜色…",
"slot_edit_color": "颜色",
"slot_edit_custom": "例如 PLA, PETG, ABS…",
"slot_edit_load": "⬇ 进料",
@@ -298,15 +318,20 @@
"ss_dur": "⏱ 打印时间",
"ss_name": "AZ 名称",
"store_delete_confirm": "删除文件?",
"store_delete_selected": "删除所选",
"store_delete_selected_confirm": "删除已选择的 {n} 个文件?",
"store_download": "⬇ 下载",
"store_empty": "尚未上传文件。",
"store_estimate": "估算",
"store_exit_select": "取消",
"store_never": "从未打印",
"store_no_results": "未找到文件。",
"store_print": "▶ 打印",
"store_print_confirm": "打印文件?",
"store_refresh": "↻ 刷新",
"store_search_placeholder": "🔍 搜索…",
"store_select_all": "全选",
"store_selected_count": "已选择 {n} 个",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "浏览",
@@ -326,6 +351,5 @@
"update_docker_copied": "已复制执行docker compose pull && docker compose up -d",
"update_error": "错误",
"update_none": "已是最新版本",
"update_restarting": "重启中...",
"slot_copy_from": "从插槽复制颜色…"
}
"update_restarting": "重启中..."
}