Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions
a360b463f9 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.30-nightly10 release 2026-08-04 12:51:58 +00:00
7e33cc9eda fix(mqtt): detect a dead printer connection promptly instead of hanging offline detection forever
All checks were successful
Nightly Build / build (push) Successful in 6m38s
Discovered while testing the smart-plug power-switch feature: unplugging
the printer left the dashboard stuck showing it as online/"ready"
indefinitely. Live-tested against a real printer to isolate two
independent, compounding causes:

1. The MQTT socket had no TCP keepalive. A connection killed without a
   clean TCP close (unplugged, not a graceful shutdown) looks alive to the
   OS for as long as its default dead-connection timeout - 15+ minutes on
   Linux - since a send on a half-open connection is buffered by the
   kernel and doesn't fail immediately. Fixed with SO_KEEPALIVE (short
   idle/interval/count) plus TCP_USER_TIMEOUT, since keepalive probes alone
   only fire on an idle connection - verified live that a printer
   disappearing while a send was still in flight (the common case, since
   the poll loop sends every few seconds) instead falls back to the far
   slower normal TCP retransmission timer, which keepalive settings don't
   affect at all.

2. Even after the socket was correctly detected as dead, the status poll
   loop could hang indefinitely inside publish() waiting for a reconnect
   attempt already running on the MQTT reader thread (the Issue #105
   reconnect-lock serialization), and therefore never reached the
   is_connected() check that flips kobra_state to "offline". _reconnect()
   now takes wait_if_in_progress/persist flags so the poll loop's call
   returns immediately with at most one attempt instead of blocking
   through someone else's multi-minute backoff loop - persistent retrying
   stays the reader thread's job.

A disconnected printer is now detected and reflected on the dashboard
within about 15 seconds. Live-verified across repeated disconnect/
reconnect cycles that no sockets, threads, or file descriptors are left
behind (checked via /proc/<pid>/fd and /proc/<pid>/task) - the transient
FIN-WAIT-2 entries seen while the printer's TLS service is still booting
belong to the kernel's own connection teardown, not to processes held by
the bridge, and clear on their own.
2026-08-04 14:44:51 +02:00
5a44d0abab feat(print): add option to delete file from printer after successful print
Frees up the printer's own limited storage automatically once a print
finishes, while keeping the file safely in the bridge's own GCode store.

Deliberately scoped to files uploaded through the bridge itself only
(matched via the GCode store, same lookup the job-history feature already
uses) - a print started directly from the printer or Anycubic Slicer has
no backup anywhere else, so it's never touched regardless of the setting.
Only triggers on a clean "finished" state, not on stopped/canceled prints,
since the user may want to retry those.

Off by default. The delete request is fire-and-forget, sent directly from
_on_print() (which runs on the MQTT reader thread) rather than through the
existing _wait_for_file_action() helper - that helper blocks waiting for a
reply dispatched from that same thread, which would deadlock if called
from within it.
2026-08-03 13:12:47 +02:00
23e3831232 fix(filament): wire ACE-RFID auto-matching into the real MQTT status path (Issue #101)
_parse_combined_rfid_type()/_match_profile_by_vendor_family() (added in an
earlier nightly) were only ever invoked inside _build_lane_data(), which is
only reached when OrcaSlicer actively polls the Moonraker lane_data endpoint.
The actual MQTT receive path (_on_multicolor_box -> self._ams_slots ->
_push_status_update -> dashboard, and the /kx/filament/slots dashboard API,
and Happy-Hare gate data) never touched this logic at all, so a combined
RFID string like "GEEETECH PLA Bas" kept showing up unmatched everywhere
except the one endpoint nobody was looking at - exactly what @Blaim's
extensive debugging in the issue thread demonstrated.

Centralized the matching into _effective_slot_profile() itself, since all
three real consumers already call it. This fixes the dashboard and gate
data with a single change instead of duplicating the matching logic per
caller (which is what caused the gap in the first place).

While centralizing, found and fixed a related edge case: the stale-profile
guard compared a manual override's material family directly against the
raw combined RFID string, which _material_family() can't parse past the
vendor prefix - a valid manual override on an RFID-tagged slot would have
been incorrectly treated as stale and dropped. Now resolves the plain
material family through the RFID parser first for that comparison.

Also added variant-token disambiguation to _match_profile_by_vendor_family()
per @Blaim's follow-up request: when a vendor has multiple profiles of the
same material family (e.g. "Geeetech PLA Basic" vs. "Geeetech PLA Matte"),
the RFID string's truncated third token ("Bas") is now scored against
candidate profile names instead of always picking the first match.

New tests cover the actual runtime path end-to-end (a realistic
multiColorBox/report payload through _on_multicolor_box, verified against
the /kx/filament/slots response the dashboard consumes) - the previous
test suite only exercised the helper functions and _build_lane_data() in
isolation with hand-set state, which is how this gap went unnoticed.
2026-08-03 12:33:47 +02:00
16 changed files with 650 additions and 53 deletions

View File

@@ -67,6 +67,7 @@ CONFIG_ENV_MAPPING = {
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"DELETE_PRINTER_FILE_AFTER_PRINT": (CONFIG_SECTION_PRINT, "delete_printer_file_after_print"),
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
@@ -459,6 +460,7 @@ AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
DELETE_PRINTER_FILE_AFTER_PRINT = int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))

View File

@@ -54,5 +54,6 @@ AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
DELETE_PRINTER_FILE_AFTER_PRINT = int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")

View File

@@ -101,6 +101,46 @@ def _parse_publish(pkt: bytes):
return topic, payload
def _enable_tcp_keepalive(sock: socket.socket) -> None:
"""Without this, a printer that goes dark without a clean TCP close (e.g.
unplugged, not gracefully shut down) leaves the socket looking alive to
is_connected() for as long as the OS's default dead-connection timeout
(often 15+ minutes on Linux) - sendall() on a half-open connection is
buffered by the kernel and doesn't fail immediately, so the poll loop's
is_connected() check (kobrax_moonraker_bridge.py's _poll_loop) never
sees the failure it needs to flip kobra_state to "offline". Short
keepalive probes make the OS notice and fail the socket within seconds
instead. Linux/macOS only (TCP_KEEPIDLE/INTVL/CNT); best-effort on other
platforms - not fatal if unsupported.
SO_KEEPALIVE alone is NOT enough, verified live by unplugging a real
printer mid-connection: keepalive probes only fire while the connection
is idle (no unacknowledged data outstanding). If the printer disappears
while a send is still in flight - the common case, since the poll loop
sends a request roughly every poll_interval - the kernel instead retries
that specific send via the normal TCP retransmission timer
(tcp_retries2, default 15 attempts with exponential backoff = 13-30+
minutes on Linux), which keepalive settings don't affect at all.
TCP_USER_TIMEOUT (Linux-specific) closes that gap: it caps how long ANY
unacknowledged data may sit in the send queue before the kernel gives up
on the connection outright, regardless of which mechanism (keepalive or
retransmission) would otherwise still be retrying."""
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
if hasattr(socket, "TCP_KEEPIDLE"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 5)
elif hasattr(socket, "TCP_KEEPALIVE"): # macOS
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 5)
if hasattr(socket, "TCP_KEEPINTVL"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3)
if hasattr(socket, "TCP_KEEPCNT"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
if hasattr(socket, "TCP_USER_TIMEOUT"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 15000)
except OSError as e:
log.debug("TCP keepalive not fully supported on this platform: %s", e)
# ---------------------------------------------------------------------------
# KobraXClient
# ---------------------------------------------------------------------------
@@ -189,6 +229,7 @@ class KobraXClient:
# senders. Only the finished socket is swapped in under the lock (#53).
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
raw = socket.create_connection(_ai[0][4], timeout=5)
_enable_tcp_keepalive(raw)
new_sock = ctx.wrap_socket(raw)
log.info("TLS connected cipher=%s", new_sock.cipher()[0])
@@ -252,19 +293,31 @@ class KobraXClient:
with self._lock:
return self._sock is not None
def _reconnect(self):
"""Persistent reconnect: keeps retrying forever until the printer is
responds or disconnect() was called. Backoff caps at 60 s. The
first 5 attempts log as WARNING (acute connection issue), afterwards
only DEBUG to avoid log spam during long printer outages (e.g. switched
ausgeschaltet) zu vermeiden.
def _reconnect(self, wait_if_in_progress: bool = True, persist: bool = True):
"""Reconnect the MQTT/TLS session. With persist=True (the default, used
by the reader-thread keepalive path) it keeps retrying forever until
the printer responds or disconnect() was called, backoff capped at 60s.
The first 5 attempts log as WARNING (acute connection issue), afterwards
only DEBUG to avoid log spam during long printer outages (e.g. switched off).
Guarded by _reconnect_lock (Issue #105): if another thread's reconnect
is already in flight, this call waits for it to finish instead of
starting a second, competing _do_connect() - the printer likely only
is already in flight, this call normally waits for it to finish instead
of starting a second, competing _do_connect() - the printer likely only
accepts one mTLS session at a time, so two parallel handshakes would
just interfere with each other and neither converges."""
just interfere with each other and neither converges.
wait_if_in_progress=False + persist=False are used by the poll loop's
publish()/publish_web(): that thread MUST return promptly so the poll
loop can observe the dead session (via is_connected()) and flip
kobra_state to "offline". It must neither block on the lock waiting for
the reader thread's persistent reconnect (wait_if_in_progress=False),
nor run the multi-minute backoff loop itself (persist=False -> at most
one immediate attempt). Otherwise the poll loop hangs inside publish()
for the entire outage and the dashboard stays stuck on the last known
state - the exact bug seen when a printer was unplugged mid-connection."""
if not self._reconnect_lock.acquire(blocking=False):
if not wait_if_in_progress:
return self._sock is not None
self._reconnect_lock.acquire()
self._reconnect_lock.release()
return self._sock is not None
@@ -290,6 +343,12 @@ class KobraXClient:
return True
except Exception as e:
attempt += 1
if not persist:
# One-shot: don't block the caller (poll loop) in the
# backoff loop - leave persistent retrying to the
# reader thread's keepalive path.
log.debug("Reconnect (one-shot) failed: %s", e)
return False
lvl = log.warning if attempt <= 5 else log.debug
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
# Split sleep so disconnect() breaks the loop faster.
@@ -522,7 +581,10 @@ class KobraXClient:
self._pending_msgid.pop(msgid, None)
if report_registered:
self._pending_report.pop(report_key, None)
if not self._reconnect():
# Non-blocking: never hang the poll-loop thread inside publish()
# while a reconnect is running / during backoff (see _reconnect
# docstring) - it must return so kobra_state can flip to "offline".
if not self._reconnect(wait_if_in_progress=False, persist=False):
return None
# retry once after reconnect
try:
@@ -569,10 +631,11 @@ class KobraXClient:
except Exception as e:
log.error("web send error: %s, reconnecting…", e)
# Trigger a reconnect (like publish()); no retry because it is
# fire-and-forget - the next call will hit the fresh socket
# treffen.
# fire-and-forget - the next call will hit the fresh socket.
# Non-blocking for the same reason as publish() (see _reconnect
# docstring) - never hang this thread through a backoff loop.
try:
self._reconnect()
self._reconnect(wait_if_in_progress=False, persist=False)
except Exception:
pass

View File

@@ -1073,6 +1073,11 @@ class KobraXBridge:
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 = ""
# Filename of the file backing _current_job_id, kept alongside it so
# the "finished" handler can still delete it from the printer's own
# storage (Issue: delete-after-print) after self._state["filename"]
# has already been cleared as part of the terminal-state reset below.
self._current_job_filename: str = ""
self._camera_autostarted: bool = False
self._camera_user_stopped: bool = False # user manually stopped the camera during a print
self.camera_cache: CameraCache = CameraCache()
@@ -1396,6 +1401,7 @@ class KobraXBridge:
gcode_file_id=gf["id"],
printer_id=self._printer_id,
)
self._current_job_filename = filename
log.info(f"Job started: {self._current_job_id} for {filename}")
self._spoolman_slot_usage = {}
self._spoolman_slot_reported = {}
@@ -1408,11 +1414,21 @@ class KobraXBridge:
log.info(f"Job abgeschlossen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
# Optional cleanup (Settings -> Print): only for files that are
# also backed by the bridge's own GCode store - never for prints
# started directly from the printer/Anycubic Slicer, which would
# otherwise be deleted with no copy left anywhere (Issue: delete
# printer file after successful print). Deliberately only on a
# clean "finished" - stoped/canceled prints keep their file.
if getattr(self._args, "delete_printer_file_after_print", 0) and self._current_job_filename:
self._delete_printer_file_fire_and_forget(self._current_job_filename)
self._current_job_filename = ""
elif kobra_state in ("stoped", "canceled") and self._current_job_id:
self._store.finish_job(self._current_job_id, status="cancelled")
log.info(f"Job abgebrochen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
self._current_job_filename = ""
# Terminal states (successful finish AND stop/cancel) must leave the
# same clean end state - a "finished" print used to only clear
@@ -1579,6 +1595,25 @@ 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 _delete_printer_file_fire_and_forget(self, filename: str) -> None:
"""Deletes a file from the printer's own storage without waiting for
the response - called from _on_print(), which runs on the MQTT
reader thread itself, so blocking here (like _wait_for_file_action
does) would deadlock: the file/report reply that would unblock it is
dispatched from that same thread. Fire-and-forget is safe because the
bridge's own copy in the GCode store is what matters for correctness
here; a failed delete just leaves the printer's storage as it is
(Settings -> Print -> "Delete file from printer after successful print")."""
try:
self.client.publish(
"file", "deleteBatch",
{"root": "local", "files": [{"path": "/", "filename": filename}]},
timeout=0,
)
log.info(f"Requested printer-storage delete for {filename} after successful print")
except Exception as e:
log.warning(f"Delete-after-print request failed for {filename}: {e}")
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
@@ -2243,12 +2278,32 @@ class KobraXBridge:
return "", ""
return vendor, family
def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict:
@staticmethod
def _rfid_variant_tokens(raw_type: str) -> list[str]:
"""Tokens after "VENDOR TYPE" in a combined ACE-RFID string (e.g.
["bas"] for "GEEETECH PLA Bas") - the truncated variant/serial that
distinguishes multiple profiles of the same (vendor, material family),
e.g. "Basic" vs. "Matte". Kept separate from _parse_combined_rfid_type()
so that function's 2-tuple signature (and its existing callers/tests)
stay unchanged (Issue #101)."""
tokens = raw_type.split()
return [t.lower() for t in tokens[2:]]
def _match_profile_by_vendor_family(self, vendor: str, family: str,
variant_tokens: list[str] | None = None) -> 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."""
RFID string.
When multiple profiles share the same (vendor, family) - e.g. "Geeetech
PLA Basic" and "Geeetech PLA Matte" both matching (Geeetech, PLA) -
variant_tokens (the RFID string's remaining tokens, e.g. ["bas"] for
"Basic") are scored against each candidate's name: a word-prefix match
scores higher than a plain substring match, so "bas" prefers "Basic"
over "Matte" or an unrelated profile name containing "bas" as noise.
Falls back to the first match when nothing disambiguates."""
matches = [
p for p in self._load_orca_filaments()
if p.get("vendor", "").lower() == vendor.lower()
@@ -2256,12 +2311,28 @@ class KobraXBridge:
]
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]
if len(matches) == 1 or not variant_tokens:
return matches[0]
best = matches[0]
best_score = -1
for p in matches:
name_words = p.get("name", "").lower().split()
score = 0
for tok in variant_tokens:
if any(w.startswith(tok) for w in name_words):
score += 2
elif tok in p.get("name", "").lower():
score += 1
if score > best_score:
best_score = score
best = p
log.debug(
f"_match_profile_by_vendor_family: {len(matches)} profiles match "
f"vendor={vendor!r} family={family!r}, variant_tokens={variant_tokens!r} "
f"-> {best.get('name')!r} (score={best_score})"
)
return best
def _profile_material(self, profile: dict) -> str:
"""Material type (e.g. "PETG") of a saved slot profile, resolved by
@@ -2278,21 +2349,44 @@ class KobraXBridge:
def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict:
"""Saved slot-profile override — but only while its material *family*
still matches the material currently loaded in the AMS.
still matches the material currently loaded in the AMS. Falls back to
auto-resolving a combined ACE-RFID type string (Issue #101) when there
is no (usable) manual override.
Non-destructive suppression (Option A): when the family no longer matches
(e.g. a PETG profile but PLA loaded) we return {} → the slot falls back to
the generic default. The override stays in config.ini and reactivates as
soon as the matching material is loaded again. When the profile's family
is unknown we do NOT suppress (fail-safe)."""
(e.g. a PETG profile but PLA loaded) the override is skipped → falls
through to the RFID auto-match / generic default. The override stays in
config.ini and reactivates as soon as the matching material is loaded
again. When the profile's family is unknown we do NOT suppress (fail-safe).
Centralized here (rather than duplicated per caller) so every consumer -
the dashboard's /kx/filament/slots, Happy-Hare gate data, and the
OrcaSlicer lane-data sync - benefits from RFID auto-matching identically,
instead of only the one call site that happened to also call
_parse_combined_rfid_type() directly."""
# A combined ACE-RFID string ("GEEETECH PLA Bas") carries a vendor
# prefix that _material_family() alone can't see past (it only
# strips known polymer prefixes, so "GEEETECH PLA BAS" resolves to
# itself, not "PLA") - resolve the plain material family through the
# RFID parser first so the stale-profile guard below compares against
# the actual polymer family, not the raw combined string.
vendor, family = self._parse_combined_rfid_type(ams_material)
plain_material = family or ams_material
profile = self._filament_profiles.get(global_idx) or {}
if not profile.get("name"):
return {}
prof_fam = self._material_family(self._profile_material(profile))
ams_fam = self._material_family(ams_material)
if prof_fam and ams_fam and prof_fam != ams_fam:
return {}
return profile
if profile.get("name"):
prof_fam = self._material_family(self._profile_material(profile))
ams_fam = self._material_family(plain_material)
if not (prof_fam and ams_fam and prof_fam != ams_fam):
return profile
if vendor:
variant_tokens = self._rfid_variant_tokens(ams_material)
auto = self._match_profile_by_vendor_family(vendor, family, variant_tokens)
if auto.get("name"):
return auto
return {}
def _build_lane_data(self) -> dict:
"""Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0.
@@ -2338,30 +2432,19 @@ class KobraXBridge:
# The vendor is sent along (tray_sub_brands + filament_vendor),
# so a patched OrcaSlicer can match by brand + type +
# color (analogous to SnapmakerPrinterAgent).
# Two-layer resolution for the filament hint sent to OrcaSlicer:
# Three-layer resolution for the filament hint sent to OrcaSlicer,
# all handled inside _effective_slot_profile() (Issue #101):
# 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
# 2. Generic fallback (_TRAY_INFO_IDX) per material type - no
# 2. Combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
# "GEEETECH PLA Bas") auto-matched against the user's
# already-imported profile library. Not persisted to
# config.ini - re-derives on every call, so a differently
# tagged spool loaded later isn't stuck with a stale match.
# 3. Generic fallback (_TRAY_INFO_IDX) per material type - no
# vendor hint; OrcaSlicer then picks its own generic preset
# Stale-profile guard: only apply the override while its material
# 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"):
material = self._material_family(user_profile.get("type", material)) or material
vendor = user_profile.get("vendor", "")
fila_name = user_profile.get("name", "")
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
@@ -3547,6 +3630,7 @@ class KobraXBridge:
printer_id=getattr(self._args, "device_id", "unknown"),
filament_assignments=assignments,
)
self._current_job_filename = filename
return self._json_cors({"result": "ok", "filename": filename})
@@ -4996,6 +5080,7 @@ class KobraXBridge:
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"delete_printer_file_after_print": getattr(self._args, "delete_printer_file_after_print", 0),
"print_start_dialog": getattr(self._args, "print_start_dialog", 1),
"poll_interval": getattr(self._args, "poll_interval", 3),
"verbose_http_log": getattr(self._args, "verbose_http_log", 0),
@@ -5037,6 +5122,7 @@ class KobraXBridge:
cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0))))))
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
cfg.set("print", "delete_printer_file_after_print", str(int(bool(data.get("delete_printer_file_after_print", getattr(self._args, "delete_printer_file_after_print", 0))))))
cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1))))))
if "poll_interval" in data:
try:
@@ -6219,6 +6305,10 @@ def main():
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--delete-printer-file-after-print", type=int,
default=env_loader.DELETE_PRINTER_FILE_AFTER_PRINT,
help="After a successful print, delete the file from the printer's "
"own storage if it's also in the bridge's own GCode store")
parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int)
parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER,

View File

@@ -109,3 +109,128 @@ def test_build_lane_data_plain_type_still_uses_generic_fallback():
tray = lane["ams"][0]["tray"][0]
assert tray["name"] == "Generic PLA"
assert tray["vendor_name"] == "Generic"
# ─── Centralized matching via _effective_slot_profile() ────────────────────
#
# The bug reported in Issue #101 by @Blaim (nightly45 not working, despite
# _parse_combined_rfid_type()/_match_profile_by_vendor_family() existing):
# those two helpers were ONLY ever invoked inside _build_lane_data(), which
# is only reached when OrcaSlicer polls the Moonraker lane_data endpoint -
# never as part of the real MQTT receive path (_on_multicolor_box ->
# self._ams_slots -> _push_status_update -> dashboard / /kx/filament/slots).
# So the dashboard and the Happy-Hare gate data never saw a match, matching
# exactly what the user's screenshots showed. Fixed by moving the matching
# logic into _effective_slot_profile() itself, which all three consumers
# already call.
def test_effective_slot_profile_auto_resolves_raw_rfid_string_without_override():
"""The core Issue #101 regression: _effective_slot_profile() itself (not
just _build_lane_data()) must resolve a combined RFID string when there is
no manual per-slot override in config.ini."""
b = _bridge()
b._filament_profiles = {}
profile = b._effective_slot_profile(0, "GEEETECH PLA Bas")
assert profile.get("name") == "Geeetech PLA Basic"
assert profile.get("vendor") == "Geeetech"
def test_effective_slot_profile_manual_override_still_wins():
b = _bridge()
b._filament_profiles = {0: {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic"}}
profile = b._effective_slot_profile(0, "GEEETECH PLA Bas")
assert profile.get("name") == "Generic PLA"
def test_effective_slot_profile_plain_type_no_override_returns_empty():
"""Regression guard: a plain type="PLA" slot with no override must still
fall through to {} (the caller's own generic-name fallback), not be
treated as an RFID string."""
b = _bridge()
b._filament_profiles = {}
assert b._effective_slot_profile(0, "PLA") == {}
def _multicolor_box_report(raw_type: str, color=(238, 190, 152)) -> dict:
"""A realistic multiColorBox/report payload for one ACE box (id=0) with
a toolhead (id=-1), matching the real Kobra X topology captured live
during Issue #100/#101 investigation - drives _detect_filament_mode()
to "ace_hub", same as on real hardware."""
return {
"state": "success",
"data": {
"head_tools_model": 1,
"multi_color_box": [
{
"id": -1, "loaded_slot": -1,
"slots": [{"index": 0, "status": 0, "type": "", "color": [0, 0, 0]}],
},
{
"id": 0, "loaded_slot": -1,
"slots": [
{"index": 0, "status": 0, "type": "", "color": [0, 0, 0]},
{"index": 1, "status": 0, "type": "", "color": [0, 0, 0]},
{
"index": 2, "status": 5, "type": raw_type,
"color": list(color), "sku": "",
},
{"index": 3, "status": 0, "type": "", "color": [0, 0, 0]},
],
},
],
},
}
def test_on_multicolor_box_end_to_end_resolves_rfid_slot_for_dashboard():
"""End-to-end regression test for Issue #101: feed a raw MQTT
multiColorBox/report payload through the real receive path
(_on_multicolor_box), then check that the dashboard-facing
/kx/filament/slots data (handle_kx_filament_slots) - which is what
populates the dashboard's window._slotProfileMap in the browser -
actually reflects the matched profile, not the raw "GEEETECH PLA Bas"
string. This is the exact path that was broken and untested before."""
b = _bridge()
b._filament_profiles = {}
b._on_multicolor_box(_multicolor_box_report("GEEETECH PLA Bas"))
assert b._filament_mode == "ace_hub"
# global_index 6 = box_id 0 * 4 + local slot 2 in ace_hub mode's ACE block
slot = next(s for s in b._ams_slots if s.get("type") == "GEEETECH PLA Bas")
global_idx = slot["global_index"]
profile = b._effective_slot_profile(global_idx, slot["type"])
assert profile.get("name") == "Geeetech PLA Basic"
assert profile.get("vendor") == "Geeetech"
def test_match_profile_by_vendor_family_disambiguates_via_variant_tokens():
"""Issue #101 follow-up (@Blaim): two profiles of the same vendor+family
("Geeetech PLA Basic" vs. "Geeetech PLA Matte") must resolve to the one
matching the RFID string's truncated variant token ("bas" -> Basic),
not just "whichever loads first"."""
profiles = USER_PROFILES + [
{"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
]
b = _bridge(profiles)
basic = b._match_profile_by_vendor_family("Geeetech", "PLA", ["bas"])
assert basic.get("name") == "Geeetech PLA Basic"
matte = b._match_profile_by_vendor_family("Geeetech", "PLA", ["mat"])
assert matte.get("name") == "Geeetech PLA Matte"
def test_match_profile_by_vendor_family_no_variant_tokens_falls_back_to_first():
profiles = USER_PROFILES + [
{"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
]
b = _bridge(profiles)
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
assert profile.get("name") == "Geeetech PLA Basic"
def test_rfid_variant_tokens_extracts_tokens_after_vendor_and_family():
assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA Bas") == ["bas"]
assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA") == []
assert KobraXBridge._rfid_variant_tokens("PLA") == []

View File

@@ -0,0 +1,157 @@
"""Optional auto-delete of a printed file from the printer's own storage
after a successful print (Settings -> Print -> "Delete file from printer
after successful print").
Only applies to files that are also backed by the bridge's own GCode store
(otherwise the file would be gone with no copy left anywhere) and only on a
clean "finished" state - not on stoped/canceled prints, and never when the
setting is off (the default).
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import GCodeStore, KobraXBridge
def _bridge(delete_after_print=1):
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="kxdelafterprint-"),
delete_printer_file_after_print=delete_after_print,
)
store = GCodeStore(args.data_dir)
b = KobraXBridge(c, args=args, store=store)
return b, c
def _seed_file(bridge, filename="test.gcode"):
file_id = "abc123"
bridge._store.save_file(file_id, filename, b"; gcode content")
return file_id
def _print_report(state, filename=None):
payload = {"state": state, "data": {}}
if filename is not None:
payload["data"]["filename"] = filename
return payload
def test_finished_print_deletes_printer_file_when_enabled_and_in_store():
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
assert b._current_job_id
assert b._current_job_filename == "test.gcode"
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert len(delete_calls) == 1
payload = delete_calls[0].args[2]
assert payload == {"root": "local", "files": [{"path": "/", "filename": "test.gcode"}]}
def test_finished_print_no_delete_when_setting_disabled():
b, c = _bridge(delete_after_print=0)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_finished_print_no_delete_when_file_not_in_bridge_store():
"""Files started directly from the printer/Anycubic Slicer aren't in the
bridge's own GCode store - must never be deleted, since that would leave
no copy anywhere."""
b, c = _bridge(delete_after_print=1)
# No _seed_file() call - the file is not in the store.
b._on_print(_print_report("printing", "not_in_store.gcode"))
assert not b._current_job_id # no store match -> no job tracked either
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_canceled_print_does_not_delete_file():
"""Only a clean "finished" triggers the delete - a stopped/canceled
print keeps its file, since the user may want to retry it."""
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("canceled"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
assert b._current_job_filename == ""
def test_stoped_print_does_not_delete_file():
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("stoped"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_current_job_filename_reset_after_finished():
"""Regression guard: _current_job_filename must not leak into the next
print's finished-handling if that next print isn't itself tracked."""
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("finished"))
assert b._current_job_filename == ""
# A second "finished" with no new job in between must not re-trigger a delete.
c.publish.reset_mock()
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_delete_publish_failure_does_not_raise():
"""A broken MQTT send during the delete request must not propagate out
of _on_print() - it runs on the MQTT reader thread, and an unhandled
exception there would break processing of subsequent messages."""
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
c.publish.side_effect = RuntimeError("send failed")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("finished")) # must not raise

View File

@@ -92,3 +92,61 @@ def test_reconnect_second_waiter_returns_after_first_completes():
assert result is True
assert c._sock is not None
def test_reconnect_non_blocking_returns_immediately_while_reconnect_in_progress():
"""The poll-loop path (publish/publish_web) must NOT block while the reader
thread's persistent reconnect is running its multi-minute backoff loop -
it has to return so the poll loop can flip kobra_state to "offline".
A printer unplugged mid-connection otherwise left the dashboard stuck on
the last known state indefinitely (Issue #103 follow-up)."""
c = _client()
c._running = True
started = threading.Event()
release = threading.Event()
def slow_persistent_do_connect():
started.set()
# Simulate the printer still being gone: never succeeds until released.
release.wait(timeout=5.0)
raise OSError("still unreachable")
c._do_connect = slow_persistent_do_connect
# First reconnect (reader-thread style): persistent, holds the lock, stuck
# in backoff.
t1 = threading.Thread(target=lambda: c._reconnect(persist=True), daemon=True)
t1.start()
assert started.wait(timeout=2.0)
# Poll-loop style call must return basically instantly, not block on t1.
t0 = time.time()
result = c._reconnect(wait_if_in_progress=False, persist=False)
elapsed = time.time() - t0
assert elapsed < 0.5, f"non-blocking reconnect blocked for {elapsed:.2f}s"
assert result is False # socket is down while the other reconnect churns
release.set() # let the daemon thread unwind
def test_reconnect_one_shot_does_not_loop_on_failure():
"""persist=False must attempt the handshake at most once and return,
instead of entering the backoff loop (which would block the caller)."""
c = _client()
c._running = True
attempts = []
def failing_do_connect():
attempts.append(1)
raise OSError("unreachable")
c._do_connect = failing_do_connect
t0 = time.time()
result = c._reconnect(persist=False)
elapsed = time.time() - t0
assert result is False
assert len(attempts) == 1 # exactly one attempt, no backoff retries
assert elapsed < 0.5

View File

@@ -0,0 +1,80 @@
"""TCP keepalive + TCP_USER_TIMEOUT on the MQTT socket (Issue #103 follow-up).
Without these, a printer that disappears without a clean TCP close
(unplugged, not gracefully shut down) leaves the socket looking alive to
is_connected() for as long as the OS's default dead-connection timeout -
often 15+ minutes on Linux - since sendall() on a half-open connection is
buffered by the kernel and doesn't fail immediately. This left the
dashboard's printer-state indicator stuck showing the last known state
(e.g. green "ready") long after the printer was actually unreachable,
reported when testing the smart-plug power-switch feature by physically
unplugging the printer.
Verified live (real printer, physically unplugged) that SO_KEEPALIVE alone
is not sufficient: keepalive probes only fire on an idle connection, but if
the printer disappears while a send is still unacknowledged - the normal
case, since the poll loop is sending every few seconds - the kernel's
regular TCP retransmission timer takes over instead (tcp_retries2, 13-30+
minutes on Linux), which keepalive settings don't affect. TCP_USER_TIMEOUT
closes that gap by capping how long ANY unacknowledged data may sit in the
send queue, regardless of which retry mechanism would otherwise still be
running.
"""
import socket
from unittest.mock import MagicMock
import pytest
from kobrax_client import _enable_tcp_keepalive
def test_enable_tcp_keepalive_sets_so_keepalive():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
_enable_tcp_keepalive(s)
assert s.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE) == 1
finally:
s.close()
@pytest.mark.skipif(not hasattr(socket, "TCP_KEEPIDLE"), reason="Linux-specific option")
def test_enable_tcp_keepalive_sets_short_idle_and_interval():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
_enable_tcp_keepalive(s)
idle = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE)
intvl = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL)
cnt = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT)
# Short enough that a dead connection is detected within a couple of
# poll cycles (default poll_interval is 3s), not the OS default of
# minutes.
assert idle <= 10
assert intvl <= 5
assert cnt <= 5
finally:
s.close()
@pytest.mark.skipif(not hasattr(socket, "TCP_USER_TIMEOUT"), reason="Linux-specific option")
def test_enable_tcp_keepalive_sets_user_timeout():
"""The critical fix, verified live against a real printer: without this,
a dead connection with unacknowledged data in flight is only detected
after the OS's normal TCP retransmission timeout (13-30+ minutes on
Linux), not the keepalive interval."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
_enable_tcp_keepalive(s)
user_timeout_ms = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT)
# Short enough that a dead connection with in-flight data is detected
# within a couple of poll cycles, not tens of minutes.
assert 0 < user_timeout_ms <= 20000
finally:
s.close()
def test_enable_tcp_keepalive_does_not_raise_on_unsupported_platform():
"""A platform without TCP_KEEPIDLE/INTVL/CNT (e.g. some Windows builds)
must not crash the connection attempt - keepalive is best-effort."""
s = MagicMock()
s.setsockopt.side_effect = OSError("unsupported")
_enable_tcp_keepalive(s) # must not raise

View File

@@ -464,6 +464,8 @@ function applyLang(){
setText('opt-file-ready-banner',T.settings_file_ready_banner);
setText('lbl-camera-on-print',T.settings_camera_on_print);
setText('lbl-web-upload-warning',T.settings_web_upload_warning);
setText('lbl-delete-printer-file-after-print',T.settings_delete_printer_file_after_print||'Delete file from printer after successful print');
setText('lbl-delete-printer-file-after-print-hint',T.settings_delete_printer_file_after_print_hint||'Only applies to prints started through this bridge (files it uploaded itself) - prints started directly from the printer or Anycubic Slicer are never deleted, since no copy of those exists anywhere else.');
setText('fd-options-title',T.fd_options_title);
setText('fd-lbl-auto-leveling',T.print_auto_leveling);
@@ -1141,6 +1143,7 @@ function openSettings(){
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
var dpfap=document.getElementById('s-delete-printer-file-after-print');if(dpfap)dpfap.checked=!!d.delete_printer_file_after_print;
// Poll-Intervall (Sekunden) — Backend hat Vorrang vor localStorage
var pi=document.getElementById('s-poll-interval');
if(pi){
@@ -1912,6 +1915,7 @@ function saveSettings(){
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
web_upload_warning:webUploadWarning,
delete_printer_file_after_print: (document.getElementById('s-delete-printer-file-after-print')||{}).checked?1:0,
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0,
spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'',

View File

@@ -603,6 +603,11 @@
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-delete-printer-file-after-print" style="width:auto;margin:0">
<label id="lbl-delete-printer-file-after-print" style="margin:0;cursor:pointer" for="s-delete-printer-file-after-print">Delete file from printer after successful print</label>
</div>
<small id="lbl-delete-printer-file-after-print-hint" style="color:var(--txt2)"></small>
</div>
</div>

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "Auswahl speichern",
"settings_visible_vendors_save_label": "Auswahl speichern",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"settings_delete_printer_file_after_print": "Datei nach erfolgreichem Druck vom Drucker löschen",
"settings_delete_printer_file_after_print_hint": "Gilt nur für Drucke, die über diese Bridge gestartet wurden (selbst hochgeladene Dateien) - direkt am Drucker oder über Anycubic Slicer gestartete Drucke werden nie gelöscht, da davon sonst keine Kopie mehr existiert.",
"sf_all": "Alle",
"sf_err": "✗ Fehler",
"sf_new": "Neu",

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "Save selection",
"settings_visible_vendors_save_label": "Save selection",
"settings_web_upload_warning": "Show warning when printing web uploads",
"settings_delete_printer_file_after_print": "Delete file from printer after successful print",
"settings_delete_printer_file_after_print_hint": "Only applies to prints started through this bridge (files it uploaded itself) - prints started directly from the printer or Anycubic Slicer are never deleted, since no copy of those exists anywhere else.",
"sf_all": "All",
"sf_err": "✗ Failed",
"sf_new": "New",

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "Guardar selección",
"settings_visible_vendors_save_label": "Guardar selección",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"settings_delete_printer_file_after_print": "Eliminar archivo de la impresora tras una impresión exitosa",
"settings_delete_printer_file_after_print_hint": "Solo aplica a impresiones iniciadas a través de este bridge (archivos que él mismo subió) - las impresiones iniciadas directamente desde la impresora o Anycubic Slicer nunca se eliminan, ya que no existe ninguna copia en otro lugar.",
"sf_all": "Todos",
"sf_err": "✗ Fallido",
"sf_new": "Nuevo",

View File

@@ -279,6 +279,8 @@
"settings_visible_vendors_save": "Enregistrer la sélection",
"settings_visible_vendors_save_label": "Enregistrer la sélection",
"settings_web_upload_warning": "Afficher un avertissement lors de l'impression de fichiers web",
"settings_delete_printer_file_after_print": "Supprimer le fichier de l'imprimante après une impression réussie",
"settings_delete_printer_file_after_print_hint": "S'applique uniquement aux impressions lancées via ce bridge (fichiers qu'il a lui-même téléversés) - les impressions lancées directement depuis l'imprimante ou Anycubic Slicer ne sont jamais supprimées, car aucune copie n'existe ailleurs.",
"sf_all": "Tout",
"sf_err": "✗ Échoués",
"sf_new": "Nouveau",

View File

@@ -279,6 +279,8 @@
"settings_visible_vendors_save": "Salva selezione",
"settings_visible_vendors_save_label": "Salva selezione",
"settings_web_upload_warning": "Mostra un avviso quando si stampano caricamenti web",
"settings_delete_printer_file_after_print": "Elimina il file dalla stampante dopo una stampa riuscita",
"settings_delete_printer_file_after_print_hint": "Si applica solo alle stampe avviate tramite questo bridge (file caricati da esso) - le stampe avviate direttamente dalla stampante o da Anycubic Slicer non vengono mai eliminate, poiché non ne esiste alcuna copia altrove.",
"sf_all": "Tutti",
"sf_err": "✗ Fallito",
"sf_new": "Nuovo",

View File

@@ -293,6 +293,8 @@
"settings_visible_vendors_save": "保存选择",
"settings_visible_vendors_save_label": "保存选择",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"settings_delete_printer_file_after_print": "打印成功后从打印机删除文件",
"settings_delete_printer_file_after_print_hint": "仅适用于通过此网桥启动的打印(即由网桥自己上传的文件)——直接从打印机或 Anycubic Slicer 启动的打印任务永远不会被删除,因为它们没有其他备份。",
"sf_all": "全部",
"sf_err": "✗ 失败",
"sf_new": "新",