Compare commits
4 Commits
nightly-0.
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee1af4cb7 | |||
| 5e87f1f94f | |||
| 6c9363c718 | |||
| 86adde0a45 |
@@ -59,15 +59,11 @@ CONFIG_ENV_MAPPING = {
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"POWER_ON_URL": (CONFIG_SECTION_CONNECTION, "power_on_url"),
|
||||
"POWER_OFF_URL": (CONFIG_SECTION_CONNECTION, "power_off_url"),
|
||||
"POWER_STATUS_URL": (CONFIG_SECTION_CONNECTION, "power_status_url"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"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"),
|
||||
@@ -80,7 +76,7 @@ CONFIG_ENV_MAPPING = {
|
||||
|
||||
def _load_config_file(path: pathlib.Path):
|
||||
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
|
||||
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
|
||||
@@ -117,7 +113,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
env_vals[k.strip()] = v.strip()
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg[CONFIG_SECTION_CONNECTION] = {
|
||||
"printer_ip": env_vals.get("PRINTER_IP", ""),
|
||||
"mqtt_port": env_vals.get("MQTT_PORT", "9883"),
|
||||
@@ -179,7 +175,7 @@ def list_printers() -> list[dict]:
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return []
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
printers: list[dict] = []
|
||||
idx = 1
|
||||
@@ -241,7 +237,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return {}
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
if not cfg.has_section(section):
|
||||
@@ -282,7 +278,7 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str]
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
|
||||
@@ -324,7 +320,7 @@ def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return []
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
if not cfg.has_option(section, "visible_vendors"):
|
||||
@@ -346,7 +342,7 @@ def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
if not cfg.has_section(section):
|
||||
@@ -406,7 +402,7 @@ def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]:
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return {}
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _spoolman_map_section(printer_id)
|
||||
if cfg.has_option(section, "slot_spools"):
|
||||
@@ -426,7 +422,7 @@ def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _spoolman_map_section(printer_id)
|
||||
clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0}
|
||||
@@ -452,15 +448,11 @@ USERNAME = get("MQTT_USERNAME", "")
|
||||
PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
POWER_ON_URL = get("POWER_ON_URL", "")
|
||||
POWER_OFF_URL = get("POWER_OFF_URL", "")
|
||||
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
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"))
|
||||
|
||||
@@ -46,14 +46,10 @@ USERNAME = get("MQTT_USERNAME", "")
|
||||
PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
POWER_ON_URL = get("POWER_ON_URL", "")
|
||||
POWER_OFF_URL = get("POWER_OFF_URL", "")
|
||||
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
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", "")
|
||||
|
||||
176
kobrax_client.py
176
kobrax_client.py
@@ -101,46 +101,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -166,13 +126,6 @@ class KobraXClient:
|
||||
# underneath it (Issue #53). Protects against recv on a stale fd.
|
||||
self._sock_gen = 0
|
||||
self._running = False
|
||||
# Guards _reconnect() against concurrent invocation - both the reader
|
||||
# thread (keepalive ping failure) and publish()/publish_web() (send
|
||||
# failure) can trigger a reconnect independently. Without this, two
|
||||
# threads could race into _do_connect() at once, each opening its own
|
||||
# competing TLS handshake to a printer that likely only accepts one
|
||||
# mTLS session at a time (Issue #105).
|
||||
self._reconnect_lock = threading.Lock()
|
||||
|
||||
# Pending requests by msgid (for response ACK)
|
||||
self._pending_msgid: dict[str, dict] = {}
|
||||
@@ -184,11 +137,6 @@ class KobraXClient:
|
||||
|
||||
# Dedup: last hash per topic suffix to suppress repeated identical messages
|
||||
self._last_rx_hash: dict[str, str] = {}
|
||||
# Debug switch (MQTT_RAW_LOG=1): logs every RX message unfiltered on
|
||||
# INFO, including dedup'd duplicates and topics with no registered
|
||||
# callback - for capturing printer behavior the bridge doesn't
|
||||
# normally surface (e.g. reverse-engineering a rejected command).
|
||||
self._raw_log = os.environ.get("MQTT_RAW_LOG", "").strip().lower() in ("1", "true", "yes")
|
||||
# Fields that change every tick and should be stripped before dedup-hashing
|
||||
_VOLATILE = {"timestamp", "msgid", "progress", "curr_layer",
|
||||
"curr_nozzle_temp", "curr_hotbed_temp",
|
||||
@@ -229,7 +177,6 @@ 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])
|
||||
|
||||
@@ -284,81 +231,41 @@ class KobraXClient:
|
||||
self._sock = None
|
||||
self._sock_gen += 1
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Thread-safe check whether the MQTT socket is currently up. Used by
|
||||
the bridge's poll loop to detect a dead session even when publish()
|
||||
already swallowed the send failure and returned None instead of
|
||||
raising (Issue #105) - a TCP-reachable printer alone doesn't mean the
|
||||
MQTT/TLS session is still alive."""
|
||||
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."""
|
||||
log.warning("Connection lost - reconnecting...")
|
||||
# Close + invalidation under the lock so no sender is mid-sendall
|
||||
# auf den gerade geschlossenen Socket trifft (Issue #53).
|
||||
with self._lock:
|
||||
return self._sock is not None
|
||||
|
||||
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 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.
|
||||
|
||||
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
|
||||
try:
|
||||
log.warning("Connection lost - reconnecting...")
|
||||
# Close + invalidation under the lock so no sender is mid-sendall
|
||||
# auf den gerade geschlossenen Socket trifft (Issue #53).
|
||||
with self._lock:
|
||||
try:
|
||||
if self._sock is not None:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock = None
|
||||
self._sock_gen += 1
|
||||
delays = [2, 4, 8, 15, 30, 60]
|
||||
attempt = 0
|
||||
while self._running:
|
||||
delay = delays[min(attempt, len(delays) - 1)]
|
||||
try:
|
||||
self._do_connect()
|
||||
log.info("Reconnect successful (after %d attempts)", attempt + 1)
|
||||
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.
|
||||
slept = 0.0
|
||||
while slept < delay and self._running:
|
||||
time.sleep(min(0.5, delay - slept))
|
||||
slept += 0.5
|
||||
return False # only when disconnect() was called
|
||||
finally:
|
||||
self._reconnect_lock.release()
|
||||
try:
|
||||
if self._sock is not None:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock = None
|
||||
self._sock_gen += 1
|
||||
delays = [2, 4, 8, 15, 30, 60]
|
||||
attempt = 0
|
||||
while self._running:
|
||||
delay = delays[min(attempt, len(delays) - 1)]
|
||||
try:
|
||||
self._do_connect()
|
||||
log.info("Reconnect successful (after %d attempts)", attempt + 1)
|
||||
return True
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
lvl = log.warning if attempt <= 5 else log.debug
|
||||
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
|
||||
# Split sleep so disconnect() breaks the loop faster.
|
||||
slept = 0.0
|
||||
while slept < delay and self._running:
|
||||
time.sleep(min(0.5, delay - slept))
|
||||
slept += 0.5
|
||||
return False # only when disconnect() was called
|
||||
|
||||
def _subscribe(self, topic: str):
|
||||
with self._lock:
|
||||
@@ -486,9 +393,6 @@ class KobraXClient:
|
||||
def _dispatch(self, topic: str, payload: dict):
|
||||
suffix = "/".join(topic.split("/")[-2:])
|
||||
|
||||
if self._raw_log:
|
||||
log.info("RX [raw] %s %s", topic, json.dumps(payload, ensure_ascii=False))
|
||||
|
||||
# Structured RX log with dedup suppression
|
||||
h = self._dedup_hash(suffix, payload)
|
||||
is_dup = self._last_rx_hash.get(suffix) == h
|
||||
@@ -581,10 +485,7 @@ class KobraXClient:
|
||||
self._pending_msgid.pop(msgid, None)
|
||||
if report_registered:
|
||||
self._pending_report.pop(report_key, None)
|
||||
# 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):
|
||||
if not self._reconnect():
|
||||
return None
|
||||
# retry once after reconnect
|
||||
try:
|
||||
@@ -631,11 +532,10 @@ 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.
|
||||
# Non-blocking for the same reason as publish() (see _reconnect
|
||||
# docstring) - never hang this thread through a backoff loop.
|
||||
# fire-and-forget - the next call will hit the fresh socket
|
||||
# treffen.
|
||||
try:
|
||||
self._reconnect(wait_if_in_progress=False, persist=False)
|
||||
self._reconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1042,8 +1042,6 @@ class KobraXBridge:
|
||||
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
|
||||
"error_code": 0,
|
||||
"pause_msg": "",
|
||||
"storage_total_mb": 0,
|
||||
"storage_used_mb": 0,
|
||||
}
|
||||
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
|
||||
self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot
|
||||
@@ -1064,20 +1062,9 @@ class KobraXBridge:
|
||||
# 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] = {}
|
||||
# Last buried/report payload (printer's own analytics event, fired once
|
||||
# per print start regardless of slicer - see reference_buried_report_trigger
|
||||
# memory). Carries gcode_size/estimate_duration/total_layers that are
|
||||
# otherwise unavailable for files not uploaded through the bridge itself
|
||||
# (Issue #102). Single entry only - just the most recent print.
|
||||
self._buried_cache: dict | None = None
|
||||
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()
|
||||
@@ -1126,7 +1113,6 @@ class KobraXBridge:
|
||||
client.callbacks["print/report"] = self._on_print
|
||||
client.callbacks["info/report"] = self._on_info
|
||||
client.callbacks["file/report"] = self._on_file
|
||||
client.callbacks["buried/report"] = self._on_buried
|
||||
client.callbacks["multiColorBox/report"] = self._on_multicolor_box
|
||||
client.callbacks["light/report"] = self._on_light
|
||||
client.callbacks["skip/report"] = self._on_skip
|
||||
@@ -1317,7 +1303,7 @@ class KobraXBridge:
|
||||
cfg_path = self._find_config_path()
|
||||
if not cfg_path.is_file():
|
||||
return defaults
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(cfg_path, encoding="utf-8")
|
||||
sec = "ace_dry_presets"
|
||||
if not cfg.has_section(sec):
|
||||
@@ -1401,7 +1387,6 @@ 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 = {}
|
||||
@@ -1414,21 +1399,11 @@ 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
|
||||
@@ -1595,25 +1570,6 @@ 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
|
||||
@@ -1638,30 +1594,6 @@ class KobraXBridge:
|
||||
if self._file_action_waiters.get(action) is waiter:
|
||||
del self._file_action_waiters[action]
|
||||
|
||||
def _on_buried(self, payload: dict):
|
||||
"""buried/report - the printer's own analytics event, fired once per
|
||||
print start (verified live against a real Kobra X: fires identically
|
||||
for prints started via Anycubic Slicer Next and via OrcaSlicer/the
|
||||
bridge). Carries gcode_size/estimate_duration/total_layers, which
|
||||
_build_file_metadata() falls back to for files not in our own
|
||||
GCodeStore (Issue #102), plus printer storage usage."""
|
||||
d = payload.get("data") or {}
|
||||
task_name = d.get("task_name") or ""
|
||||
if not task_name:
|
||||
return
|
||||
self._buried_cache = {
|
||||
"task_name": task_name,
|
||||
"gcode_size": int(d.get("gcode_size") or 0),
|
||||
"estimate_duration": int(d.get("estimate_duration") or 0),
|
||||
"total_layers": int(d.get("total_layers") or 0),
|
||||
}
|
||||
self._state["storage_total_mb"] = int(d.get("storage_total") or 0)
|
||||
self._state["storage_used_mb"] = int(d.get("storage_used") or 0)
|
||||
log.info(
|
||||
f"buried/report: {task_name} size={d.get('gcode_size')} "
|
||||
f"est={d.get('estimate_duration')}s layers={d.get('total_layers')}"
|
||||
)
|
||||
|
||||
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/
|
||||
@@ -2278,32 +2210,12 @@ class KobraXBridge:
|
||||
return "", ""
|
||||
return vendor, family
|
||||
|
||||
@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:
|
||||
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.
|
||||
|
||||
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."""
|
||||
RFID string."""
|
||||
matches = [
|
||||
p for p in self._load_orca_filaments()
|
||||
if p.get("vendor", "").lower() == vendor.lower()
|
||||
@@ -2311,28 +2223,12 @@ class KobraXBridge:
|
||||
]
|
||||
if not matches:
|
||||
return {}
|
||||
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
|
||||
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
|
||||
@@ -2349,44 +2245,21 @@ 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. Falls back to
|
||||
auto-resolving a combined ACE-RFID type string (Issue #101) when there
|
||||
is no (usable) manual override.
|
||||
still matches the material currently loaded in the AMS.
|
||||
|
||||
Non-destructive suppression (Option A): when the family no longer matches
|
||||
(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
|
||||
|
||||
(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)."""
|
||||
profile = self._filament_profiles.get(global_idx) or {}
|
||||
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 {}
|
||||
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
|
||||
|
||||
def _build_lane_data(self) -> dict:
|
||||
"""Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0.
|
||||
@@ -2432,19 +2305,30 @@ 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).
|
||||
# Three-layer resolution for the filament hint sent to OrcaSlicer,
|
||||
# all handled inside _effective_slot_profile() (Issue #101):
|
||||
# Two-layer resolution for the filament hint sent to OrcaSlicer:
|
||||
# 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
|
||||
# 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
|
||||
# 2. 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")
|
||||
@@ -3492,78 +3376,9 @@ class KobraXBridge:
|
||||
"bridge_url": bridge_url,
|
||||
"printer_ip": br._args.printer_ip,
|
||||
"device_id": br._args.device_id or "",
|
||||
"has_power_control": bool(
|
||||
(getattr(br._args, "power_on_url", "") or "").strip()
|
||||
or (getattr(br._args, "power_off_url", "") or "").strip()
|
||||
),
|
||||
})
|
||||
return self._json_cors({"result": out})
|
||||
|
||||
async def handle_kx_printer_power(self, request):
|
||||
"""Toggles an external smart plug (e.g. Tasmota) for a printer that
|
||||
has no MQTT-level power-off/standby command of its own (Issue #103).
|
||||
|
||||
Just fires a plain HTTP GET at the configured power_on_url/power_off_url -
|
||||
works for Tasmota's cmnd=Power%20on/off style URLs and any other
|
||||
switch that exposes a GET-triggered on/off endpoint."""
|
||||
pid = str(request.match_info.get("pid", "")).strip()
|
||||
br = self._all_bridges.get(pid)
|
||||
if br is None:
|
||||
return self._json_cors({"error": "unknown printer id"}, status=404)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
action = str(body.get("action", "")).lower()
|
||||
if action not in ("on", "off"):
|
||||
return self._json_cors({"error": "action must be 'on' or 'off'"}, status=400)
|
||||
url = getattr(br._args, f"power_{action}_url", "") or ""
|
||||
if not url:
|
||||
return self._json_cors({"error": f"no power_{action}_url configured"}, status=400)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
ok = resp.status == 200
|
||||
except Exception as e:
|
||||
return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502)
|
||||
return self._json_cors({"result": "ok" if ok else "error", "status": "on" if action == "on" else "off"})
|
||||
|
||||
async def handle_kx_printer_power_status(self, request):
|
||||
"""Queries the configured smart plug for its current on/off state.
|
||||
|
||||
Tries to parse a Tasmota-style {"POWER":"ON"/"OFF"} JSON body first,
|
||||
falls back to a plain substring search for "ON"/"OFF" in the raw
|
||||
response so other switch firmwares with a simpler status endpoint
|
||||
still work."""
|
||||
pid = str(request.match_info.get("pid", "")).strip()
|
||||
br = self._all_bridges.get(pid)
|
||||
if br is None:
|
||||
return self._json_cors({"error": "unknown printer id"}, status=404)
|
||||
url = getattr(br._args, "power_status_url", "") or ""
|
||||
if not url:
|
||||
return self._json_cors({"error": "no power_status_url configured"}, status=400)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
text = await resp.text()
|
||||
except Exception as e:
|
||||
return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502)
|
||||
state = "unknown"
|
||||
try:
|
||||
data = json.loads(text)
|
||||
power = str(data.get("POWER", "")).upper()
|
||||
if power in ("ON", "OFF"):
|
||||
state = power.lower()
|
||||
except Exception:
|
||||
pass
|
||||
if state == "unknown":
|
||||
up = text.upper()
|
||||
if "ON" in up and "OFF" not in up:
|
||||
state = "on"
|
||||
elif "OFF" in up:
|
||||
state = "off"
|
||||
return self._json_cors({"state": state})
|
||||
|
||||
async def handle_kx_print(self, request):
|
||||
"""Print start from the GCode store with optional filament assignments."""
|
||||
try:
|
||||
@@ -3630,7 +3445,6 @@ 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})
|
||||
|
||||
@@ -3763,18 +3577,6 @@ class KobraXBridge:
|
||||
size_bytes = int(gf.get("size_bytes") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
# Third fallback: the printer's own buried/report analytics event
|
||||
# (fires once per print start regardless of slicer), for files that
|
||||
# are neither the currently-tracked job nor in our own GCodeStore -
|
||||
# e.g. printed directly via Anycubic Slicer Next (Issue #102).
|
||||
buried = self._buried_cache
|
||||
if buried and buried.get("task_name") == filename:
|
||||
if not total_layers:
|
||||
total_layers = buried.get("total_layers") or total_layers
|
||||
if not est_time:
|
||||
est_time = buried.get("estimate_duration") or est_time
|
||||
if not size_bytes:
|
||||
size_bytes = buried.get("gcode_size") or size_bytes
|
||||
if not layer_h:
|
||||
layer_h = self._layer_height_from_filename(filename)
|
||||
if layer_h and not first_h:
|
||||
@@ -4937,8 +4739,6 @@ class KobraXBridge:
|
||||
"version": self._read_version(),
|
||||
"pause_msg": s.get("pause_msg", ""),
|
||||
"error_code": s.get("error_code", 0),
|
||||
"storage_total_mb": s.get("storage_total_mb", 0),
|
||||
"storage_used_mb": s.get("storage_used_mb", 0),
|
||||
})
|
||||
|
||||
async def handle_moonraker_database(self, request):
|
||||
@@ -5072,15 +4872,11 @@ class KobraXBridge:
|
||||
"password": self._args.password,
|
||||
"mode_id": self._args.mode_id,
|
||||
"device_id": self._args.device_id,
|
||||
"power_on_url": getattr(self._args, "power_on_url", "") or "",
|
||||
"power_off_url": getattr(self._args, "power_off_url", "") or "",
|
||||
"power_status_url": getattr(self._args, "power_status_url", "") or "",
|
||||
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
||||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||||
"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),
|
||||
@@ -5098,7 +4894,7 @@ class KobraXBridge:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Read the existing config.ini (comments are lost, but values are kept)
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
if config_path.is_file():
|
||||
cfg.read(config_path, encoding="utf-8")
|
||||
|
||||
@@ -5114,15 +4910,11 @@ class KobraXBridge:
|
||||
cfg.set("connection", "password", str(data.get("password", self._args.password or "")))
|
||||
cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or "")))
|
||||
cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or "")))
|
||||
cfg.set("connection", "power_on_url", str(data.get("power_on_url", getattr(self._args, "power_on_url", "") or "")).strip())
|
||||
cfg.set("connection", "power_off_url", str(data.get("power_off_url", getattr(self._args, "power_off_url", "") or "")).strip())
|
||||
cfg.set("connection", "power_status_url", str(data.get("power_status_url", getattr(self._args, "power_status_url", "") or "")).strip())
|
||||
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
|
||||
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
|
||||
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:
|
||||
@@ -5187,7 +4979,7 @@ class KobraXBridge:
|
||||
|
||||
import configparser
|
||||
config_path = self._find_config_path()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
if config_path.is_file():
|
||||
cfg.read(config_path, encoding="utf-8")
|
||||
|
||||
@@ -5255,7 +5047,7 @@ class KobraXBridge:
|
||||
|
||||
import configparser
|
||||
config_path = self._find_config_path()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg = configparser.ConfigParser()
|
||||
if config_path.is_file():
|
||||
cfg.read(config_path, encoding="utf-8")
|
||||
|
||||
@@ -5351,12 +5143,7 @@ class KobraXBridge:
|
||||
|
||||
# ─── Update ──────────────────────────────────────────────────────────────
|
||||
|
||||
# limit=1 would only ever see the single newest release regardless of type -
|
||||
# if that happens to be a nightly/dev prerelease (the common case, since
|
||||
# those publish far more often than stable), the stable_releases filter
|
||||
# below finds nothing and update checks fail with "no stable releases
|
||||
# found" even though older stable releases exist (Issue #104).
|
||||
STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=20"
|
||||
STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1"
|
||||
NIGHTLY_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=5&pre-release=true"
|
||||
DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true"
|
||||
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
|
||||
@@ -5921,25 +5708,6 @@ class KobraXBridge:
|
||||
info = self.client.query_info()
|
||||
if info:
|
||||
self._on_info(info)
|
||||
elif not self.client.is_connected():
|
||||
# publish() swallows send/reconnect failures internally and
|
||||
# just returns None (Issue #105) - a falsy `info` alone
|
||||
# doesn't distinguish "printer sent nothing this tick" from
|
||||
# "the MQTT session itself is dead". Check is_connected()
|
||||
# explicitly so a dead session gets routed into the same
|
||||
# clean offline/reconnect path as a TCP-unreachable printer,
|
||||
# instead of silently retrying every poll_interval forever.
|
||||
log.warning("MQTT connection lost (query returned no response) - switching to offline mode")
|
||||
self._state["print_state"] = "error"
|
||||
self._state["kobra_state"] = "offline"
|
||||
self._state["connection_error"] = f"MQTT connection lost ({self._args.printer_ip})"
|
||||
try:
|
||||
self.client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
_offline = True
|
||||
stop_event.wait(getattr(self._args, "poll_interval", 3))
|
||||
continue
|
||||
# While printing: query print/report directly
|
||||
if self._state["print_state"] in ("printing", "preheating",
|
||||
"auto_leveling", "checking", "init"):
|
||||
@@ -6092,8 +5860,6 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
r.add_get("/kx/printers", bridge.handle_kx_printers)
|
||||
r.add_post("/kx/printers/add", bridge.handle_kx_printer_add)
|
||||
r.add_delete("/kx/printers/{pid}", bridge.handle_kx_printer_remove)
|
||||
r.add_post("/kx/printers/{pid}/power", bridge.handle_kx_printer_power)
|
||||
r.add_get("/kx/printers/{pid}/power-status", bridge.handle_kx_printer_power_status)
|
||||
r.add_post("/kx/print", bridge.handle_kx_print)
|
||||
r.add_get("/kx/files", bridge.handle_kx_files)
|
||||
r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete)
|
||||
@@ -6149,9 +5915,6 @@ def _build_per_printer_args(base_args, p: dict):
|
||||
a.mode_id = p.get("mode_id") or base_args.mode_id
|
||||
a.device_id = p.get("device_id") or base_args.device_id
|
||||
a.port = int(p.get("http_port") or base_args.port)
|
||||
a.power_on_url = p.get("power_on_url") or getattr(base_args, "power_on_url", "") or ""
|
||||
a.power_off_url = p.get("power_off_url") or getattr(base_args, "power_off_url", "") or ""
|
||||
a.power_status_url = p.get("power_status_url") or getattr(base_args, "power_status_url", "") or ""
|
||||
return a
|
||||
|
||||
|
||||
@@ -6294,21 +6057,11 @@ def main():
|
||||
parser.add_argument("--password", default=env_loader.PASSWORD)
|
||||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||||
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
|
||||
parser.add_argument("--power-on-url", default=env_loader.POWER_ON_URL,
|
||||
help="HTTP GET URL to power the printer on (e.g. a Tasmota smart plug)")
|
||||
parser.add_argument("--power-off-url", default=env_loader.POWER_OFF_URL,
|
||||
help="HTTP GET URL to power the printer off")
|
||||
parser.add_argument("--power-status-url", default=env_loader.POWER_STATUS_URL,
|
||||
help="HTTP GET URL returning the smart plug's current on/off state")
|
||||
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
|
||||
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
||||
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,
|
||||
|
||||
@@ -109,128 +109,3 @@ 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") == []
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""
|
||||
Tests für buried/report — das druckerseitige Analytics-Event, das einmal pro
|
||||
Druckstart feuert (verifiziert live gegen einen echten Kobra X, sowohl für
|
||||
Anycubic Slicer Next als auch für OrcaSlicer/die Bridge selbst). Liefert
|
||||
gcode_size/estimate_duration/total_layers, die server/files/metadata für
|
||||
Dateien außerhalb des eigenen GCodeStore sonst nicht hat (Issue #102).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
BURIED_PAYLOAD = {
|
||||
"type": "buried",
|
||||
"action": "PrintStart",
|
||||
"code": 200,
|
||||
"state": "done",
|
||||
"data": {
|
||||
"task_name": "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode",
|
||||
"gcode_size": 644437,
|
||||
"estimate_duration": 1264,
|
||||
"total_layers": 8,
|
||||
"storage_total": 6481,
|
||||
"storage_used": 898,
|
||||
"slicer": "OrcaSlicer",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_on_buried_populates_cache(client):
|
||||
_, bridge = client
|
||||
bridge._on_buried(BURIED_PAYLOAD)
|
||||
assert bridge._buried_cache == {
|
||||
"task_name": "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode",
|
||||
"gcode_size": 644437,
|
||||
"estimate_duration": 1264,
|
||||
"total_layers": 8,
|
||||
}
|
||||
|
||||
|
||||
def test_on_buried_populates_storage_state(client):
|
||||
_, bridge = client
|
||||
bridge._on_buried(BURIED_PAYLOAD)
|
||||
assert bridge._state["storage_total_mb"] == 6481
|
||||
assert bridge._state["storage_used_mb"] == 898
|
||||
|
||||
|
||||
def test_on_buried_ignores_payload_without_task_name(client):
|
||||
_, bridge = client
|
||||
bridge._buried_cache = None
|
||||
bridge._on_buried({"type": "buried", "data": {"gcode_size": 123}})
|
||||
assert bridge._buried_cache is None
|
||||
|
||||
|
||||
def test_build_file_metadata_uses_buried_fallback_for_unknown_file(client):
|
||||
"""A file not in the GCodeStore and not the currently-tracked job should
|
||||
still get real size/estimated_time/layer_count from the buried cache."""
|
||||
_, bridge = client
|
||||
bridge._on_buried(BURIED_PAYLOAD)
|
||||
meta = bridge._build_file_metadata("Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode")
|
||||
assert meta["size"] == 644437
|
||||
assert meta["estimated_time"] == 1264
|
||||
assert meta["layer_count"] == 8
|
||||
|
||||
|
||||
def test_build_file_metadata_ignores_buried_cache_for_different_file(client):
|
||||
"""The buried cache must only apply when task_name matches the queried
|
||||
filename - otherwise it would leak the last print's data into an
|
||||
unrelated query, the exact bug Issue #102 already fixed for live state."""
|
||||
_, bridge = client
|
||||
bridge._on_buried(BURIED_PAYLOAD)
|
||||
meta = bridge._build_file_metadata("some_other_file.gcode")
|
||||
assert meta["size"] == 1 # unchanged fallback, not leaked from buried cache
|
||||
assert meta["estimated_time"] is None
|
||||
assert meta["layer_count"] is None
|
||||
|
||||
|
||||
def test_build_file_metadata_prefers_gcodestore_over_buried(client):
|
||||
"""GCodeStore data (from the bridge's own upload) must win over the
|
||||
buried cache when both are available for the same filename."""
|
||||
_, bridge = client
|
||||
bridge._on_buried(BURIED_PAYLOAD)
|
||||
with bridge._store._lock:
|
||||
bridge._store._conn.execute(
|
||||
"INSERT INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
("f1", "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode", "/tmp/f1", 999999, "2026-01-01T00:00:00Z", 42, 5000),
|
||||
)
|
||||
bridge._store._conn.commit()
|
||||
meta = bridge._build_file_metadata("Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode")
|
||||
assert meta["size"] == 999999
|
||||
assert meta["estimated_time"] == 5000
|
||||
assert meta["layer_count"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_state_reports_storage_after_buried_report(client):
|
||||
c, bridge = client
|
||||
bridge._on_buried(BURIED_PAYLOAD)
|
||||
resp = await c.get("/api/state")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["storage_total_mb"] == 6481
|
||||
assert data["storage_used_mb"] == 898
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_state_storage_defaults_to_zero(client):
|
||||
c, _ = client
|
||||
resp = await c.get("/api/state")
|
||||
data = await resp.json()
|
||||
assert data["storage_total_mb"] == 0
|
||||
assert data["storage_used_mb"] == 0
|
||||
@@ -1,157 +0,0 @@
|
||||
"""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
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
Tests für den MQTT-Reconnect-Mechanismus (Issue #105) — der Client hing nach
|
||||
einem Drucker-Reconnect fest, weil zwei unabhängige Fehlerpfade (der Reader-
|
||||
Thread-Keepalive und publish()'s eigener Reconnect-Trigger) unkoordiniert
|
||||
parallel liefen, UND weil der Poll-Loop einen None-Rückgabewert von publish()
|
||||
(statt einer Exception) nie als "Verbindung tot" erkannte.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from kobrax_client import KobraXClient
|
||||
|
||||
|
||||
def _client(**overrides):
|
||||
kwargs = dict(
|
||||
host="192.168.1.100", username="u", password="p",
|
||||
mode_id="20030", device_id="abc123", port=9883,
|
||||
client_id="test",
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return KobraXClient(**kwargs)
|
||||
|
||||
|
||||
def test_is_connected_false_when_no_socket():
|
||||
c = _client()
|
||||
assert c.is_connected() is False
|
||||
|
||||
|
||||
def test_is_connected_true_when_socket_present():
|
||||
c = _client()
|
||||
c._sock = object() # any truthy stand-in for a real socket
|
||||
assert c.is_connected() is True
|
||||
|
||||
|
||||
def test_reconnect_concurrent_calls_only_run_do_connect_once():
|
||||
"""Two threads calling _reconnect() at the same time must not both run
|
||||
_do_connect() - only one handshake should happen; the second caller waits
|
||||
for the first instead of racing it (Issue #105)."""
|
||||
c = _client()
|
||||
c._running = True
|
||||
do_connect_calls = []
|
||||
call_lock = threading.Lock()
|
||||
release_event = threading.Event()
|
||||
|
||||
def fake_do_connect():
|
||||
with call_lock:
|
||||
do_connect_calls.append(1)
|
||||
# Simulate a slow handshake so the second _reconnect() call has time
|
||||
# to observe the lock as already held.
|
||||
release_event.wait(timeout=2.0)
|
||||
c._sock = object()
|
||||
|
||||
c._do_connect = fake_do_connect
|
||||
|
||||
results = []
|
||||
|
||||
def run():
|
||||
results.append(c._reconnect())
|
||||
|
||||
t1 = threading.Thread(target=run)
|
||||
t2 = threading.Thread(target=run)
|
||||
t1.start()
|
||||
time.sleep(0.05) # let t1 acquire the lock and enter _do_connect first
|
||||
t2.start()
|
||||
time.sleep(0.1)
|
||||
release_event.set() # let the in-flight handshake finish
|
||||
t1.join(timeout=3)
|
||||
t2.join(timeout=3)
|
||||
|
||||
assert len(do_connect_calls) == 1
|
||||
assert results == [True, True]
|
||||
|
||||
|
||||
def test_reconnect_second_waiter_returns_after_first_completes():
|
||||
c = _client()
|
||||
c._running = True
|
||||
|
||||
def fake_do_connect():
|
||||
time.sleep(0.1)
|
||||
c._sock = object()
|
||||
|
||||
c._do_connect = fake_do_connect
|
||||
|
||||
t1 = threading.Thread(target=c._reconnect)
|
||||
t1.start()
|
||||
time.sleep(0.02)
|
||||
# Second call while the first is still mid-handshake.
|
||||
result = c._reconnect()
|
||||
t1.join(timeout=3)
|
||||
|
||||
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
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Tests für _poll_loop's Umgang mit einer toten MQTT-Session (Issue #105).
|
||||
publish()/query_info() swallow send failures internally and return None
|
||||
instead of raising - the poll loop must treat that (combined with
|
||||
is_connected() == False) as "connection lost" and switch to the offline
|
||||
branch, instead of silently retrying forever every poll_interval.
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
|
||||
def _bridge():
|
||||
c = MagicMock()
|
||||
c.callbacks = {}
|
||||
c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="192.168.1.100", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxpoll-"), poll_interval=0.05,
|
||||
)
|
||||
return KobraXBridge(c, args=args)
|
||||
|
||||
|
||||
def test_poll_loop_switches_to_offline_when_query_returns_none_and_disconnected():
|
||||
b = _bridge()
|
||||
b._state["print_state"] = "standby"
|
||||
b._state["kobra_state"] = "free"
|
||||
b.client.query_info.return_value = None
|
||||
b.client.is_connected.return_value = False
|
||||
b._printer_reachable = MagicMock(return_value=True) # TCP still fine
|
||||
|
||||
stop_event = threading.Event()
|
||||
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.2)
|
||||
stop_event.set()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert b._state["kobra_state"] == "offline"
|
||||
b.client.disconnect.assert_called()
|
||||
|
||||
|
||||
def test_poll_loop_stays_online_when_query_returns_none_but_still_connected():
|
||||
"""A single missed poll tick (info momentarily falsy) must not flip the
|
||||
bridge offline if the MQTT session itself is still alive."""
|
||||
b = _bridge()
|
||||
b._state["print_state"] = "standby"
|
||||
b._state["kobra_state"] = "free"
|
||||
b.client.query_info.return_value = None
|
||||
b.client.is_connected.return_value = True # session still up
|
||||
b.client.query_multicolor_box.return_value = None
|
||||
b._printer_reachable = MagicMock(return_value=True)
|
||||
|
||||
stop_event = threading.Event()
|
||||
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.2)
|
||||
stop_event.set()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert b._state["kobra_state"] != "offline"
|
||||
|
||||
|
||||
def test_poll_loop_recovers_via_offline_branch_once_reachable_again():
|
||||
"""Once flipped offline, the existing offline branch should re-connect
|
||||
as soon as the printer becomes reachable again (pre-existing behavior,
|
||||
unaffected by this fix)."""
|
||||
b = _bridge()
|
||||
b._state["print_state"] = "standby"
|
||||
b._state["kobra_state"] = "free"
|
||||
b.client.query_info.return_value = None
|
||||
b.client.is_connected.return_value = False
|
||||
b._printer_reachable = MagicMock(return_value=True)
|
||||
|
||||
stop_event = threading.Event()
|
||||
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.15) # let it flip offline
|
||||
assert b._state["kobra_state"] == "offline"
|
||||
|
||||
# Printer "comes back": client.connect() succeeds, subsequent query_info
|
||||
# starts returning real data again.
|
||||
b.client.connect.side_effect = None
|
||||
b.client.query_info.return_value = {"data": {"state": "free"}}
|
||||
time.sleep(0.2)
|
||||
stop_event.set()
|
||||
t.join(timeout=2)
|
||||
|
||||
b.client.connect.assert_called()
|
||||
@@ -1,175 +0,0 @@
|
||||
"""External smart-plug power control (Issue #103).
|
||||
|
||||
The printer itself has no MQTT command to power off or enter standby - only
|
||||
heaters/motors/etc. can be controlled remotely. For users running the
|
||||
printer through a Tasmota-style smart plug, the bridge exposes plain
|
||||
HTTP GET on/off/status URLs (configured per printer) as its own dashboard
|
||||
button, instead of routing through Moonraker's device_power API.
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge, build_app
|
||||
|
||||
|
||||
def _make_bridge(pid="1", **arg_overrides):
|
||||
c = MagicMock()
|
||||
c.callbacks = {}
|
||||
c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="192.168.1.50", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxpower-"),
|
||||
power_on_url="", power_off_url="", power_status_url="",
|
||||
)
|
||||
for k, v in arg_overrides.items():
|
||||
setattr(args, k, v)
|
||||
all_bridges = {}
|
||||
bridge = KobraXBridge(c, args=args, printer_id=pid, all_bridges=all_bridges)
|
||||
all_bridges[pid] = bridge
|
||||
return bridge
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def power_client():
|
||||
bridge = _make_bridge(
|
||||
power_on_url="http://192.168.1.99/cm?cmnd=Power%20on",
|
||||
power_off_url="http://192.168.1.99/cm?cmnd=Power%20off",
|
||||
power_status_url="http://192.168.1.99/cm?cmnd=Power",
|
||||
)
|
||||
app = build_app(bridge)
|
||||
async with TestClient(TestServer(app)) as c:
|
||||
yield c, bridge
|
||||
|
||||
|
||||
def _fake_get_response(status=200, text=""):
|
||||
resp = MagicMock()
|
||||
resp.status = status
|
||||
resp.text = AsyncMock(return_value=text)
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=resp)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printers_list_reports_has_power_control(power_client):
|
||||
c, bridge = power_client
|
||||
resp = await c.get("/kx/printers")
|
||||
data = await resp.json()
|
||||
assert data["result"][0]["has_power_control"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printers_list_no_power_control_when_unconfigured():
|
||||
bridge = _make_bridge()
|
||||
app = build_app(bridge)
|
||||
async with TestClient(TestServer(app)) as c:
|
||||
resp = await c.get("/kx/printers")
|
||||
data = await resp.json()
|
||||
assert data["result"][0]["has_power_control"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_on_hits_configured_url(power_client):
|
||||
c, bridge = power_client
|
||||
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200)) as mock_get:
|
||||
resp = await c.post("/kx/printers/1/power", json={"action": "on"})
|
||||
data = await resp.json()
|
||||
assert resp.status == 200
|
||||
assert data["result"] == "ok"
|
||||
mock_get.assert_called_once()
|
||||
assert mock_get.call_args[0][0] == "http://192.168.1.99/cm?cmnd=Power%20on"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_off_hits_configured_url(power_client):
|
||||
c, bridge = power_client
|
||||
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200)) as mock_get:
|
||||
resp = await c.post("/kx/printers/1/power", json={"action": "off"})
|
||||
data = await resp.json()
|
||||
assert resp.status == 200
|
||||
assert data["result"] == "ok"
|
||||
assert mock_get.call_args[0][0] == "http://192.168.1.99/cm?cmnd=Power%20off"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_invalid_action_rejected(power_client):
|
||||
c, bridge = power_client
|
||||
resp = await c.post("/kx/printers/1/power", json={"action": "toggle"})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_unknown_printer_id_404(power_client):
|
||||
c, bridge = power_client
|
||||
resp = await c.post("/kx/printers/99/power", json={"action": "on"})
|
||||
assert resp.status == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_missing_url_configured_error():
|
||||
bridge = _make_bridge() # no power_on_url set
|
||||
app = build_app(bridge)
|
||||
async with TestClient(TestServer(app)) as c:
|
||||
resp = await c.post("/kx/printers/1/power", json={"action": "on"})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_switch_unreachable_returns_502(power_client):
|
||||
c, bridge = power_client
|
||||
with patch("aiohttp.ClientSession.get", side_effect=OSError("connection refused")):
|
||||
resp = await c.post("/kx/printers/1/power", json={"action": "on"})
|
||||
assert resp.status == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_status_parses_tasmota_json(power_client):
|
||||
c, bridge = power_client
|
||||
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, '{"POWER":"ON"}')):
|
||||
resp = await c.get("/kx/printers/1/power-status")
|
||||
data = await resp.json()
|
||||
assert data["state"] == "on"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_status_parses_tasmota_json_off(power_client):
|
||||
c, bridge = power_client
|
||||
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, '{"POWER":"OFF"}')):
|
||||
resp = await c.get("/kx/printers/1/power-status")
|
||||
data = await resp.json()
|
||||
assert data["state"] == "off"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_status_falls_back_to_plain_text(power_client):
|
||||
c, bridge = power_client
|
||||
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, "STATE: ON")):
|
||||
resp = await c.get("/kx/printers/1/power-status")
|
||||
data = await resp.json()
|
||||
assert data["state"] == "on"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_status_missing_url_configured_error():
|
||||
bridge = _make_bridge() # no power_status_url set
|
||||
app = build_app(bridge)
|
||||
async with TestClient(TestServer(app)) as c:
|
||||
resp = await c.get("/kx/printers/1/power-status")
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_roundtrip_persists_power_urls(power_client):
|
||||
c, bridge = power_client
|
||||
resp = await c.get("/api/settings")
|
||||
data = await resp.json()
|
||||
assert data["power_on_url"] == "http://192.168.1.99/cm?cmnd=Power%20on"
|
||||
assert data["power_off_url"] == "http://192.168.1.99/cm?cmnd=Power%20off"
|
||||
assert data["power_status_url"] == "http://192.168.1.99/cm?cmnd=Power"
|
||||
@@ -1,80 +0,0 @@
|
||||
"""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
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Update-check regression for Issue #104.
|
||||
|
||||
STABLE_RELEASE_API used limit=1, so it only ever saw the single newest
|
||||
release on Gitea regardless of type. Since nightly/dev prereleases publish
|
||||
far more often than stable releases, that newest release is almost always a
|
||||
prerelease - the stable_releases filter (not prerelease) then found nothing
|
||||
and /api/update/check returned "no stable releases found" even though older
|
||||
stable releases exist.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _fake_releases_response(payload):
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.json = AsyncMock(return_value=payload)
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=resp)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stable_update_check_finds_release_behind_newer_prereleases(client):
|
||||
c, bridge = client
|
||||
bridge._read_version = lambda: "0.9.27"
|
||||
|
||||
releases = (
|
||||
[{"tag_name": f"nightly-0.9.30-nightly{i}", "prerelease": True} for i in range(1, 7)]
|
||||
+ [{"tag_name": "v0.9.29", "prerelease": False, "body": "changelog"}]
|
||||
)
|
||||
|
||||
with patch("aiohttp.ClientSession.get", return_value=_fake_releases_response(releases)):
|
||||
resp = await c.get("/api/update/check")
|
||||
data = await resp.json()
|
||||
|
||||
assert resp.status == 200
|
||||
assert data["latest"] == "0.9.29"
|
||||
assert data["update_available"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stable_update_check_requests_enough_releases_to_skip_prereleases(client):
|
||||
"""The API URL itself must ask for more than the single newest release -
|
||||
a limit=1 request can never find a stable release behind a run of
|
||||
prereleases no matter how the response is parsed."""
|
||||
c, bridge = client
|
||||
bridge._read_version = lambda: "0.9.27"
|
||||
|
||||
import re
|
||||
assert not re.search(r"limit=1(?!\d)", bridge.STABLE_RELEASE_API), (
|
||||
"STABLE_RELEASE_API must request more than 1 release, otherwise a "
|
||||
"recent nightly/dev prerelease being the newest release hides all "
|
||||
"stable releases behind it (Issue #104)"
|
||||
)
|
||||
@@ -450,11 +450,6 @@ function applyLang(){
|
||||
setText('lbl-password',T.settings_password);
|
||||
setText('lbl-device-id',T.settings_device_id);
|
||||
setText('lbl-mode-id',T.settings_mode_id);
|
||||
setText('modal-sec-power',T.settings_power||'Power Switch');
|
||||
setText('lbl-power-on-url',T.settings_power_on_url||'Power-On URL');
|
||||
setText('lbl-power-off-url',T.settings_power_off_url||'Power-Off URL');
|
||||
setText('lbl-power-status-url',T.settings_power_status_url||'Status URL');
|
||||
setText('lbl-power-hint',T.settings_power_hint||'Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer\'s mains power. Leave empty to hide the power button.');
|
||||
setText('lbl-default-slot',T.settings_default_slot);
|
||||
setText('opt-slot-auto',T.settings_slot_auto);
|
||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||
@@ -464,8 +459,6 @@ 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);
|
||||
|
||||
@@ -1134,16 +1127,12 @@ function openSettings(){
|
||||
document.getElementById('s-password').value=d.password||'';
|
||||
document.getElementById('s-device-id').value=d.device_id||'';
|
||||
document.getElementById('s-mode-id').value=d.mode_id||'';
|
||||
var pon=document.getElementById('s-power-on-url');if(pon)pon.value=d.power_on_url||'';
|
||||
var poff=document.getElementById('s-power-off-url');if(poff)poff.value=d.power_off_url||'';
|
||||
var pstat=document.getElementById('s-power-status-url');if(pstat)pstat.value=d.power_status_url||'';
|
||||
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
|
||||
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
|
||||
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
|
||||
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
|
||||
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
|
||||
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
|
||||
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){
|
||||
@@ -1906,16 +1895,12 @@ function saveSettings(){
|
||||
password: document.getElementById('s-password').value,
|
||||
device_id: document.getElementById('s-device-id').value,
|
||||
mode_id: document.getElementById('s-mode-id').value,
|
||||
power_on_url: (document.getElementById('s-power-on-url')||{}).value||'',
|
||||
power_off_url: (document.getElementById('s-power-off-url')||{}).value||'',
|
||||
power_status_url: (document.getElementById('s-power-status-url')||{}).value||'',
|
||||
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
|
||||
web_upload_warning:webUploadWarning,
|
||||
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||'',
|
||||
@@ -3999,7 +3984,6 @@ function loadPrinterTab(){
|
||||
'<span style="font-weight:700;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">🖨 '+p.name+'</span>'+
|
||||
'<span style="display:flex;align-items:center;gap:8px;flex-shrink:0">'+
|
||||
(isActive?'<span style="font-size:11px;color:var(--accent);font-weight:600">'+T.printers_active+'</span>':'')+
|
||||
(p.has_power_control?'<button id="power-btn-'+printerNum+'" onclick="togglePrinterPower(\''+printerNum+'\')" title="'+T.printers_power+'" style="background:none;border:none;color:var(--txt2);font-size:16px;cursor:pointer;line-height:1;padding:0">🔌</button>':'')+
|
||||
'<button onclick="removePrinter(\''+printerNum+'\',\''+nameEsc+'\')" title="'+T.printers_remove+'" style="background:none;border:none;color:var(--txt2);font-size:16px;cursor:pointer;line-height:1;padding:0">✕</button>'+
|
||||
'</span>'+
|
||||
'</div>'+
|
||||
@@ -4016,40 +4000,8 @@ function loadPrinterTab(){
|
||||
(!isActive?'<a href="'+url+'/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
|
||||
'</div>';
|
||||
}).join('');
|
||||
results.forEach(function(res){
|
||||
if(res.printer.has_power_control)_refreshPrinterPowerIcon(res.printer.id,(res.printer.bridge_url||'').replace(/\/+$/,''));
|
||||
});
|
||||
});
|
||||
}).catch(function(e){
|
||||
if(grid)grid.innerHTML='<div style="color:var(--err);font-size:13px;padding:20px">Fehler: '+e+'</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function _refreshPrinterPowerIcon(pid,bridgeUrl){
|
||||
fetch((bridgeUrl||'')+'/kx/printers/'+encodeURIComponent(pid)+'/power-status',{signal:AbortSignal.timeout(5000)})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
var btn=document.getElementById('power-btn-'+pid);
|
||||
if(!btn)return;
|
||||
if(d.state==='on'){btn.style.color='var(--ok)';btn.title=T.printers_power_on||'Power: On';}
|
||||
else if(d.state==='off'){btn.style.color='var(--txt2)';btn.title=T.printers_power_off||'Power: Off';}
|
||||
})
|
||||
.catch(function(){/* status endpoint optional - icon just stays neutral */});
|
||||
}
|
||||
|
||||
function togglePrinterPower(pid){
|
||||
var btn=document.getElementById('power-btn-'+pid);
|
||||
var currentlyOn=btn&&btn.style.color&&btn.style.color.indexOf('var(--ok)')!==-1;
|
||||
// Without a known current state, default to "on" - turning an already-off
|
||||
// switch on is harmless, whereas guessing "off" on a printer mid-print is not.
|
||||
var action=currentlyOn?'off':'on';
|
||||
if(action==='off'&&!confirm(T.printers_power_off_confirm||'Turn printer power off? Make sure no print is running.'))return;
|
||||
if(btn)btn.style.opacity='0.5';
|
||||
post('/kx/printers/'+encodeURIComponent(pid)+'/power',{action:action}).then(function(){
|
||||
if(btn)btn.style.opacity='1';
|
||||
setTimeout(function(){loadPrinterTab();},1500);
|
||||
}).catch(function(e){
|
||||
if(btn)btn.style.opacity='1';
|
||||
clog('Power-Fehler: '+e,'msg-err');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -548,22 +548,6 @@
|
||||
<input type="text" id="s-mode-id" placeholder="20030">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title"><span>🔌</span> <span id="modal-sec-power">Power Switch</span></div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-power-on-url">Power-On URL</label>
|
||||
<input type="text" id="s-power-on-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20on">
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-power-off-url">Power-Off URL</label>
|
||||
<input type="text" id="s-power-off-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20off">
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-power-status-url">Status URL</label>
|
||||
<input type="text" id="s-power-status-url" placeholder="http://192.168.x.x/cm?cmnd=Power">
|
||||
<small id="lbl-power-hint" style="color:var(--txt2)"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drucker -->
|
||||
@@ -603,11 +587,6 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -224,10 +224,6 @@
|
||||
"printers_loading": "Lade…",
|
||||
"printers_none": "Keine Drucker konfiguriert.",
|
||||
"printers_remove": "Drucker entfernen",
|
||||
"printers_power": "Drucker-Stromversorgung schalten",
|
||||
"printers_power_on": "Strom: An",
|
||||
"printers_power_off": "Strom: Aus",
|
||||
"printers_power_off_confirm": "Drucker-Strom ausschalten? Stelle sicher, dass kein Druck läuft.",
|
||||
"printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
|
||||
"printers_switch": "Wechseln →",
|
||||
"progress_action_clear": "Leeren",
|
||||
@@ -261,11 +257,6 @@
|
||||
"settings_language": "Sprache",
|
||||
"settings_mode_id": "Mode-ID",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Steckdose (Ein/Aus)",
|
||||
"settings_power_on_url": "Einschalt-URL",
|
||||
"settings_power_off_url": "Ausschalt-URL",
|
||||
"settings_power_status_url": "Status-URL",
|
||||
"settings_power_hint": "Optional: einfache HTTP-GET-URLs für eine Steckdose (z.B. Tasmota), die den Drucker per Netzstrom schaltet. Leer lassen blendet den Power-Button aus.",
|
||||
"settings_mqtt_port": "MQTT-Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Profile importieren",
|
||||
@@ -293,8 +284,6 @@
|
||||
"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",
|
||||
|
||||
@@ -224,10 +224,6 @@
|
||||
"printers_loading": "Loading…",
|
||||
"printers_none": "No printers configured.",
|
||||
"printers_remove": "Remove printer",
|
||||
"printers_power": "Toggle printer power",
|
||||
"printers_power_on": "Power: On",
|
||||
"printers_power_off": "Power: Off",
|
||||
"printers_power_off_confirm": "Turn printer power off? Make sure no print is running.",
|
||||
"printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
|
||||
"printers_switch": "Switch →",
|
||||
"progress_action_clear": "Clear",
|
||||
@@ -261,11 +257,6 @@
|
||||
"settings_language": "Language",
|
||||
"settings_mode_id": "Mode ID",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Power Switch",
|
||||
"settings_power_on_url": "Power-On URL",
|
||||
"settings_power_off_url": "Power-Off URL",
|
||||
"settings_power_status_url": "Status URL",
|
||||
"settings_power_hint": "Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer's mains power. Leave empty to hide the power button.",
|
||||
"settings_mqtt_port": "MQTT Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Import profiles",
|
||||
@@ -293,8 +284,6 @@
|
||||
"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",
|
||||
|
||||
@@ -224,10 +224,6 @@
|
||||
"printers_loading": "Cargando…",
|
||||
"printers_none": "No hay impresoras configuradas.",
|
||||
"printers_remove": "Eliminar impresora",
|
||||
"printers_power": "Alternar alimentación de la impresora",
|
||||
"printers_power_on": "Alimentación: Encendida",
|
||||
"printers_power_off": "Alimentación: Apagada",
|
||||
"printers_power_off_confirm": "¿Apagar la alimentación de la impresora? Asegúrate de que no haya ninguna impresión en curso.",
|
||||
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
|
||||
"printers_switch": "Cambiar →",
|
||||
"progress_action_clear": "Vaciar",
|
||||
@@ -261,11 +257,6 @@
|
||||
"settings_language": "Idioma",
|
||||
"settings_mode_id": "ID de modo",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Enchufe inteligente",
|
||||
"settings_power_on_url": "URL de encendido",
|
||||
"settings_power_off_url": "URL de apagado",
|
||||
"settings_power_status_url": "URL de estado",
|
||||
"settings_power_hint": "Opcional: URLs HTTP GET para un enchufe inteligente (p.ej. Tasmota) que controla la alimentación de la impresora. Déjalo vacío para ocultar el botón de encendido.",
|
||||
"settings_mqtt_port": "MQTT Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importar perfiles",
|
||||
@@ -293,8 +284,6 @@
|
||||
"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",
|
||||
|
||||
@@ -212,10 +212,6 @@
|
||||
"printers_loading": "Chargement…",
|
||||
"printers_none": "Aucune imprimante configurée.",
|
||||
"printers_remove": "Supprimer l'imprimante",
|
||||
"printers_power": "Basculer l'alimentation de l'imprimante",
|
||||
"printers_power_on": "Alimentation : Allumée",
|
||||
"printers_power_off": "Alimentation : Éteinte",
|
||||
"printers_power_off_confirm": "Éteindre l'alimentation de l'imprimante ? Assurez-vous qu'aucune impression n'est en cours.",
|
||||
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
|
||||
"printers_switch": "Changer →",
|
||||
"progress_action_clear": "Vider",
|
||||
@@ -249,11 +245,6 @@
|
||||
"settings_language": "Langue",
|
||||
"settings_mode_id": "ID du mode",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Prise électrique",
|
||||
"settings_power_on_url": "URL d'allumage",
|
||||
"settings_power_off_url": "URL d'extinction",
|
||||
"settings_power_status_url": "URL de statut",
|
||||
"settings_power_hint": "Optionnel : URL HTTP GET pour une prise connectée (ex. Tasmota) contrôlant l'alimentation secteur de l'imprimante. Laisser vide masque le bouton d'alimentation.",
|
||||
"settings_mqtt_port": "Port MQTT",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importer des profils",
|
||||
@@ -279,8 +270,6 @@
|
||||
"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",
|
||||
|
||||
@@ -212,10 +212,6 @@
|
||||
"printers_loading": "Caricamento in corso…",
|
||||
"printers_none": "Nessuna stampante configurata.",
|
||||
"printers_remove": "Rimuovi stampante",
|
||||
"printers_power": "Attiva/disattiva alimentazione stampante",
|
||||
"printers_power_on": "Alimentazione: Accesa",
|
||||
"printers_power_off": "Alimentazione: Spenta",
|
||||
"printers_power_off_confirm": "Spegnere l'alimentazione della stampante? Assicurati che non sia in corso alcuna stampa.",
|
||||
"printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
|
||||
"printers_switch": "Cambia →",
|
||||
"progress_action_clear": "Cancella",
|
||||
@@ -249,11 +245,6 @@
|
||||
"settings_language": "Lingua",
|
||||
"settings_mode_id": "ID modalità",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Presa elettrica",
|
||||
"settings_power_on_url": "URL accensione",
|
||||
"settings_power_off_url": "URL spegnimento",
|
||||
"settings_power_status_url": "URL stato",
|
||||
"settings_power_hint": "Opzionale: URL HTTP GET per una presa smart (es. Tasmota) che controlla l'alimentazione della stampante. Lasciare vuoto per nascondere il pulsante di accensione.",
|
||||
"settings_mqtt_port": "Porta MQTT",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importa profili",
|
||||
@@ -279,8 +270,6 @@
|
||||
"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",
|
||||
|
||||
@@ -224,10 +224,6 @@
|
||||
"printers_loading": "加载中…",
|
||||
"printers_none": "未配置打印机。",
|
||||
"printers_remove": "移除打印机",
|
||||
"printers_power": "切换打印机电源",
|
||||
"printers_power_on": "电源:开",
|
||||
"printers_power_off": "电源:关",
|
||||
"printers_power_off_confirm": "关闭打印机电源?请确认当前没有正在进行的打印任务。",
|
||||
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
|
||||
"printers_switch": "切换 →",
|
||||
"progress_action_clear": "清除",
|
||||
@@ -261,11 +257,6 @@
|
||||
"settings_language": "语言",
|
||||
"settings_mode_id": "模式 ID",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "电源插座",
|
||||
"settings_power_on_url": "开机 URL",
|
||||
"settings_power_off_url": "关机 URL",
|
||||
"settings_power_status_url": "状态 URL",
|
||||
"settings_power_hint": "可选:智能插座(如 Tasmota)的 HTTP GET 控制 URL,用于控制打印机的电源。留空则隐藏电源按钮。",
|
||||
"settings_mqtt_port": "MQTT 端口",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "导入配置文件",
|
||||
@@ -293,8 +284,6 @@
|
||||
"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": "新",
|
||||
|
||||
Reference in New Issue
Block a user