'
- # → printer.auth_token aus der Antwort in die cfg eintragen
+ # → enter printer.auth_token from the response into the cfg
moonraker-obico:
image: gitea.it-drui.de/viewit/moonraker-obico:latest
container_name: moonraker-obico
@@ -195,7 +195,7 @@ networks:
kobrax-stack:
driver: bridge
-# Verzeichnisse müssen auf dem Host existieren:
+# Directories must exist on the host:
# mkdir -p /mnt/dockerdata/KobraXStack/kx-bridge/config \
# /mnt/dockerdata/KobraXStack/kx-bridge/data \
# /mnt/dockerdata/KobraXStack/spoolman \
@@ -203,8 +203,8 @@ networks:
# /mnt/dockerdata/KobraXStack/obico/frontend \
# /mnt/dockerdata/KobraXStack/obico/redis \
# /mnt/dockerdata/KobraXStack/moonraker-obico/logs
-# Spoolman benötigt UID/GID 1000:
+# Spoolman requires UID/GID 1000:
# sudo chown -R 1000:1000 /mnt/dockerdata/KobraXStack/spoolman
#
-# moonraker-obico Config anlegen (auth_token nach Obico-Setup eintragen):
+# Create the moonraker-obico config (enter auth_token after Obico setup):
# cp /path/to/moonraker-obico.cfg.example /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py
index bf321da..9edd842 100644
--- a/kobrax_moonraker_bridge.py
+++ b/kobrax_moonraker_bridge.py
@@ -631,7 +631,16 @@ class CameraCache:
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 forcefully tear down any running
@@ -679,7 +688,12 @@ class CameraCache:
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:
@@ -989,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,
@@ -1036,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 = ""
@@ -1569,34 +1595,12 @@ class KobraXBridge:
self._spoolman_notify_end()
self._current_job_id = ""
- # Benachrichtigungen bei Statuswechsel (filename vor Cleanup holen)
- if kobra_state and kobra_state != self._prev_kobra_state:
- _notif_filename = d.get("filename", self._state.get("filename", ""))
- if kobra_state == "printing":
- if not self._print_active:
- self._print_active = True
- self._notify("started", _notif_filename)
- self._last_progress_notif_time = time.monotonic()
- self._last_progress_notif_layer = self._state.get("curr_layer", 0)
- elif kobra_state == "finished":
- self._print_active = False
- self._notify("finished", _notif_filename)
- elif kobra_state == "failed":
- self._print_active = False
- self._notify("failed", _notif_filename)
- elif kobra_state in ("stoped", "canceled"):
- self._print_active = False
- self._notify("cancelled", _notif_filename)
- elif kobra_state == "paused":
- self._notify("paused", _notif_filename)
- self._prev_kobra_state = kobra_state
-
- # 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"] = ""
@@ -1606,9 +1610,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
@@ -1644,8 +1659,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":
@@ -1665,7 +1685,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
@@ -1740,7 +1761,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 ""
@@ -1975,6 +2030,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:
@@ -1989,10 +2052,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
@@ -2084,10 +2147,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
@@ -2302,6 +2376,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
@@ -2385,6 +2504,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", "")
@@ -2878,6 +3012,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)
@@ -3524,10 +3745,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 {}
@@ -4242,6 +4469,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
@@ -4556,6 +4788,16 @@ class KobraXBridge:
q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8)
self.camera_cache.mjpeg_subscribers.add(q)
+ # 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:
+ 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={
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
@@ -4564,8 +4806,8 @@ class KobraXBridge:
})
await resp.prepare(request)
try:
+ frame = first_frame
while True:
- frame = await q.get()
header = (
f"--{boundary}\r\n"
f"Content-Type: image/jpeg\r\n"
@@ -4577,6 +4819,7 @@ class KobraXBridge:
break
except Exception:
break
+ frame = await q.get()
except Exception as e:
log.warning(f"Camera stream interrupted: {e}")
finally:
@@ -5909,6 +6152,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)
diff --git a/tests/test_ace_rfid_vendor_matching.py b/tests/test_ace_rfid_vendor_matching.py
new file mode 100644
index 0000000..88b9135
--- /dev/null
+++ b/tests/test_ace_rfid_vendor_matching.py
@@ -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 " 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"
diff --git a/tests/test_auto_ams_box_mapping_empty_slot.py b/tests/test_auto_ams_box_mapping_empty_slot.py
new file mode 100644
index 0000000..7c90466
--- /dev/null
+++ b/tests/test_auto_ams_box_mapping_empty_slot.py
@@ -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]
diff --git a/tests/test_camera_cache_url_rotation.py b/tests/test_camera_cache_url_rotation.py
new file mode 100644
index 0000000..8cb4caf
--- /dev/null
+++ b/tests/test_camera_cache_url_rotation.py
@@ -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
diff --git a/tests/test_metadata_and_print_state_reset.py b/tests/test_metadata_and_print_state_reset.py
new file mode 100644
index 0000000..7a9b70d
--- /dev/null
+++ b/tests/test_metadata_and_print_state_reset.py
@@ -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
diff --git a/tests/test_multicolor_box_failed_state.py b/tests/test_multicolor_box_failed_state.py
new file mode 100644
index 0000000..3e3654e
--- /dev/null
+++ b/tests/test_multicolor_box_failed_state.py
@@ -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)
diff --git a/tests/test_printer_files_endpoint.py b/tests/test_printer_files_endpoint.py
new file mode 100644
index 0000000..6550f89
--- /dev/null
+++ b/tests/test_printer_files_endpoint.py
@@ -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
diff --git a/web/themes/default/app.js b/web/themes/default/app.js
index dbbbe61..9b75615 100644
--- a/web/themes/default/app.js
+++ b/web/themes/default/app.js
@@ -424,6 +424,11 @@ function applyLang() {
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);
@@ -764,13 +769,22 @@ function showPanel(id) {
}
// Settings-Kategorie umschalten (Master-Detail)
-function showSettingsCat(name) {
- document.querySelectorAll(".set-group").forEach((g) => g.classList.remove("active"));
- document.querySelectorAll(".set-cat").forEach((b) => b.classList.remove("active"));
- var g = document.getElementById("setgrp-" + name);
- if (g) g.classList.add("active");
- var c = document.getElementById("setcat-" + name);
- if (c) c.classList.add("active");
+function showSettingsCat(name){
+ document.querySelectorAll('.set-group').forEach(g=>g.classList.remove('active'));
+ document.querySelectorAll('.set-cat').forEach(b=>b.classList.remove('active'));
+ var g=document.getElementById('setgrp-'+name);if(g)g.classList.add('active');
+ 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 ──
@@ -4066,16 +4080,198 @@ function storeDeleteSelected(){
});
}
-var _gcodeFilaments = [];
+// ── 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 _setGcodeFilamentsFromFileObj(fileObj) {
- try {
- if (fileObj && Array.isArray(fileObj.gcode_filaments)) {
- _gcodeFilaments = fileObj.gcode_filaments;
- } else if (fileObj && typeof fileObj.gcode_filaments === "string" && fileObj.gcode_filaments) {
- _gcodeFilaments = JSON.parse(fileObj.gcode_filaments);
- } else {
- _gcodeFilaments = [];
+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=''+
+ '';
+ 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
+ ? '
'
+ : '🖨';
+ return ''+
+ checkbox+
+ thumbHtml+
+ ''+name+''+
+ '💾 '+sizeKb+''+
+ '📅 '+date+''+
+ ''+
+ ''+
+ ''+
+ '';
+ }).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&&n0?(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){
+ try{
+ if(fileObj&&Array.isArray(fileObj.gcode_filaments)){
+ _gcodeFilaments=fileObj.gcode_filaments;
+ }else if(fileObj&&typeof fileObj.gcode_filaments==='string'&&fileObj.gcode_filaments){
+ _gcodeFilaments=JSON.parse(fileObj.gcode_filaments);
+ }else{
+ _gcodeFilaments=[];
}
} catch (e) {
_gcodeFilaments = [];
diff --git a/web/themes/default/index.html b/web/themes/default/index.html
index b79b6b8..50ffb0e 100644
--- a/web/themes/default/index.html
+++ b/web/themes/default/index.html
@@ -537,49 +537,81 @@
🗂 Datei-Browser
-
-
-
-
+
+
+
-
-
- ⬆
- GCode hierher ziehen oder durchsuchen
-
+
+
+
+
+
+
+
+
+
+ ⬆
+ GCode hierher ziehen oder durchsuchen
+
+
+
+
+
-
diff --git a/web/themes/default/style.css b/web/themes/default/style.css
index bffa01d..dc2c69a 100644
--- a/web/themes/default/style.css
+++ b/web/themes/default/style.css
@@ -258,7 +258,7 @@ main {
GridStack's resize-handle hit area and drag listeners. */
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
the cell smaller than the content needs. */
-.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box}
+.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. */
@@ -314,59 +314,22 @@ main {
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
-.hero-info {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-.pct-big {
- font-size: 52px;
- font-weight: 700;
- line-height: 1;
- color: var(--txt);
-}
-.pct-big small {
- font-size: 20px;
- font-weight: 400;
- color: var(--txt2);
-}
-.progress-bar {
- height: 8px;
- background: var(--raised);
- border-radius: 4px;
- overflow: hidden;
- margin: 4px 0;
-}
-.progress-fill {
- height: 100%;
- background: linear-gradient(90deg, var(--accent), #0080cc);
- border-radius: 4px;
- transition: width 0.6s ease;
-}
-.meta-row {
- display: flex;
- justify-content: space-between;
- font-size: 12px;
- color: var(--txt2);
-}
-.layer-badge {
- background: var(--raised);
- border-radius: 6px;
- padding: 4px 8px;
- font-family: var(--mono);
- font-size: 12px;
- color: var(--txt);
-}
-.fname {
- font-size: 12px;
- color: var(--txt2);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- background: var(--raised);
- border-radius: 6px;
- padding: 6px 8px;
-}
+/* 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)}
+.progress-bar{height:8px;background:var(--raised);border-radius:4px;overflow:hidden;margin:4px 0}
+.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#0080cc);
+ border-radius:4px;transition:width .6s ease}
+.meta-row{display:flex;justify-content:space-between;font-size:12px;color:var(--txt2)}
+.layer-badge{background:var(--raised);border-radius:6px;padding:4px 8px;
+ font-family:var(--mono);font-size:12px;color:var(--txt)}
+.fname{font-size:12px;color:var(--txt2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
+ background:var(--raised);border-radius:6px;padding:6px 8px}
/* ── PRINT CONTROLS ── */
.ctrl-btns {
@@ -891,6 +854,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
}
}
+/* ── 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;
diff --git a/web/translations/de.json b/web/translations/de.json
index 5505208..30c3bfe 100644
--- a/web/translations/de.json
+++ b/web/translations/de.json
@@ -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?",
@@ -202,6 +216,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.",
@@ -215,7 +233,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",
@@ -270,19 +287,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)",
- "dash_edit": "Dashboard anpassen",
- "dash_done": "Fertig",
- "dash_reset": "Zurücksetzen",
- "dash_preset_standard": "Standard",
- "dash_preset_wide89": "Desktop breit",
- "dash_hidden_cards": "Ausgeblendete Karten",
- "dash_show": "Einblenden",
- "dash_hide": "Ausblenden",
- "dash_toggle_width": "Breite umschalten",
- "dash_save_preset": "Als Preset speichern",
- "dash_preset_name_prompt": "Preset-Name:",
- "dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
"settings_print": "Druckeinstellungen",
"settings_printer_ip": "Drucker-IP",
@@ -294,7 +298,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)",
@@ -315,6 +321,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",
@@ -340,15 +347,15 @@
"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_exit_select": "Abbrechen",
- "store_search_placeholder": "🔍 Suche…",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "durchsuchen",
@@ -368,6 +375,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…"
-}
\ No newline at end of file
+ "update_restarting": "Starte neu..."
+}
diff --git a/web/translations/en.json b/web/translations/en.json
index cb65742..2fd133c 100644
--- a/web/translations/en.json
+++ b/web/translations/en.json
@@ -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_stop": "◼ Camera",
"btn_cam_stop2": "◼ Stop",
@@ -67,6 +69,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?",
@@ -201,6 +215,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.",
@@ -214,7 +232,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",
@@ -267,19 +284,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)",
- "dash_edit": "Customize dashboard",
- "dash_done": "Done",
- "dash_reset": "Reset",
- "dash_preset_standard": "Standard",
- "dash_preset_wide89": "Wide desktop",
- "dash_hidden_cards": "Hidden cards",
- "dash_show": "Show",
- "dash_hide": "Hide",
- "dash_toggle_width": "Toggle width",
- "dash_save_preset": "Save as preset",
- "dash_preset_name_prompt": "Preset name:",
- "dash_preset_delete_confirm": "Delete preset \"{name}\"?",
"settings_poll_interval_label": "Poll Interval (seconds)",
"settings_print": "Print Settings",
"settings_printer_ip": "Printer IP",
@@ -291,7 +295,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)",
@@ -310,6 +316,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",
@@ -335,15 +342,15 @@
"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_exit_select": "Cancel",
- "store_search_placeholder": "🔍 Search…",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "browse",
@@ -363,6 +370,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…"
-}
\ No newline at end of file
+ "update_restarting": "Restarting..."
+}
diff --git a/web/translations/es.json b/web/translations/es.json
index 243e2bd..9e3372b 100644
--- a/web/translations/es.json
+++ b/web/translations/es.json
@@ -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?",
@@ -202,6 +216,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.",
@@ -215,7 +233,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",
@@ -270,19 +287,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)",
- "dash_edit": "Personalizar panel",
- "dash_done": "Listo",
- "dash_reset": "Restablecer",
- "dash_preset_standard": "Estándar",
- "dash_preset_wide89": "Escritorio ancho",
- "dash_hidden_cards": "Tarjetas ocultas",
- "dash_show": "Mostrar",
- "dash_hide": "Ocultar",
- "dash_toggle_width": "Cambiar ancho",
- "dash_save_preset": "Guardar como preset",
- "dash_preset_name_prompt": "Nombre del preset:",
- "dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
"settings_print": "Ajustes de impresión",
"settings_printer_ip": "IP de impresora",
@@ -294,7 +298,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)",
@@ -315,6 +321,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",
@@ -340,15 +347,15 @@
"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_exit_select": "Cancelar",
- "store_search_placeholder": "🔍 Buscar…",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "buscar",
@@ -368,6 +375,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…"
-}
\ No newline at end of file
+ "update_restarting": "Reiniciando..."
+}
diff --git a/web/translations/fr.json b/web/translations/fr.json
index 2a731d2..447bc9c 100644
--- a/web/translations/fr.json
+++ b/web/translations/fr.json
@@ -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",
@@ -203,6 +205,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.",
@@ -302,6 +308,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",
@@ -327,15 +334,15 @@
"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_exit_select": "Annuler",
- "store_search_placeholder": "🔍 Rechercher…",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "parcourir",
@@ -355,6 +362,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…"
-}
\ No newline at end of file
+ "update_restarting": "Redémarrage…"
+}
diff --git a/web/translations/it.json b/web/translations/it.json
index 9a63d90..3797bd1 100644
--- a/web/translations/it.json
+++ b/web/translations/it.json
@@ -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",
@@ -203,6 +205,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.",
@@ -301,6 +307,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",
@@ -326,15 +333,15 @@
"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_exit_select": "Annulla",
- "store_search_placeholder": "🔍 Cerca…",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "sfoglia",
@@ -354,6 +361,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…"
-}
\ No newline at end of file
+ "update_restarting": "Riavvio in corso..."
+}
diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json
index b0d7c98..b4541c8 100644
--- a/web/translations/zh-cn.json
+++ b/web/translations/zh-cn.json
@@ -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}仍要开始打印吗?",
@@ -202,6 +216,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": "尚未设置打印机。",
@@ -215,7 +233,6 @@
"progress_action_slots": "分配槽位",
"settings_auto_leveling": "打印前自动调平",
"settings_auto_leveling_label": "打印前自动调平",
- "settings_vibration_compensation": "打印前共振补偿",
"settings_btn_tooltip": "设置",
"settings_camera_on_print": "打印开始时开启相机",
"settings_cat_connection": "连接",
@@ -270,19 +287,6 @@
"settings_password": "MQTT 密码",
"settings_poll": "轮询间隔(秒)",
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
- "settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
- "dash_edit": "自定义仪表盘",
- "dash_done": "完成",
- "dash_reset": "重置",
- "dash_preset_standard": "标准",
- "dash_preset_wide89": "宽屏桌面",
- "dash_hidden_cards": "隐藏的卡片",
- "dash_show": "显示",
- "dash_hide": "隐藏",
- "dash_toggle_width": "切换宽度",
- "dash_save_preset": "另存为预设",
- "dash_preset_name_prompt": "预设名称:",
- "dash_preset_delete_confirm": "删除预设 \"{name}\"?",
"settings_poll_interval_label": "轮询间隔(秒)",
"settings_print": "打印设置",
"settings_printer_ip": "打印机 IP",
@@ -294,7 +298,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": "可见厂商(配置下拉框)",
@@ -315,6 +321,7 @@
"skip_sending": "发送中 …",
"skip_success": "对象将被跳过。",
"skip_title": "✂ 跳过对象",
+ "slot_copy_from": "从插槽复制颜色…",
"slot_edit_color": "颜色",
"slot_edit_custom": "例如 PLA, PETG, ABS…",
"slot_edit_load": "⬇ 进料",
@@ -340,15 +347,15 @@
"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_exit_select": "取消",
- "store_search_placeholder": "🔍 搜索…",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "浏览",
@@ -368,6 +375,5 @@
"update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d",
"update_error": "错误",
"update_none": "已是最新版本",
- "update_restarting": "重启中...",
- "slot_copy_from": "从插槽复制颜色…"
-}
\ No newline at end of file
+ "update_restarting": "重启中..."
+}