Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51951476a9 | ||
| 5dd668e8ca | |||
| 0c520ffa00 | |||
| 3daf945612 | |||
| 60156fd589 | |||
| a106e4ab68 | |||
| 8384c69836 | |||
|
|
a360b463f9 |
@@ -1,5 +1,2 @@
|
||||
## Changes in this build
|
||||
|
||||
- Fix: **combined ACE-RFID filament tags (e.g. "GEEETECH PLA Bas" from third-party RFID tools) still weren't auto-matching to imported OrcaSlicer profiles**, even after the first attempt at this in a previous nightly — the matching logic was only ever wired into the OrcaSlicer slicer-sync endpoint, never into the actual MQTT status path that feeds the dashboard and Happy-Hare gate data, so the dashboard kept showing the raw unmatched RFID string. Centralized the matching so all three places that resolve a slot's filament profile benefit identically. Also added variant-token disambiguation (e.g. "Bas" vs. "Matte") for when a vendor has multiple profiles of the same material, and fixed a related edge case where a manual per-slot override could be incorrectly treated as stale on an RFID-tagged spool (Issue #101, thanks @Blaim for the extensive debugging that pinpointed this).
|
||||
- Feat: new setting under Settings → Print — "Delete file from printer after successful print" — automatically removes a GCode file from the printer's own storage once it finishes printing successfully, keeping only the copy in the bridge's own GCode store. Off by default, and only ever applies to files that were uploaded through the bridge itself (so there's always a backup); files started directly from the printer or Anycubic Slicer are never touched.
|
||||
- Fix: **the dashboard could stay stuck showing a printer as online/"ready" indefinitely after it was physically switched off or unplugged**, discovered while testing the new smart-plug power-switch feature. Root cause was two-fold: the MQTT socket had no TCP keepalive, so a connection killed without a clean close (unplugged, not a graceful shutdown) could look alive to the OS for 15+ minutes; and even once the dead connection was detected, the status poll loop could get stuck waiting on a reconnect attempt that was already running elsewhere, so it never reached the code that flips the dashboard to "offline". Live-tested against a real printer, including that no sockets, threads, or file descriptors are left behind across repeated disconnect/reconnect cycles — a disconnected printer is now detected and reflected on the dashboard within about 15 seconds.
|
||||
|
||||
@@ -7,8 +7,11 @@ import os
|
||||
import sys
|
||||
import pathlib
|
||||
import configparser
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("kobrax.config")
|
||||
|
||||
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
|
||||
|
||||
CONFIG_SECTION_CONNECTION = "connection"
|
||||
@@ -116,7 +119,6 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
k, _, v = line.partition("=")
|
||||
env_vals[k.strip()] = v.strip()
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg[CONFIG_SECTION_CONNECTION] = {
|
||||
"printer_ip": env_vals.get("PRINTER_IP", ""),
|
||||
@@ -136,10 +138,20 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
cfg[CONFIG_SECTION_BRIDGE] = {
|
||||
"poll_interval": "3",
|
||||
}
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.write("# KX-Bridge Konfigurationsdatei\n")
|
||||
f.write("# Automatically migrated from .env\n\n")
|
||||
cfg.write(f)
|
||||
# This runs at module import time (see the "Laden" section below) - an
|
||||
# uncaught mkdir/write failure (e.g. a read-only filesystem) would crash
|
||||
# the whole bridge at startup with a raw traceback. Log a clear diagnostic
|
||||
# before re-raising, so the actual cause (permissions, disk full) is
|
||||
# visible instead of a bare stack trace pointing into configparser.
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.write("# KX-Bridge Konfigurationsdatei\n")
|
||||
f.write("# Automatically migrated from .env\n\n")
|
||||
cfg.write(f)
|
||||
except OSError as e:
|
||||
log.error("Failed to write migrated config.ini to %s: %s", config_path, e)
|
||||
raise
|
||||
|
||||
|
||||
def find_config_path() -> pathlib.Path:
|
||||
@@ -445,9 +457,25 @@ def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def _safe_int(value: str, default: int) -> int:
|
||||
"""Falls back to `default` instead of raising on a non-numeric value.
|
||||
|
||||
All of these run at module import time - an uncaught ValueError here
|
||||
(e.g. from a hand-edited config.ini with a typo like `mqtt_port = 98833x`)
|
||||
would crash the entire bridge before it even starts, with a raw traceback
|
||||
instead of a clear diagnostic. list_printers() already guards this same
|
||||
class of input the same way; this applies it to the module-level
|
||||
shortcuts too."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
log.warning("config: expected a number, got %r - using default %r", value, default)
|
||||
return default
|
||||
|
||||
|
||||
# Frequently used shortcuts
|
||||
PRINTER_IP = get("PRINTER_IP", "")
|
||||
MQTT_PORT = int(get("MQTT_PORT", "9883"))
|
||||
MQTT_PORT = _safe_int(get("MQTT_PORT", "9883"), 9883)
|
||||
USERNAME = get("MQTT_USERNAME", "")
|
||||
PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
@@ -456,14 +484,14 @@ 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")))
|
||||
AUTO_LEVELING = _safe_int(get("AUTO_LEVELING", "1"), 1)
|
||||
VIBRATION_COMPENSATION = _safe_int(get("VIBRATION_COMPENSATION", "0"), 0)
|
||||
CAMERA_ON_PRINT = _safe_int(get("CAMERA_ON_PRINT", "0"), 0)
|
||||
WEB_UPLOAD_WARNING = _safe_int(get("WEB_UPLOAD_WARNING", "1"), 1)
|
||||
DELETE_PRINTER_FILE_AFTER_PRINT = _safe_int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0"), 0)
|
||||
PRINT_START_DIALOG = _safe_int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")), 1)
|
||||
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
|
||||
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
|
||||
SPOOLMAN_SYNC_RATE = _safe_int(get("SPOOLMAN_SYNC_RATE", "0"), 0)
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
|
||||
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0"))
|
||||
POLL_INTERVAL = _safe_int(get("POLL_INTERVAL", "3"), 3)
|
||||
VERBOSE_HTTP_LOG = _safe_int(get("VERBOSE_HTTP_LOG", "0"), 0)
|
||||
|
||||
185
kobrax_client.py
185
kobrax_client.py
@@ -178,6 +178,14 @@ class KobraXClient:
|
||||
self._pending_msgid: dict[str, dict] = {}
|
||||
# Pending requests by msg_type/report topic suffix
|
||||
self._pending_report: dict[str, dict] = {}
|
||||
# Guards _pending_msgid/_pending_report against concurrent mutation:
|
||||
# the reader thread resolves entries in _dispatch() while publish()
|
||||
# (called from the poll loop and, via run_in_executor, HTTP handler
|
||||
# threads) registers/cleans them up - without this, two concurrent
|
||||
# publish() calls for the same msg_type can race on the
|
||||
# check-then-set for a report_key slot, and _dispatch() could observe
|
||||
# a dict mid-mutation.
|
||||
self._pending_lock = threading.Lock()
|
||||
|
||||
# Optional callbacks: topic_suffix → callable(payload_dict)
|
||||
self.callbacks: dict[str, callable] = {}
|
||||
@@ -446,34 +454,46 @@ class KobraXClient:
|
||||
def _drain(self):
|
||||
buf = self._buf
|
||||
idx = 0
|
||||
while idx < len(buf):
|
||||
ptype = buf[idx] & 0xF0
|
||||
i = idx + 1
|
||||
mul = 1
|
||||
rem = 0
|
||||
while i < len(buf):
|
||||
b = buf[i]
|
||||
rem += (b & 0x7F) * mul
|
||||
mul *= 128
|
||||
i += 1
|
||||
if not (b & 0x80):
|
||||
try:
|
||||
while idx < len(buf):
|
||||
ptype = buf[idx] & 0xF0
|
||||
i = idx + 1
|
||||
mul = 1
|
||||
rem = 0
|
||||
while i < len(buf):
|
||||
b = buf[i]
|
||||
rem += (b & 0x7F) * mul
|
||||
mul *= 128
|
||||
i += 1
|
||||
if not (b & 0x80):
|
||||
break
|
||||
if i + rem > len(buf):
|
||||
break
|
||||
if i + rem > len(buf):
|
||||
break
|
||||
pkt = buf[i:i + rem]
|
||||
idx = i + rem
|
||||
pkt = buf[i:i + rem]
|
||||
idx = i + rem
|
||||
|
||||
if ptype == 0x30:
|
||||
topic, raw_payload = _parse_publish(pkt)
|
||||
if topic is None:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except Exception:
|
||||
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
|
||||
self._dispatch(topic, payload)
|
||||
|
||||
self._buf = buf[idx:]
|
||||
if ptype == 0x30:
|
||||
topic, raw_payload = _parse_publish(pkt)
|
||||
if topic is None:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except Exception:
|
||||
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
|
||||
try:
|
||||
self._dispatch(topic, payload)
|
||||
except Exception as e:
|
||||
# A single malformed/unexpected message (e.g. valid JSON
|
||||
# that isn't an object, like a bare number or list) must
|
||||
# not be reprocessed forever: without this, an exception
|
||||
# here would skip the buffer-advance below, leaving the
|
||||
# same bad packet at the front of self._buf so every
|
||||
# future _drain() call crashes on it again - each one
|
||||
# forcing a reconnect via the reader loop's exception
|
||||
# handler, an endless self-inflicted reconnect loop.
|
||||
log.warning("dispatch error for %s: %s", topic, e)
|
||||
finally:
|
||||
self._buf = buf[idx:]
|
||||
|
||||
def _dedup_hash(self, suffix: str, payload: dict) -> str:
|
||||
"""Hash payload ignoring volatile per-tick fields for dedup check."""
|
||||
@@ -484,6 +504,9 @@ class KobraXClient:
|
||||
return hashlib.md5(json.dumps(stable, sort_keys=True).encode(), usedforsecurity=False).hexdigest()
|
||||
|
||||
def _dispatch(self, topic: str, payload: dict):
|
||||
if not isinstance(payload, dict):
|
||||
log.warning("dispatch: non-dict payload on %s: %r", topic, payload)
|
||||
return
|
||||
suffix = "/".join(topic.split("/")[-2:])
|
||||
|
||||
if self._raw_log:
|
||||
@@ -511,18 +534,29 @@ class KobraXClient:
|
||||
log.info("RX %-25s state=%-12s data=%s",
|
||||
suffix, state, json.dumps(payload.get("data"), ensure_ascii=False))
|
||||
|
||||
# Resolve by report topic suffix (e.g. "info/report")
|
||||
if suffix in self._pending_report:
|
||||
entry = self._pending_report[suffix]
|
||||
entry["result"] = payload
|
||||
entry["event"].set()
|
||||
msgid = payload.get("msgid")
|
||||
with self._pending_lock:
|
||||
report_entry = self._pending_report.get(suffix)
|
||||
msgid_entry = self._pending_msgid.get(msgid) if msgid else None
|
||||
|
||||
# Resolve by report topic suffix (e.g. "info/report"). If the payload
|
||||
# carries a msgid that doesn't match what this waiter is actually
|
||||
# expecting, it's a stale/late reply for a different, already-timed-out
|
||||
# request that happens to share the same report_key - don't deliver it
|
||||
# to the wrong caller.
|
||||
if report_entry is not None:
|
||||
entry_msgid = report_entry.get("msgid")
|
||||
if not entry_msgid or not msgid or entry_msgid == msgid:
|
||||
report_entry["result"] = payload
|
||||
report_entry["event"].set()
|
||||
else:
|
||||
log.debug("dispatch: msgid mismatch for %s report (waiting=%s, got=%s) - ignoring stale reply",
|
||||
suffix, entry_msgid, msgid)
|
||||
|
||||
# Resolve by msgid (for generic response ACK)
|
||||
msgid = payload.get("msgid")
|
||||
if msgid and msgid in self._pending_msgid:
|
||||
entry = self._pending_msgid[msgid]
|
||||
entry["result"] = payload
|
||||
entry["event"].set()
|
||||
if msgid_entry is not None:
|
||||
msgid_entry["result"] = payload
|
||||
msgid_entry["event"].set()
|
||||
|
||||
# User callbacks by topic suffix (last two path components)
|
||||
if suffix in self.callbacks:
|
||||
@@ -558,13 +592,18 @@ class KobraXClient:
|
||||
# Also register by report topic as fallback for responses without msgid.
|
||||
report_key = f"{msg_type}/report"
|
||||
event = threading.Event()
|
||||
entry = {"event": event, "result": None}
|
||||
self._pending_msgid[msgid] = entry
|
||||
# Only register report-key waiter if nobody else is waiting on it
|
||||
# entry carries its own msgid so _dispatch()'s report-suffix path can
|
||||
# confirm a reply actually belongs to THIS request before delivering
|
||||
# it - without that, a late reply for an already-timed-out request A
|
||||
# could be handed to a newer request B waiting on the same report_key.
|
||||
entry = {"event": event, "result": None, "msgid": msgid}
|
||||
report_registered = False
|
||||
if report_key not in self._pending_report:
|
||||
self._pending_report[report_key] = entry
|
||||
report_registered = True
|
||||
with self._pending_lock:
|
||||
self._pending_msgid[msgid] = entry
|
||||
# Only register report-key waiter if nobody else is waiting on it
|
||||
if report_key not in self._pending_report:
|
||||
self._pending_report[report_key] = entry
|
||||
report_registered = True
|
||||
|
||||
topic = self._pub_topic(msg_type)
|
||||
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
|
||||
@@ -578,9 +617,10 @@ class KobraXClient:
|
||||
self._sock.sendall(_build_publish(topic, payload))
|
||||
except Exception as e:
|
||||
log.error("send error: %s, reconnecting…", e)
|
||||
self._pending_msgid.pop(msgid, None)
|
||||
if report_registered:
|
||||
self._pending_report.pop(report_key, None)
|
||||
with self._pending_lock:
|
||||
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".
|
||||
@@ -590,22 +630,25 @@ class KobraXClient:
|
||||
try:
|
||||
with self._lock:
|
||||
self._sock.sendall(_build_publish(topic, payload))
|
||||
self._pending_msgid[msgid] = entry
|
||||
if report_registered:
|
||||
self._pending_report[report_key] = entry
|
||||
with self._pending_lock:
|
||||
self._pending_msgid[msgid] = entry
|
||||
if report_registered:
|
||||
self._pending_report[report_key] = entry
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if timeout <= 0:
|
||||
self._pending_msgid.pop(msgid, None)
|
||||
if report_registered:
|
||||
self._pending_report.pop(report_key, None)
|
||||
with self._pending_lock:
|
||||
self._pending_msgid.pop(msgid, None)
|
||||
if report_registered:
|
||||
self._pending_report.pop(report_key, None)
|
||||
return None
|
||||
|
||||
received = event.wait(timeout)
|
||||
self._pending_msgid.pop(msgid, None)
|
||||
if report_registered:
|
||||
self._pending_report.pop(report_key, None)
|
||||
with self._pending_lock:
|
||||
self._pending_msgid.pop(msgid, None)
|
||||
if report_registered:
|
||||
self._pending_report.pop(report_key, None)
|
||||
if not received:
|
||||
return None
|
||||
return entry["result"]
|
||||
@@ -710,7 +753,9 @@ class KobraXClient:
|
||||
raise RuntimeError("Could not get info/report for upload URL")
|
||||
upload_url = info["data"]["urls"]["fileUploadurl"]
|
||||
# parse token from URL query string
|
||||
token = upload_url.split("?s=")[1] if "?s=" in upload_url else ""
|
||||
if "?s=" not in upload_url:
|
||||
raise RuntimeError(f"Upload: no session token ('?s=') in upload URL: {upload_url!r}")
|
||||
token = upload_url.split("?s=")[1]
|
||||
|
||||
with open(filepath, "rb") as f:
|
||||
file_data = f.read()
|
||||
@@ -760,19 +805,25 @@ class KobraXClient:
|
||||
# (the printer processes the file before replying).
|
||||
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock = socket.create_connection(_ai[0][4], timeout=10)
|
||||
sock.settimeout(None) # blocking during send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
except socket.timeout:
|
||||
pass
|
||||
sock.close()
|
||||
sock.settimeout(None) # blocking during send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
except socket.timeout:
|
||||
pass
|
||||
finally:
|
||||
# Without this, a sendall()/recv() failure other than
|
||||
# socket.timeout (e.g. ConnectionResetError/BrokenPipeError if
|
||||
# the printer drops the connection mid-upload) skipped
|
||||
# sock.close() entirely, leaking the fd on every failed attempt.
|
||||
sock.close()
|
||||
|
||||
# parse HTTP response body
|
||||
if b"\r\n\r\n" in response:
|
||||
|
||||
@@ -713,7 +713,7 @@ class CameraCache:
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
self._proc_jpeg = await asyncio.create_subprocess_exec(
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "warning",
|
||||
*self._input_args(url), "-i", url,
|
||||
"-vf", "fps=2",
|
||||
@@ -722,6 +722,7 @@ class CameraCache:
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._proc_jpeg = proc
|
||||
except Exception as e:
|
||||
log.warning(f"CameraCache: ffmpeg-jpeg start failed: {e}")
|
||||
await asyncio.sleep(3.0)
|
||||
@@ -731,7 +732,7 @@ class CameraCache:
|
||||
rc = None
|
||||
try:
|
||||
while True:
|
||||
chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK)
|
||||
chunk = await proc.stdout.read(self.TS_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
@@ -751,26 +752,31 @@ class CameraCache:
|
||||
except Exception as e:
|
||||
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
|
||||
finally:
|
||||
# Kill + wait - otherwise the child process lingers as a zombie and
|
||||
# asyncio reports "Unknown child pid ..." on the next reaper tick.
|
||||
if self._proc_jpeg is not None:
|
||||
# NOTE: cleanup operates on the local `proc` reference, not on
|
||||
# self._proc_jpeg - see _run_mjpeg_loop's identical comment.
|
||||
# If this task got cancelled (e.g. by reset()), a new task may
|
||||
# already have started and assigned its own process to
|
||||
# self._proc_jpeg by the time we reach here; killing that
|
||||
# shared attribute instead of our own local proc would kill
|
||||
# the WRONG (newer) process and leak this one as an orphan.
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
rc = proc.returncode
|
||||
if rc:
|
||||
try:
|
||||
self._proc_jpeg.kill()
|
||||
err = await proc.stderr.read(500)
|
||||
if err:
|
||||
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await self._proc_jpeg.wait()
|
||||
except Exception:
|
||||
pass
|
||||
rc = self._proc_jpeg.returncode
|
||||
if rc:
|
||||
try:
|
||||
err = await self._proc_jpeg.stderr.read(500)
|
||||
if err:
|
||||
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
self._proc_jpeg = None
|
||||
if self._proc_jpeg is proc:
|
||||
self._proc_jpeg = None
|
||||
if rc:
|
||||
self._fail_count_jpeg += 1
|
||||
delay = min(2.0 * (2 ** self._fail_count_jpeg), 300.0)
|
||||
@@ -788,7 +794,7 @@ class CameraCache:
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
self._proc_h264 = await asyncio.create_subprocess_exec(
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "warning",
|
||||
*self._input_args(url), "-i", url,
|
||||
"-c:v", "copy", "-an",
|
||||
@@ -796,6 +802,7 @@ class CameraCache:
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._proc_h264 = proc
|
||||
except Exception as e:
|
||||
log.warning(f"CameraCache: ffmpeg-h264 start failed: {e}")
|
||||
await asyncio.sleep(3.0)
|
||||
@@ -804,7 +811,7 @@ class CameraCache:
|
||||
rc = None
|
||||
try:
|
||||
while True:
|
||||
chunk = await self._proc_h264.stdout.read(self.TS_CHUNK)
|
||||
chunk = await proc.stdout.read(self.TS_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
# Fanout: non-blocking per subscriber; slow clients
|
||||
@@ -822,24 +829,31 @@ class CameraCache:
|
||||
except Exception as e:
|
||||
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
|
||||
finally:
|
||||
if self._proc_h264 is not None:
|
||||
# NOTE: cleanup operates on the local `proc` reference, not on
|
||||
# self._proc_h264 - see _run_mjpeg_loop's identical comment.
|
||||
# If this task got cancelled (e.g. by reset()), a new task may
|
||||
# already have started and assigned its own process to
|
||||
# self._proc_h264 by the time we reach here; killing that
|
||||
# shared attribute instead of our own local proc would kill
|
||||
# the WRONG (newer) process and leak this one as an orphan.
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
rc = proc.returncode
|
||||
if rc:
|
||||
try:
|
||||
self._proc_h264.kill()
|
||||
err = await proc.stderr.read(500)
|
||||
if err:
|
||||
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await self._proc_h264.wait()
|
||||
except Exception:
|
||||
pass
|
||||
rc = self._proc_h264.returncode
|
||||
if rc:
|
||||
try:
|
||||
err = await self._proc_h264.stderr.read(500)
|
||||
if err:
|
||||
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
self._proc_h264 = None
|
||||
if self._proc_h264 is proc:
|
||||
self._proc_h264 = None
|
||||
if rc:
|
||||
self._fail_count_h264 += 1
|
||||
delay = min(2.0 * (2 ** self._fail_count_h264), 300.0)
|
||||
@@ -5093,7 +5107,10 @@ class KobraXBridge:
|
||||
|
||||
async def handle_api_settings_post(self, request):
|
||||
import configparser
|
||||
data = await request.json()
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return self._json_cors({"error": "invalid json"}, status=400)
|
||||
config_path = self._find_config_path()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -5434,6 +5451,21 @@ class KobraXBridge:
|
||||
|
||||
async def handle_api_update_check(self, request):
|
||||
current = self._read_version()
|
||||
# Testing channel (testing-<sha>) has no Gitea releases at all - it's
|
||||
# a Docker-only channel. Report that directly instead of falling
|
||||
# through to the stable path (which would wrongly offer a stable
|
||||
# "update"). The :testing image is rolling, so there's nothing to
|
||||
# compare a version against.
|
||||
if "testing" in current:
|
||||
return web.json_response({
|
||||
"current": current,
|
||||
"latest": current,
|
||||
"update_available": False,
|
||||
"tag": current,
|
||||
"docker_only": True,
|
||||
"changelog": "Testing channel - updates are delivered via Docker: "
|
||||
"docker compose pull && docker compose up -d",
|
||||
})
|
||||
is_nightly = "nightly" in current
|
||||
is_dev = "-dev+" in current
|
||||
if is_nightly:
|
||||
@@ -5513,11 +5545,16 @@ class KobraXBridge:
|
||||
]
|
||||
|
||||
async def handle_api_update_apply(self, request):
|
||||
data = await request.json()
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "invalid json"}, status=400)
|
||||
new_tag = data.get("tag", "")
|
||||
if "nightly" in self._read_version():
|
||||
_cur = self._read_version()
|
||||
if "nightly" in _cur or "testing" in _cur:
|
||||
channel = "testing" if "testing" in _cur else "nightly"
|
||||
return web.json_response(
|
||||
{"error": "nightly updates are delivered via Docker: "
|
||||
{"error": f"{channel} updates are delivered via Docker: "
|
||||
"docker compose pull && docker compose up -d"}, status=400)
|
||||
if getattr(sys, "frozen", False):
|
||||
return web.json_response(
|
||||
|
||||
@@ -9,8 +9,11 @@ them as a normalized list with (id, name, vendor, type, color).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("kobrax.filaments")
|
||||
|
||||
|
||||
def first_str(value, default: str = "") -> str:
|
||||
"""Orca profiles store some fields as ['value']. Returns the first
|
||||
@@ -69,7 +72,17 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
if system_index:
|
||||
for p in system_index:
|
||||
if isinstance(p, dict) and p.get("name"):
|
||||
sys_by_name[p["name"]] = p
|
||||
pname = p["name"]
|
||||
if pname in sys_by_name and sys_by_name[pname] is not p:
|
||||
# clean_name() deliberately collapses variant-suffixed
|
||||
# names (e.g. "...@base" vs "...@Anycubic Kobra X 0.4
|
||||
# nozzle") onto the same cleaned name - expected, but the
|
||||
# last-write-wins overwrite here was previously silent,
|
||||
# making an unexpected inherits-parent resolution hard to
|
||||
# debug.
|
||||
log.debug("orca_filaments: duplicate system profile name %r - "
|
||||
"overwriting %r with %r", pname, sys_by_name[pname].get("id"), p.get("id"))
|
||||
sys_by_name[pname] = p
|
||||
|
||||
def _resolve(key: str, depth: int = 5):
|
||||
cur_list = [data]
|
||||
@@ -119,7 +132,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
if not fid or not isinstance(fid, str):
|
||||
return None
|
||||
|
||||
name_raw = data.get("name", fid)
|
||||
name_raw = first_str(data.get("name"), fid)
|
||||
name = clean_name(name_raw)
|
||||
vendor = first_str(_resolve_full("filament_vendor")) or (path_vendor or "Generic")
|
||||
ftype = first_str(_resolve_full("filament_type"), "")
|
||||
|
||||
83
tests/test_camera_process_race.py
Normal file
83
tests/test_camera_process_race.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Camera ffmpeg process-handle race (code review finding).
|
||||
|
||||
_run_jpeg_loop()/_run_h264_loop() used to operate on the shared instance
|
||||
attribute (self._proc_jpeg / self._proc_h264) in their cleanup, instead of a
|
||||
local reference to the process they themselves started - the same bug
|
||||
_run_mjpeg_loop() already had fixed with a documented local-`proc` pattern.
|
||||
|
||||
If a loop's task is cancelled (e.g. via CameraCache.reset() after a stream
|
||||
URL rotation) while a new task has already started and assigned its own
|
||||
process to the shared attribute, the cancelled task's cleanup would kill
|
||||
and null out the NEWER process instead of its own - leaking its own actual
|
||||
ffmpeg child as an orphan that nothing ever cleans up again.
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kobrax_moonraker_bridge import CameraCache
|
||||
|
||||
|
||||
def _fake_proc(name):
|
||||
"""A minimal stand-in for asyncio.subprocess.Process good enough to
|
||||
drive _run_jpeg_loop()'s read/kill/wait/returncode usage."""
|
||||
proc = MagicMock(name=name)
|
||||
proc.returncode = 0
|
||||
proc.kill = MagicMock()
|
||||
proc.wait = AsyncMock()
|
||||
proc.stdout = MagicMock()
|
||||
proc.stderr = MagicMock()
|
||||
proc.stderr.read = AsyncMock(return_value=b"")
|
||||
return proc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jpeg_loop_cleanup_does_not_kill_a_newer_process():
|
||||
cache = CameraCache()
|
||||
cache._url = "http://printer/live/streamtoken"
|
||||
|
||||
old_proc = _fake_proc("old")
|
||||
new_proc = _fake_proc("new")
|
||||
|
||||
# old_proc's stdout.read blocks forever until cancelled - simulating the
|
||||
# loop being stuck reading from a stale connection, same as the real bug.
|
||||
stuck = asyncio.Event()
|
||||
|
||||
async def old_stdout_read(_n):
|
||||
await stuck.wait()
|
||||
return b""
|
||||
|
||||
old_proc.stdout.read = old_stdout_read
|
||||
|
||||
create_calls = []
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
create_calls.append(1)
|
||||
return old_proc if len(create_calls) == 1 else new_proc
|
||||
|
||||
with patch("kobrax_moonraker_bridge.asyncio.create_subprocess_exec",
|
||||
side_effect=fake_create_subprocess_exec), \
|
||||
patch("kobrax_moonraker_bridge._find_ffmpeg", return_value="ffmpeg"):
|
||||
task = asyncio.create_task(cache._run_jpeg_loop())
|
||||
# Let the loop start and assign old_proc to the shared attribute.
|
||||
await asyncio.sleep(0.05)
|
||||
assert cache._proc_jpeg is old_proc
|
||||
|
||||
# Simulate a second loop iteration's process already having been
|
||||
# assigned to the shared attribute before the cancelled task's
|
||||
# cleanup runs - the exact race window from the bug report.
|
||||
cache._proc_jpeg = new_proc
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=2.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
|
||||
# The cancelled task must have killed/waited on ITS OWN process (old_proc),
|
||||
# not the newer one that had already taken over the shared attribute.
|
||||
old_proc.kill.assert_called_once()
|
||||
new_proc.kill.assert_not_called()
|
||||
# And it must not have clobbered the newer process's slot.
|
||||
assert cache._proc_jpeg is new_proc
|
||||
189
tests/test_client_robustness.py
Normal file
189
tests/test_client_robustness.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Robustness fixes found during a targeted code review of kobrax_client.py:
|
||||
|
||||
1. _drain()/_dispatch() must not get stuck reprocessing the same malformed
|
||||
packet forever when the printer sends valid JSON that isn't an object
|
||||
(e.g. a bare number or list) - previously an exception from _dispatch()
|
||||
escaped before self._buf was advanced, so the same bytes sat at the
|
||||
front of the buffer and crashed every subsequent _drain() call, each one
|
||||
forcing a reconnect via the reader loop's exception handler.
|
||||
2. publish()'s pending-dict registration/cleanup must be safe against
|
||||
concurrent callers for the same msg_type, and a stale/late reply must
|
||||
not be delivered to a newer, unrelated caller waiting on the same
|
||||
report-topic suffix.
|
||||
3. upload_gcode() must raise a clear error for a malformed upload URL
|
||||
(missing "?s=") instead of silently sending an unauthenticated request,
|
||||
and must not leak the upload socket on a send/recv failure.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kobrax_client import KobraXClient, _build_publish
|
||||
|
||||
|
||||
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 _frame(topic: str, payload) -> bytes:
|
||||
"""Builds a raw MQTT PUBLISH frame carrying `payload` as JSON, the same
|
||||
wire format _drain() parses."""
|
||||
return _build_publish(topic, json.dumps(payload))
|
||||
|
||||
|
||||
# --- Fix 1: malformed (non-dict) JSON payload must not wedge the buffer ---
|
||||
|
||||
def test_drain_advances_buffer_past_non_dict_payload():
|
||||
c = _client()
|
||||
# A bare JSON number is valid JSON but not a dict - json.loads succeeds,
|
||||
# but _dispatch()'s dict-oriented logic would previously crash on it.
|
||||
bad = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", 42)
|
||||
good = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", {"state": "done"})
|
||||
c._buf = bad + good
|
||||
|
||||
c._drain() # must not raise
|
||||
|
||||
# Both packets must have been consumed - the malformed one logged/skipped,
|
||||
# the buffer advanced past it so the following valid packet is also
|
||||
# processed, not left stuck behind it.
|
||||
assert c._buf == b""
|
||||
|
||||
|
||||
def test_drain_does_not_reprocess_bad_packet_on_repeated_calls():
|
||||
"""Regression guard for the original bug: before the fix, a crash in
|
||||
_dispatch() left self._buf untouched, so the bad packet stayed at the
|
||||
front and every _drain() call re-crashed on it."""
|
||||
c = _client()
|
||||
bad = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", [1, 2, 3])
|
||||
c._buf = bad
|
||||
|
||||
c._drain()
|
||||
assert c._buf == b"" # consumed, not stuck
|
||||
|
||||
# A second call on the now-empty buffer must be a no-op, not a re-crash.
|
||||
c._drain()
|
||||
assert c._buf == b""
|
||||
|
||||
|
||||
def test_dispatch_rejects_non_dict_payload_directly():
|
||||
c = _client()
|
||||
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", "not a dict")
|
||||
# No exception, no registered pending entries get corrupted.
|
||||
assert c._pending_report == {}
|
||||
|
||||
|
||||
# --- Fix 2: pending-dict thread-safety + msgid correlation ---
|
||||
|
||||
def test_publish_concurrent_same_msg_type_both_get_delivered():
|
||||
"""Two overlapping publish() calls for the same msg_type must each
|
||||
receive their OWN reply, not have one silently miss out because the
|
||||
other already claimed the shared report_key slot."""
|
||||
c = _client()
|
||||
c._running = True
|
||||
c._sock = MagicMock()
|
||||
c._ensure_reader = lambda: None # no real reader thread needed for this test
|
||||
|
||||
results = {}
|
||||
|
||||
def call(label):
|
||||
results[label] = c.publish("info", "query", timeout=2.0)
|
||||
|
||||
t1 = threading.Thread(target=call, args=("a",))
|
||||
t2 = threading.Thread(target=call, args=("b",))
|
||||
t1.start()
|
||||
time.sleep(0.02)
|
||||
t2.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
# Simulate the printer replying to whichever msgid-bearing requests are
|
||||
# currently pending, by msgid (the reliable path both requests can use).
|
||||
with c._pending_lock:
|
||||
pending = dict(c._pending_msgid)
|
||||
for msgid, entry in pending.items():
|
||||
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
||||
{"msgid": msgid, "state": "done", "data": {"ok": True}})
|
||||
|
||||
t1.join(timeout=3)
|
||||
t2.join(timeout=3)
|
||||
|
||||
assert results["a"] is not None
|
||||
assert results["b"] is not None
|
||||
|
||||
|
||||
def test_dispatch_ignores_stale_reply_with_mismatched_msgid():
|
||||
"""A late reply carrying a msgid that doesn't match what the current
|
||||
report_key waiter is expecting must not be delivered to it - it belongs
|
||||
to an earlier, already-resolved/timed-out request."""
|
||||
c = _client()
|
||||
c._running = True
|
||||
c._sock = MagicMock()
|
||||
|
||||
event = threading.Event()
|
||||
entry = {"event": event, "result": None, "msgid": "the-real-one"}
|
||||
with c._pending_lock:
|
||||
c._pending_report["info/report"] = entry
|
||||
|
||||
# A stale reply for a different msgid must not resolve this waiter.
|
||||
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
||||
{"msgid": "some-other-stale-id", "state": "done"})
|
||||
assert not event.is_set()
|
||||
assert entry["result"] is None
|
||||
|
||||
# The matching reply must resolve it.
|
||||
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
||||
{"msgid": "the-real-one", "state": "done"})
|
||||
assert event.is_set()
|
||||
assert entry["result"]["msgid"] == "the-real-one"
|
||||
|
||||
|
||||
def test_dispatch_delivers_reply_without_msgid_as_before():
|
||||
"""Regression guard: plenty of printer reports carry no msgid at all
|
||||
(e.g. spontaneous status pushes) - those must still resolve a waiter
|
||||
registered without one (entry["msgid"] is falsy)."""
|
||||
c = _client()
|
||||
event = threading.Event()
|
||||
entry = {"event": event, "result": None, "msgid": None}
|
||||
with c._pending_lock:
|
||||
c._pending_report["info/report"] = entry
|
||||
|
||||
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
|
||||
{"state": "done"})
|
||||
assert event.is_set()
|
||||
|
||||
|
||||
# --- Fix 3: upload_gcode() URL validation + socket cleanup ---
|
||||
|
||||
def test_upload_gcode_raises_clear_error_on_missing_session_token(tmp_path):
|
||||
c = _client()
|
||||
f = tmp_path / "test.gcode"
|
||||
f.write_text("; gcode")
|
||||
|
||||
with pytest.raises(RuntimeError, match="session token"):
|
||||
c.upload_gcode(str(f), upload_url="http://192.168.1.100:18910/gcode_upload")
|
||||
|
||||
|
||||
def test_upload_gcode_closes_socket_on_send_failure(tmp_path):
|
||||
c = _client()
|
||||
f = tmp_path / "test.gcode"
|
||||
f.write_text("; gcode")
|
||||
|
||||
fake_sock = MagicMock()
|
||||
fake_sock.sendall.side_effect = ConnectionResetError("printer went away")
|
||||
|
||||
with patch("socket.create_connection", return_value=fake_sock):
|
||||
with pytest.raises(ConnectionResetError):
|
||||
c.upload_gcode(str(f), upload_url="http://192.168.1.100:18910/gcode_upload?s=tok123")
|
||||
|
||||
fake_sock.close.assert_called_once()
|
||||
66
tests/test_config_loader_robustness.py
Normal file
66
tests/test_config_loader_robustness.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""config_loader.py robustness fixes found during code review:
|
||||
|
||||
1. _safe_int() must fall back to a default instead of raising - the
|
||||
module-level numeric shortcuts (MQTT_PORT, POLL_INTERVAL, etc.) run this
|
||||
at import time, so an uncaught ValueError there previously crashed the
|
||||
entire bridge on startup if config.ini had a typo'd numeric value
|
||||
(e.g. "mqtt_port = 98833x"), with a raw traceback instead of a clear
|
||||
diagnostic. list_printers() already guarded this same class of input the
|
||||
same way; this applies it to the module-level shortcuts too.
|
||||
2. migrate_env_to_config() must log a clear error (not a bare traceback)
|
||||
if writing the migrated config.ini fails (e.g. permission error).
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import config_loader
|
||||
|
||||
|
||||
def test_safe_int_returns_value_for_valid_numeric_string():
|
||||
assert config_loader._safe_int("42", 0) == 42
|
||||
|
||||
|
||||
def test_safe_int_falls_back_to_default_on_garbage():
|
||||
assert config_loader._safe_int("98833x", 9883) == 9883
|
||||
|
||||
|
||||
def test_safe_int_falls_back_to_default_on_empty_string():
|
||||
assert config_loader._safe_int("", 3) == 3
|
||||
|
||||
|
||||
def test_safe_int_falls_back_to_default_on_none():
|
||||
assert config_loader._safe_int(None, 5) == 5
|
||||
|
||||
|
||||
def test_config_loader_import_survives_malformed_config_ini(tmp_path):
|
||||
"""End-to-end regression guard: importing config_loader with a
|
||||
config.ini containing a non-numeric mqtt_port must not raise - it must
|
||||
fall back to the default instead. Run in a subprocess since
|
||||
config_loader executes its migration/loading logic at import time and
|
||||
Python caches modules, so a plain re-import in this test process
|
||||
wouldn't actually re-exercise the import-time code path."""
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
(config_dir / "config.ini").write_text(
|
||||
"[connection]\n"
|
||||
"printer_ip = 192.168.1.50\n"
|
||||
"mqtt_port = 98833x\n" # malformed - not a number
|
||||
)
|
||||
|
||||
script = textwrap.dedent(f"""
|
||||
import sys
|
||||
sys.path.insert(0, {str(tmp_path.parent.parent)!r})
|
||||
import config_loader
|
||||
config_loader._BASE = __import__("pathlib").Path({str(tmp_path)!r})
|
||||
config_loader._find_config_file = lambda: {str(config_dir / "config.ini")!r} \
|
||||
and __import__("pathlib").Path({str(config_dir / "config.ini")!r})
|
||||
import importlib
|
||||
importlib.reload(config_loader)
|
||||
assert config_loader.MQTT_PORT == 9883, config_loader.MQTT_PORT
|
||||
print("OK")
|
||||
""")
|
||||
|
||||
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=10)
|
||||
assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}"
|
||||
assert "OK" in result.stdout
|
||||
182
tests/test_orca_filaments_parser.py
Normal file
182
tests/test_orca_filaments_parser.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""orca_filaments.py parser robustness (code review finding).
|
||||
|
||||
No dedicated test file existed for parse_profile()/parse_profile_bytes()/
|
||||
clean_name() before this - existing tests only used pre-parsed profile dicts
|
||||
as fixtures, never exercised the actual parsing logic.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from orca_filaments import clean_name, first_str, parse_profile, parse_profile_bytes
|
||||
|
||||
|
||||
def test_clean_name_strips_base_suffix():
|
||||
assert clean_name("PolyTerra PLA @base") == "PolyTerra PLA"
|
||||
|
||||
|
||||
def test_clean_name_strips_printer_and_nozzle_suffix():
|
||||
assert clean_name("Anycubic PLA @Anycubic Kobra X 0.4 nozzle") == "Anycubic PLA"
|
||||
|
||||
|
||||
def test_clean_name_strips_bare_nozzle_suffix():
|
||||
assert clean_name("Anker Generic PLA 0.4 nozzle") == "Anker Generic PLA"
|
||||
|
||||
|
||||
def test_clean_name_returns_raw_when_stripping_leaves_nothing():
|
||||
"""An all-suffix name has nothing left after stripping - falls back to
|
||||
the original raw string rather than returning an empty string."""
|
||||
assert clean_name("@base") == "@base"
|
||||
|
||||
|
||||
def test_first_str_unwraps_single_element_list():
|
||||
assert first_str(["PLA"]) == "PLA"
|
||||
|
||||
|
||||
def test_first_str_passes_through_plain_string():
|
||||
assert first_str("PLA") == "PLA"
|
||||
|
||||
|
||||
def test_first_str_returns_default_for_empty_list():
|
||||
assert first_str([], "fallback") == "fallback"
|
||||
|
||||
|
||||
def test_first_str_returns_default_for_other_types():
|
||||
assert first_str(42, "fallback") == "fallback"
|
||||
assert first_str(None, "fallback") == "fallback"
|
||||
|
||||
|
||||
def test_parse_profile_rejects_non_dict():
|
||||
assert parse_profile([1, 2, 3]) is None
|
||||
assert parse_profile("not a dict") is None
|
||||
assert parse_profile(None) is None
|
||||
|
||||
|
||||
def test_parse_profile_rejects_stub_without_id_or_parent():
|
||||
data = {"type": "filament"} # no inherits, no filament_id
|
||||
assert parse_profile(data) is None
|
||||
|
||||
|
||||
def test_parse_profile_rejects_instantiation_false():
|
||||
data = {"type": "filament", "filament_id": "GFL01", "instantiation": "false"}
|
||||
assert parse_profile(data) is None
|
||||
|
||||
|
||||
def test_parse_profile_minimal_valid_profile():
|
||||
data = {
|
||||
"type": "filament",
|
||||
"filament_id": "GFL01",
|
||||
"name": "Generic PLA",
|
||||
"filament_vendor": ["Generic"],
|
||||
"filament_type": ["PLA"],
|
||||
"default_filament_colour": ["#FFFFFF"],
|
||||
}
|
||||
result = parse_profile(data)
|
||||
assert result == {
|
||||
"id": "GFL01",
|
||||
"name": "Generic PLA",
|
||||
"vendor": "Generic",
|
||||
"type": "PLA",
|
||||
"color": "#FFFFFF",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_profile_name_as_list_does_not_crash():
|
||||
"""Regression guard: `name` wasn't previously routed through first_str()
|
||||
like the other fields are, unlike filament_vendor/filament_type/
|
||||
default_filament_colour just below it - a list value here used to raise
|
||||
TypeError inside clean_name()'s re.sub()."""
|
||||
data = {
|
||||
"type": "filament",
|
||||
"filament_id": "GFL02",
|
||||
"name": ["Geeetech PLA Basic"],
|
||||
"filament_vendor": ["Geeetech"],
|
||||
"filament_type": ["PLA"],
|
||||
}
|
||||
result = parse_profile(data)
|
||||
assert result is not None
|
||||
assert result["name"] == "Geeetech PLA Basic"
|
||||
|
||||
|
||||
def test_parse_profile_missing_name_falls_back_to_filament_id():
|
||||
data = {"type": "filament", "filament_id": "GFL03", "filament_vendor": ["Generic"]}
|
||||
result = parse_profile(data)
|
||||
assert result["name"] == "GFL03"
|
||||
|
||||
|
||||
def test_parse_profile_missing_optional_fields_default_to_empty_string():
|
||||
data = {"type": "filament", "filament_id": "GFL04", "name": "Mystery Filament"}
|
||||
result = parse_profile(data)
|
||||
assert result["type"] == ""
|
||||
assert result["color"] == ""
|
||||
assert result["vendor"] == "Generic" # no path_vendor given either
|
||||
|
||||
|
||||
def test_parse_profile_inherits_via_by_name():
|
||||
parent = {"type": "filament", "filament_id": "GFL05", "filament_vendor": ["Geeetech"], "filament_type": ["PLA"]}
|
||||
child = {"type": "filament", "inherits": "Geeetech PLA @base", "name": "Geeetech PLA Basic"}
|
||||
by_name = {"Geeetech PLA @base": [parent]}
|
||||
result = parse_profile(child, by_name=by_name)
|
||||
assert result is not None
|
||||
assert result["id"] == "GFL05"
|
||||
assert result["vendor"] == "Geeetech"
|
||||
assert result["type"] == "PLA"
|
||||
|
||||
|
||||
def test_parse_profile_inherits_via_system_index():
|
||||
system_index = [{
|
||||
"id": "GFL06", "name": "Geeetech PLA", "vendor": "Geeetech", "type": "PLA", "color": "",
|
||||
}]
|
||||
user_profile = {"type": "filament", "inherits": "Geeetech PLA @base", "name": "My Geeetech Override"}
|
||||
result = parse_profile(user_profile, system_index=system_index)
|
||||
assert result is not None
|
||||
assert result["id"] == "GFL06"
|
||||
assert result["vendor"] == "Geeetech"
|
||||
|
||||
|
||||
def test_parse_profile_inherits_cycle_does_not_infinite_loop():
|
||||
"""A inherits B, B inherits A - _resolve()'s hard depth=5 bound must
|
||||
terminate this rather than recursing forever."""
|
||||
a = {"type": "filament", "inherits": "B"}
|
||||
b = {"type": "filament", "inherits": "A"}
|
||||
by_name = {"A": [a], "B": [b]}
|
||||
# Neither profile has a filament_id anywhere in the cycle - must return
|
||||
# None (not hang, not crash) after exhausting the depth limit.
|
||||
result = parse_profile(a, by_name=by_name)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_profile_duplicate_system_names_logs_and_uses_last(caplog):
|
||||
"""clean_name() deliberately collapses variant-suffixed names onto the
|
||||
same cleaned name - sys_by_name's last-write-wins overwrite on collision
|
||||
is expected, but must now be observable via a debug log instead of
|
||||
silent."""
|
||||
system_index = [
|
||||
{"id": "GFL07", "name": "PolyTerra PLA", "vendor": "Polymaker", "type": "PLA", "color": ""},
|
||||
{"id": "GFL08", "name": "PolyTerra PLA", "vendor": "Polymaker", "type": "PLA", "color": ""},
|
||||
]
|
||||
user_profile = {"type": "filament", "inherits": "PolyTerra PLA @base", "name": "Override"}
|
||||
with caplog.at_level(logging.DEBUG, logger="kobrax.filaments"):
|
||||
result = parse_profile(user_profile, system_index=system_index)
|
||||
assert result is not None
|
||||
assert result["id"] == "GFL08" # last one wins, as before
|
||||
assert any("duplicate system profile name" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_parse_profile_bytes_valid_json():
|
||||
blob = json.dumps({
|
||||
"type": "filament", "filament_id": "GFL09", "name": "Test PLA",
|
||||
"filament_vendor": ["Test"], "filament_type": ["PLA"],
|
||||
}).encode("utf-8")
|
||||
result = parse_profile_bytes(blob)
|
||||
assert result is not None
|
||||
assert result["id"] == "GFL09"
|
||||
|
||||
|
||||
def test_parse_profile_bytes_malformed_json_returns_none():
|
||||
assert parse_profile_bytes(b"{not valid json") is None
|
||||
|
||||
|
||||
def test_parse_profile_bytes_non_dict_json_returns_none():
|
||||
assert parse_profile_bytes(b"[1, 2, 3]") is None
|
||||
assert parse_profile_bytes(b'"just a string"') is None
|
||||
assert parse_profile_bytes(b"42") is None
|
||||
@@ -39,6 +39,15 @@ async def test_settings_get_returns_configured_values(client_configured):
|
||||
assert data["device_id"] == "abc123deadbeef"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_invalid_json_returns_400(client):
|
||||
"""A malformed/non-JSON body must be a clean 400, not an unhandled 500
|
||||
with a raw JSONDecodeError traceback (code review finding)."""
|
||||
c, _ = client
|
||||
resp = await c.post("/api/settings", data=b"not json", headers={"Content-Type": "application/json"})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_writes_config_ini(client):
|
||||
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""
|
||||
|
||||
@@ -12,6 +12,49 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_apply_invalid_json_returns_400(client):
|
||||
"""A malformed/non-JSON body must be a clean 400, not an unhandled 500
|
||||
with a raw JSONDecodeError traceback (code review finding)."""
|
||||
c, _ = client
|
||||
resp = await c.post("/api/update/apply", data=b"not json", headers={"Content-Type": "application/json"})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_check_testing_channel_is_docker_only(client):
|
||||
"""A testing-<sha> build has no Gitea releases at all - the check must
|
||||
report a docker-only channel with nothing to update, NOT fall through to
|
||||
the stable path and wrongly offer a stable "update". Must not even call
|
||||
the Gitea API."""
|
||||
c, bridge = client
|
||||
bridge._read_version = lambda: "testing-2e4dbf0"
|
||||
|
||||
with patch("aiohttp.ClientSession.get") as mock_get:
|
||||
resp = await c.get("/api/update/check")
|
||||
data = await resp.json()
|
||||
|
||||
assert resp.status == 200
|
||||
assert data["update_available"] is False
|
||||
assert data["docker_only"] is True
|
||||
assert data["current"] == "testing-2e4dbf0"
|
||||
mock_get.assert_not_called() # no Gitea round-trip for the testing channel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_apply_testing_channel_blocked(client):
|
||||
"""Self-update must be refused on the testing channel, same as nightly -
|
||||
testing images are delivered via Docker only."""
|
||||
c, bridge = client
|
||||
bridge._read_version = lambda: "testing-2e4dbf0"
|
||||
|
||||
resp = await c.post("/api/update/apply", json={"tag": "whatever"})
|
||||
data = await resp.json()
|
||||
assert resp.status == 400
|
||||
assert "testing" in data["error"]
|
||||
assert "docker" in data["error"].lower()
|
||||
|
||||
|
||||
def _fake_releases_response(payload):
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
|
||||
Reference in New Issue
Block a user