Compare commits
16 Commits
testing
...
nightly-0.
| Author | SHA1 | Date | |
|---|---|---|---|
| 7be321d185 | |||
| fba95d3564 | |||
| bedd984011 | |||
| ef1956f423 | |||
| e70720bc27 | |||
| fb4a9414f4 | |||
| 752f5c790c | |||
| 287369ac90 | |||
| 51951476a9 | |||
| 5dd668e8ca | |||
| 0c520ffa00 | |||
| 3daf945612 | |||
| 60156fd589 | |||
| a106e4ab68 | |||
| 8384c69836 | |||
| a360b463f9 |
21
CHANGES.md
Normal file
21
CHANGES.md
Normal file
@ -0,0 +1,21 @@
|
||||
## KX-Bridge 0.9.30-nightly14 — Nightly Build
|
||||
|
||||
[experimental] May be unstable — for testers and early adopters only.
|
||||
|
||||
### Changes since `nightly-20260625`
|
||||
|
||||
|
||||
|
||||
- Fix: **filament usage could be silently deducted from Spoolman spools that weren't even used in the current print.** With multiple AMS slots mapped to Spoolman spools, every print reported the same filament-usage amount to *all* mapped spools instead of only the one actually printing with — over many prints this could drift a spool's tracked remaining filament by 100g+ compared to reality. Root cause: a timing bug where the mid-print usage sync ran before the bridge had a chance to attribute usage to the correct slot right after print start, falling back to an equal split across every spool. Fixed the timing and made the fallback safe for multi-slot setups (reports nothing rather than guessing wrong).
|
||||
- Fix: the manual "Connect" button in the header could silently fail (500 error) on long-running bridge instances with frequent MQTT reconnects, due to an internal packet-ID counter overflowing after enough reconnect cycles.
|
||||
- Feat: the power-switch toggle (for smart-plug printer power control) moved from the Printers tab into the header, right next to the printer name/status — much easier to find, and now shows the action a click would perform (green "On" while the printer is off, red "Off" while it's on) instead of two static buttons.
|
||||
|
||||
---
|
||||
|
||||
### Update Docker image
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Image tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`
|
||||
@ -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)
|
||||
|
||||
35
docker-publish-testing.sh
Executable file
35
docker-publish-testing.sh
Executable file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# docker-publish-testing.sh – lokaler Docker-Build+Push für den Testing-Kanal.
|
||||
#
|
||||
# Ersatz für .gitea/workflows/testing.yml: baut & pusht
|
||||
# gitea.it-drui.de/viewit/kx-bridge:testing + :testing-<shortsha>
|
||||
# direkt vom aktuellen HEAD des testing-Branches. Bewusst kein Teil von release.sh
|
||||
# (kein Versions-Argument, kein Changelog, kein Tag, kein Branch-Writeback – Testing-
|
||||
# Pushes sind commit-getrieben/ad-hoc, wie es testing.yml auch schon war).
|
||||
#
|
||||
# Verwendung:
|
||||
# ./docker-publish-testing.sh
|
||||
#
|
||||
# Voraussetzung: einmalig `docker login gitea.it-drui.de` (siehe release_lib.sh).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEV_REPO="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DEV_REPO"
|
||||
source "$DEV_REPO/release_lib.sh"
|
||||
|
||||
CUR_BRANCH=$(git branch --show-current)
|
||||
[[ "$CUR_BRANCH" == "testing" ]] || error "Muss auf 'testing'-Branch sein (aktuell: $CUR_BRANCH)"
|
||||
|
||||
VERSION="testing-$(git rev-parse --short HEAD)"
|
||||
info "Testing-Version: $VERSION"
|
||||
|
||||
# VERSION nur im Workspace setzen (kein Commit) – wie testing.yml es tat.
|
||||
echo "$VERSION" > VERSION
|
||||
|
||||
docker_build_push "$VERSION" "testing" "$VERSION"
|
||||
|
||||
# Workspace wieder sauber hinterlassen.
|
||||
git checkout -- VERSION 2>/dev/null || true
|
||||
|
||||
success "Testing-Build fertig: gitea.it-drui.de/viewit/kx-bridge:testing + :$VERSION"
|
||||
192
kobrax_client.py
192
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] = {}
|
||||
@ -363,7 +371,12 @@ class KobraXClient:
|
||||
def _subscribe(self, topic: str):
|
||||
with self._lock:
|
||||
pid = self._pid
|
||||
self._pid += 1
|
||||
# MQTT packet IDs are a 16-bit field (1-65535, 0 reserved) - wrap
|
||||
# instead of growing unbounded, otherwise a long-lived bridge with
|
||||
# frequent reconnects eventually overflows pid.to_bytes(2, "big")
|
||||
# (OverflowError: int too big to convert), breaking every future
|
||||
# connect attempt including the manual "Connect" button.
|
||||
self._pid = 1 if self._pid >= 0xFFFF else self._pid + 1
|
||||
if self._sock is not None:
|
||||
self._sock.sendall(_build_subscribe(topic, pid))
|
||||
log.info("SUB %s", topic)
|
||||
@ -446,34 +459,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 +509,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 +539,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 +597,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 +622,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 +635,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 +758,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 +810,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)
|
||||
@ -1181,8 +1195,14 @@ class KobraXBridge:
|
||||
def _spoolman_unreported(self) -> dict[int, float]:
|
||||
"""Return {slot_idx: mm} of usage not yet reported to Spoolman.
|
||||
|
||||
Falls back to equal split of total supplies_usage when per-slot
|
||||
attribution data is absent (e.g. single-extruder with no AMS)."""
|
||||
Falls back to crediting the single mapped slot with the full
|
||||
supplies_usage when per-slot attribution data is absent (single-
|
||||
extruder setup with no AMS - there's only ever one spool it could be).
|
||||
With more than one mapped slot, splitting unattributed usage equally
|
||||
across all of them would silently deduct filament from spools not
|
||||
even used in the current print (Issue: filament removed from spools
|
||||
not part of the print) - safer to report nothing for those slots and
|
||||
wait for real attribution data than to guess wrong."""
|
||||
total_used = self._state.get("supplies_usage", 0)
|
||||
if self._spoolman_slot_usage:
|
||||
return {
|
||||
@ -1190,10 +1210,11 @@ class KobraXBridge:
|
||||
- self._spoolman_slot_reported.get(slot, 0.0)
|
||||
for slot in self._spoolman_slot_spools
|
||||
}
|
||||
n = len(self._spoolman_slot_spools)
|
||||
already = sum(self._spoolman_slot_reported.values())
|
||||
per = (total_used - already) / n if n else 0.0
|
||||
return {slot: per for slot in self._spoolman_slot_spools}
|
||||
if len(self._spoolman_slot_spools) == 1:
|
||||
slot = next(iter(self._spoolman_slot_spools))
|
||||
already = self._spoolman_slot_reported.get(slot, 0.0)
|
||||
return {slot: total_used - already}
|
||||
return {}
|
||||
|
||||
def _spoolman_report(self, unreported: dict[int, float], min_mm: float = 0.1) -> None:
|
||||
"""Fire-and-forget report of unreported mm to each mapped spool."""
|
||||
@ -1406,7 +1427,18 @@ class KobraXBridge:
|
||||
self._spoolman_slot_usage = {}
|
||||
self._spoolman_slot_reported = {}
|
||||
self._spoolman_last_usage = 0.0
|
||||
self._spoolman_last_sync = 0.0
|
||||
# Must be "now", not 0.0/epoch: _spoolman_sync_midprint() checks
|
||||
# time.time() - _spoolman_last_sync >= sync_rate in the poll loop,
|
||||
# and runs BEFORE _spoolman_attribute_tick() in the same iteration
|
||||
# (see run_bridge's poll loop). With last_sync=0.0 that condition
|
||||
# is true on the very first tick after print start, before any
|
||||
# per-slot usage has been attributed yet - _spoolman_unreported()
|
||||
# then falls back to splitting the printer's full (possibly
|
||||
# already nonzero/carried-over) supplies_usage equally across
|
||||
# every mapped spool, silently deducting filament from spools not
|
||||
# even used in this print (reported live, several grams per spool
|
||||
# per print).
|
||||
self._spoolman_last_sync = time.time()
|
||||
|
||||
# Job-History: Druckende erkennen
|
||||
if kobra_state in ("finished",) and self._current_job_id:
|
||||
@ -5093,7 +5125,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 +5469,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 +5563,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
release_lib.sh
Normal file
83
release_lib.sh
Normal file
@ -0,0 +1,83 @@
|
||||
# release_lib.sh – gemeinsame Logging-/Docker-/Changelog-Helper für release.sh und
|
||||
# docker-publish-testing.sh. Wird per `source` eingebunden, kein eigenständiges Skript
|
||||
# (kein Shebang, kein `set -euo pipefail` hier – das übernimmt der aufrufende Caller).
|
||||
|
||||
# ── Farben ───────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
info() { echo -e "${CYAN}[release]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[release]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[release]${NC} $*"; }
|
||||
error() { echo -e "${RED}[release]${NC} $*" >&2; exit 1; }
|
||||
|
||||
# docker_build_push <version> <tag1> [<tag2> ...]
|
||||
# Baut das kx-bridge-Image lokal via buildx (kxbuilder, 3 Plattformen) und pusht es
|
||||
# direkt in die Gitea-Registry. Ersetzt die vormals CI-seitige Build-Logik aus
|
||||
# release.yml/nightly.yml/testing.yml 1:1 (gleiche Flags, gleicher Builder-Name).
|
||||
docker_build_push() {
|
||||
local version="$1"; shift
|
||||
local -a tag_args=()
|
||||
for t in "$@"; do tag_args+=( -t "gitea.it-drui.de/viewit/kx-bridge:${t}" ); done
|
||||
[[ ${#tag_args[@]} -eq 0 ]] && error "docker_build_push: keine Tags angegeben"
|
||||
|
||||
info "Docker buildx: kxbuilder aktivieren …"
|
||||
docker buildx inspect kxbuilder --bootstrap >/dev/null 2>&1 || {
|
||||
docker buildx create --name kxbuilder --driver docker-container --use
|
||||
docker buildx inspect kxbuilder --bootstrap
|
||||
}
|
||||
docker buildx use kxbuilder
|
||||
|
||||
info "QEMU/binfmt sicherstellen …"
|
||||
docker run --rm --privileged tonistiigi/binfmt:latest --install all >/dev/null
|
||||
|
||||
grep -q '"gitea.it-drui.de"' ~/.docker/config.json 2>/dev/null \
|
||||
|| error "Nicht bei gitea.it-drui.de eingeloggt — 'docker login gitea.it-drui.de' zuerst ausführen"
|
||||
|
||||
info "docker buildx build --push (${tag_args[*]}) für Version $version …"
|
||||
( cd "$DEV_REPO" && docker buildx build \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--push \
|
||||
--provenance=false \
|
||||
--no-cache \
|
||||
"${tag_args[@]}" \
|
||||
. )
|
||||
success "Docker-Image gepusht: ${tag_args[*]}"
|
||||
}
|
||||
|
||||
# author_changelog_entry <base_tag> <output_file> <title> [<seed_content_file>]
|
||||
# Öffnet $EDITOR auf einem Draft, vorausgefüllt mit dem Git-Log-Delta seit <base_tag>
|
||||
# (als Kommentar) plus optional dem Inhalt von <seed_content_file> (z.B. bestehende
|
||||
# NIGHTLY_CHANGELOG.md) als Startpunkt. Schreibt das editierte Ergebnis (Kommentarzeilen
|
||||
# entfernt) nach <output_file>. Bricht ab wenn nichts Sinnvolles übrig bleibt.
|
||||
author_changelog_entry() {
|
||||
local base="$1" outfile="$2" title="$3" seed_file="${4:-}"
|
||||
local draft
|
||||
draft=$(mktemp)
|
||||
{
|
||||
echo "# ${title}"
|
||||
echo "# Editiere den Text unten (oder ersetze ihn komplett)."
|
||||
echo "# Zeilen die mit '#' beginnen werden entfernt."
|
||||
echo "#"
|
||||
echo "# Roher Commit-Delta seit ${base:-<Repo-Anfang>} zur Orientierung:"
|
||||
gen_changelog "$base" | sed 's/^/# /'
|
||||
echo "#"
|
||||
if [[ -n "$seed_file" && -s "$seed_file" ]]; then
|
||||
echo "# ── Bestehender Inhalt (z.B. NIGHTLY_CHANGELOG.md) als Ausgangspunkt: ──"
|
||||
cat "$seed_file"
|
||||
else
|
||||
echo "### New"
|
||||
echo "- "
|
||||
echo ""
|
||||
echo "### Fixed"
|
||||
echo "- "
|
||||
fi
|
||||
} > "$draft"
|
||||
|
||||
"${EDITOR:-nano}" "$draft"
|
||||
|
||||
grep -v '^#' "$draft" > "$outfile"
|
||||
if [[ ! -s "$outfile" ]] || ! grep -q '^- ' "$outfile"; then
|
||||
rm -f "$draft"
|
||||
error "Changelog-Eintrag leer/kein Bullet gefunden — Release abgebrochen."
|
||||
fi
|
||||
rm -f "$draft"
|
||||
}
|
||||
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)."""
|
||||
|
||||
87
tests/test_spoolman_unreported.py
Normal file
87
tests/test_spoolman_unreported.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Regression test: Spoolman fallback split silently deducted filament from
|
||||
spools not used in the current print.
|
||||
|
||||
_spoolman_unreported() falls back to an equal split across every mapped
|
||||
AMS slot when per-slot attribution data (_spoolman_slot_usage) is still
|
||||
empty - previously true right after print start, because
|
||||
_spoolman_sync_midprint() ran (in the poll loop) before the first
|
||||
_spoolman_attribute_tick() had a chance to populate any per-slot data,
|
||||
since _spoolman_last_sync was reset to 0.0 (== "due immediately") instead
|
||||
of the current time. With 4 spools mapped, that meant the full
|
||||
(possibly already nonzero) supplies_usage got reported equally to all 4
|
||||
spools regardless of which one was actually printing with.
|
||||
|
||||
The fix: only fall back to a full-credit report when there is exactly
|
||||
one mapped slot (single-extruder, no ambiguity possible) - otherwise
|
||||
report nothing until real attribution data exists. Additionally,
|
||||
_on_print() now resets _spoolman_last_sync to the current time (not 0.0)
|
||||
at print start, so the first mid-print sync check is due only after a
|
||||
real sync_rate interval has passed.
|
||||
"""
|
||||
import time
|
||||
|
||||
|
||||
def _configure(bridge, slot_spools):
|
||||
bridge._spoolman_slot_spools = dict(slot_spools)
|
||||
bridge._spoolman_slot_usage = {}
|
||||
bridge._spoolman_slot_reported = {}
|
||||
|
||||
|
||||
async def test_unreported_reports_nothing_for_multiple_slots_without_attribution(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 11, 1: 15, 2: 17, 3: 23})
|
||||
bridge._state["supplies_usage"] = 4369
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_unreported_credits_the_single_slot_without_attribution(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 42})
|
||||
bridge._state["supplies_usage"] = 500
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
assert result == {0: 500}
|
||||
|
||||
|
||||
async def test_unreported_single_slot_subtracts_already_reported(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 42})
|
||||
bridge._spoolman_slot_reported = {0: 200.0}
|
||||
bridge._state["supplies_usage"] = 500
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
assert result == {0: 300.0}
|
||||
|
||||
|
||||
async def test_unreported_uses_real_attribution_once_available(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 11, 1: 15})
|
||||
bridge._spoolman_slot_usage = {0: 120.0}
|
||||
bridge._state["supplies_usage"] = 120
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
# Only the slot that was actually attributed usage gets a nonzero report;
|
||||
# the untouched slot must not receive any share of it.
|
||||
assert result[0] == 120.0
|
||||
assert result[1] == 0.0
|
||||
|
||||
|
||||
async def test_print_start_resets_last_sync_to_now_not_epoch(client):
|
||||
"""_spoolman_last_sync=0.0 at print start meant the poll loop's mid-print
|
||||
sync check (time.time() - last_sync >= sync_rate) was true on the very
|
||||
first tick, before _spoolman_attribute_tick() ever ran once - triggering
|
||||
the fallback-split bug above immediately after print start, before any
|
||||
real per-slot usage existed yet."""
|
||||
_, bridge = client
|
||||
before = time.time()
|
||||
|
||||
bridge._on_print({"state": "printing", "data": {"filename": "test.gcode"}})
|
||||
|
||||
assert bridge._spoolman_last_sync >= before
|
||||
assert bridge._spoolman_last_sync <= time.time()
|
||||
@ -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
|
||||
|
||||
161
tools/probe_ams_setinfo.py
Normal file
161
tools/probe_ams_setinfo.py
Normal file
@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manual probe for Issue #100's open remainder: the printer rejects
|
||||
multiColorBox/setInfo for physical ACE-box slots (box id >= 0) while the
|
||||
same call succeeds for the no-ACE/single-slot case (box id == -1).
|
||||
|
||||
Sends a series of setInfo payload variants against ONE real ACE slot and
|
||||
prints the printer's multiColorBox/report response for each, so the
|
||||
accepted schema (if any of these match it) becomes visible by diff.
|
||||
|
||||
DO NOT run this against a printer with a print in progress - AMS slot
|
||||
writes are safe at idle but untested behavior mid-print.
|
||||
|
||||
Usage:
|
||||
python3 tools/probe_ams_setinfo.py <printer_ip> <box_id> <local_slot> <material_type> <r> <g> <b>
|
||||
|
||||
Example (box 0, slot 2, keep existing PLA/color to be non-destructive):
|
||||
python3 tools/probe_ams_setinfo.py 192.168.2.144 0 2 PLA 238 190 152
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bridge"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from kobrax_client import KobraXClient # noqa: E402
|
||||
|
||||
MODE_ID = "20030"
|
||||
|
||||
|
||||
def make_client(host):
|
||||
c = KobraXClient(
|
||||
host=host,
|
||||
username=os.environ.get("PROBE_USER", ""),
|
||||
password=os.environ.get("PROBE_PASS", ""),
|
||||
mode_id=os.environ.get("PROBE_MODE_ID", MODE_ID),
|
||||
device_id=os.environ.get("PROBE_DEVICE_ID", ""),
|
||||
port=9883,
|
||||
client_id="ams-probe",
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
def variant_original(box_id, local_slot, mat, color):
|
||||
return {"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]}
|
||||
|
||||
|
||||
def variant_full_slot_object(box_id, local_slot, mat, color, existing_slot):
|
||||
slot = dict(existing_slot)
|
||||
slot["index"] = local_slot
|
||||
slot["type"] = mat
|
||||
slot["color"] = color
|
||||
slot["color_group"] = [color + [255]]
|
||||
return {"multi_color_box": [{"id": box_id, "slots": [slot]}]}
|
||||
|
||||
|
||||
def variant_filaments_key(box_id, local_slot, mat, color):
|
||||
# Mirrors the shape the printer's OWN rejection echo uses:
|
||||
# {"filaments": {"id": local_slot}, "id": box_id}
|
||||
return {"multi_color_box": [{"id": box_id, "filaments": [{"id": local_slot, "type": mat, "color": color}]}]}
|
||||
|
||||
|
||||
def variant_filaments_singular(box_id, local_slot, mat, color):
|
||||
return {"multi_color_box": [{"id": box_id, "filaments": {"id": local_slot, "type": mat, "color": color}}]}
|
||||
|
||||
|
||||
def _extract_slot(payload, box_id, local_slot):
|
||||
"""multiColorBox/report's data comes as either a dict ({"multi_color_box": [...]})
|
||||
or, on rejection, a bare 2-element list (["multi_color_box", [...]]) - see
|
||||
Issue #100. Handle both so the probe doesn't silently miss a rejection reply."""
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
boxes = data.get("multi_color_box") or []
|
||||
elif isinstance(data, list) and len(data) == 2 and data[0] == "multi_color_box":
|
||||
boxes = data[1] or []
|
||||
else:
|
||||
return None
|
||||
for box in boxes:
|
||||
if int(box.get("id", -999)) == box_id:
|
||||
slots = box.get("slots") or []
|
||||
if 0 <= local_slot < len(slots):
|
||||
return slots[local_slot]
|
||||
return None
|
||||
|
||||
|
||||
def get_existing_slot(client, box_id, local_slot, timeout=5.0):
|
||||
"""Reads the current slot state via the multiColorBox/report CALLBACK,
|
||||
not publish()'s return value - the generic empty response ACK
|
||||
({"code":0,"data":None}) can arrive under the same msgid and race the
|
||||
real report, silently winning the publish() wait (observed live)."""
|
||||
result = {}
|
||||
done = threading.Event()
|
||||
|
||||
def on_report(payload):
|
||||
slot = _extract_slot(payload, box_id, local_slot)
|
||||
if slot is not None:
|
||||
result["slot"] = slot
|
||||
done.set()
|
||||
|
||||
client.callbacks["multiColorBox/report"] = on_report
|
||||
client.publish_web("multiColorBox", "getInfo", None)
|
||||
done.wait(timeout)
|
||||
client.callbacks.pop("multiColorBox/report", None)
|
||||
return result.get("slot")
|
||||
|
||||
|
||||
def try_variant(client, name, payload, settle=3.0):
|
||||
print(f"\n=== {name} ===")
|
||||
print("TX:", payload)
|
||||
reports = []
|
||||
client.callbacks["multiColorBox/report"] = lambda p: reports.append(p)
|
||||
client.publish_web("multiColorBox", "setInfo", payload)
|
||||
time.sleep(settle)
|
||||
client.callbacks.pop("multiColorBox/report", None)
|
||||
for r in reports:
|
||||
print("RX report:", json.dumps(r, ensure_ascii=False)[:600])
|
||||
return reports
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 8:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
host = sys.argv[1]
|
||||
box_id = int(sys.argv[2])
|
||||
local_slot = int(sys.argv[3])
|
||||
mat = sys.argv[4]
|
||||
color = [int(sys.argv[5]), int(sys.argv[6]), int(sys.argv[7])]
|
||||
|
||||
client = make_client(host)
|
||||
client.connect()
|
||||
time.sleep(1)
|
||||
|
||||
existing = get_existing_slot(client, box_id, local_slot)
|
||||
print("Existing slot state:", existing)
|
||||
if existing is None:
|
||||
print("Could not read existing slot - aborting (don't probe blind).")
|
||||
return
|
||||
|
||||
try_variant(client, "A: original bridge shape (slots/index/type/color)",
|
||||
variant_original(box_id, local_slot, mat, color))
|
||||
|
||||
try_variant(client, "B: full slot object (all fields from getInfo, only type/color changed)",
|
||||
variant_full_slot_object(box_id, local_slot, mat, color, existing))
|
||||
|
||||
try_variant(client, "C: filaments list (mirrors printer's rejection echo shape)",
|
||||
variant_filaments_key(box_id, local_slot, mat, color))
|
||||
|
||||
try_variant(client, "D: filaments singular dict",
|
||||
variant_filaments_singular(box_id, local_slot, mat, color))
|
||||
|
||||
print("\nDone. Compare RX multiColorBox/report lines above/in the bridge log "
|
||||
"for each variant - state=success with the slot's type actually changed "
|
||||
"in the FOLLOWING getInfo/report is the only real confirmation.")
|
||||
client.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -279,6 +279,65 @@ function initPrinters(){
|
||||
renderPrinterDropdown();
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
// ── Header Power Switch (Issue: hard to find + poorly visible in the
|
||||
// Printers tab; mirrors togglePrinterPower()/_refreshPrinterPowerIcon() for
|
||||
// the currently active printer, right next to its name/status in the header
|
||||
// so it works the same in single- and multi-printer setups - switching the
|
||||
// active printer via the dropdown re-evaluates has_power_control for the
|
||||
// newly active one instead of a fixed dashboard card tied to whichever
|
||||
// printer happened to be active on page load) ──
|
||||
// Single button showing the action a click would perform (not the current
|
||||
// state): printer on → button offers "Aus", printer off → button offers
|
||||
// "An". _headerPowerState holds the last known actual state ('on'/'off'/
|
||||
// null=unknown) so toggleHeaderPower() knows which action to send.
|
||||
var _headerPowerState=null;
|
||||
function _initHeaderPower(){
|
||||
var btn=document.getElementById('h-power-btn');
|
||||
if(!btn)return;
|
||||
if(!_activePrinter||!_activePrinter.has_power_control){btn.style.display='none';return;}
|
||||
btn.style.display='';
|
||||
_refreshHeaderPower();
|
||||
}
|
||||
function _applyHeaderPowerState(state){
|
||||
_headerPowerState=state;
|
||||
var btn=document.getElementById('h-power-btn'), lbl=document.getElementById('h-power-lbl');
|
||||
if(!btn||!lbl)return;
|
||||
if(state==='on'){
|
||||
btn.classList.remove('h-power-can-on');btn.classList.add('h-power-can-off');
|
||||
lbl.textContent=T.printers_power_off_short||'Aus';
|
||||
}else if(state==='off'){
|
||||
btn.classList.remove('h-power-can-off');btn.classList.add('h-power-can-on');
|
||||
lbl.textContent=T.printers_power_on_short||'An';
|
||||
}else{
|
||||
btn.classList.remove('h-power-can-on','h-power-can-off');
|
||||
lbl.textContent=T.printers_power_on_short||'An';
|
||||
}
|
||||
}
|
||||
function _refreshHeaderPower(){
|
||||
if(!_activePrinter||!_activePrinter.has_power_control)return;
|
||||
var bridgeUrl=(_activePrinter.bridge_url||'').replace(/\/+$/,'');
|
||||
fetch(bridgeUrl+'/kx/printers/'+encodeURIComponent(_activePrinter.id)+'/power-status',{signal:AbortSignal.timeout(5000)})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){_applyHeaderPowerState(d.state==='on'||d.state==='off'?d.state:null);})
|
||||
.catch(function(){/* status endpoint optional - button just stays neutral */});
|
||||
}
|
||||
function toggleHeaderPower(){
|
||||
if(!_activePrinter)return;
|
||||
// Unknown 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=_headerPowerState==='on'?'off':'on';
|
||||
if(action==='off'&&!confirm(T.printers_power_off_confirm||'Turn printer power off? Make sure no print is running.'))return;
|
||||
var btn=document.getElementById('h-power-btn');
|
||||
if(btn)btn.style.opacity='0.5';
|
||||
post('/kx/printers/'+encodeURIComponent(_activePrinter.id)+'/power',{action:action}).then(function(){
|
||||
if(btn)btn.style.opacity='1';
|
||||
setTimeout(_refreshHeaderPower,1500);
|
||||
}).catch(function(e){
|
||||
if(btn)btn.style.opacity='1';
|
||||
clog('Power-Fehler: '+e,'msg-err');
|
||||
});
|
||||
}
|
||||
function renderPrinterDropdown(){
|
||||
var wrap=document.getElementById('printer-dropdown-wrap');
|
||||
var single=document.getElementById('h-pname-single');
|
||||
@ -302,6 +361,7 @@ function renderPrinterDropdown(){
|
||||
if(wrap)wrap.style.display='none';
|
||||
if(single)single.style.display='';
|
||||
}
|
||||
_initHeaderPower();
|
||||
}
|
||||
function togglePrinterDropdown(){
|
||||
var menu=document.getElementById('printer-dropdown-menu');
|
||||
@ -2025,6 +2085,7 @@ var pollTimer;
|
||||
}).catch(function(){});
|
||||
poll();pollTimer=setInterval(poll,ms);
|
||||
setInterval(_loadSpoolmanStatus,30000);
|
||||
setInterval(_refreshHeaderPower,30000);
|
||||
// initDashGrid() is called at the very end of the file, after all the
|
||||
// DASH_* var declarations below have executed (they are hoisted but not yet
|
||||
// assigned at this point in the IIFE).
|
||||
|
||||
@ -35,6 +35,9 @@
|
||||
</div>
|
||||
<div id="h-pname-single" class="hname">Anycubic Kobra X</div>
|
||||
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
|
||||
<button id="h-power-btn" class="h-power-btn" style="display:none;margin-left:10px" onclick="toggleHeaderPower()" title="Steckdose">
|
||||
🔌 <span id="h-power-lbl">An</span>
|
||||
</button>
|
||||
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
|
||||
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
|
||||
<button class="theme-btn" onclick="showPanel('settings')" id="settings-btn" title="Einstellungen">⚙</button>
|
||||
|
||||
@ -202,6 +202,14 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
.spd-btn .spd-icon{font-size:22px}
|
||||
.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
|
||||
.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
|
||||
.h-power-btn{border:1.5px solid var(--border);background:var(--raised);color:var(--txt2);
|
||||
border-radius:6px;padding:4px 10px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s}
|
||||
.h-power-btn:hover{opacity:.85}
|
||||
/* Shows the action a click would perform, not the current state - green
|
||||
"An" while the printer is off (click turns it on), red "Aus" while it's
|
||||
on (click turns it off). */
|
||||
.h-power-btn.h-power-can-on{border-color:var(--ok);background:rgba(76,222,128,.12);color:var(--ok)}
|
||||
.h-power-btn.h-power-can-off{border-color:var(--err);background:rgba(255,77,109,.12);color:var(--err)}
|
||||
|
||||
/* ── TIME CARDS ── */
|
||||
.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"card_light_fan": "Lüfter",
|
||||
"card_progress": "Fortschritt",
|
||||
"card_speed": "Druckgeschwindigkeit",
|
||||
"card_power": "Steckdose",
|
||||
"card_temps": "Temperaturen",
|
||||
"confirm_cancel": "Druck wirklich abbrechen?",
|
||||
"dash_done": "Fertig",
|
||||
@ -228,6 +229,8 @@
|
||||
"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_power_on_short": "An",
|
||||
"printers_power_off_short": "Aus",
|
||||
"printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
|
||||
"printers_switch": "Wechseln →",
|
||||
"progress_action_clear": "Leeren",
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"card_light_fan": "Fan",
|
||||
"card_progress": "Progress",
|
||||
"card_speed": "Print Speed",
|
||||
"card_power": "Power Switch",
|
||||
"card_temps": "Temperatures",
|
||||
"confirm_cancel": "Really cancel the print?",
|
||||
"dash_done": "Done",
|
||||
@ -228,6 +229,8 @@
|
||||
"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_power_on_short": "On",
|
||||
"printers_power_off_short": "Off",
|
||||
"printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
|
||||
"printers_switch": "Switch →",
|
||||
"progress_action_clear": "Clear",
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"card_light_fan": "Ventilador",
|
||||
"card_progress": "Progreso",
|
||||
"card_speed": "Velocidad de impresión",
|
||||
"card_power": "Enchufe",
|
||||
"card_temps": "Temperaturas",
|
||||
"confirm_cancel": "¿Realmente cancelar la impresión?",
|
||||
"dash_done": "Listo",
|
||||
@ -228,6 +229,8 @@
|
||||
"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_power_on_short": "Encendido",
|
||||
"printers_power_off_short": "Apagado",
|
||||
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
|
||||
"printers_switch": "Cambiar →",
|
||||
"progress_action_clear": "Vaciar",
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"card_light_fan": "Ventilateur",
|
||||
"card_progress": "Progression",
|
||||
"card_speed": "Vitesse d'impression",
|
||||
"card_power": "Prise électrique",
|
||||
"card_temps": "Températures",
|
||||
"confirm_cancel": "Vraiment annuler l'impression ?",
|
||||
"fd_cancel": "Annuler",
|
||||
@ -216,6 +217,8 @@
|
||||
"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_power_on_short": "Marche",
|
||||
"printers_power_off_short": "Arrêt",
|
||||
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
|
||||
"printers_switch": "Changer →",
|
||||
"progress_action_clear": "Vider",
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"card_light_fan": "Ventola",
|
||||
"card_progress": "Avanzamento",
|
||||
"card_speed": "Velocità di stampa",
|
||||
"card_power": "Presa di corrente",
|
||||
"card_temps": "Temperature",
|
||||
"confirm_cancel": "Annullare davvero la stampa?",
|
||||
"fd_cancel": "Annulla",
|
||||
@ -216,6 +217,8 @@
|
||||
"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_power_on_short": "Acceso",
|
||||
"printers_power_off_short": "Spento",
|
||||
"printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
|
||||
"printers_switch": "Cambia →",
|
||||
"progress_action_clear": "Cancella",
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"card_light_fan": "风扇",
|
||||
"card_progress": "进度",
|
||||
"card_speed": "打印速度",
|
||||
"card_power": "电源插座",
|
||||
"card_temps": "温度",
|
||||
"confirm_cancel": "确定要取消打印吗?",
|
||||
"dash_done": "完成",
|
||||
@ -228,6 +229,8 @@
|
||||
"printers_power_on": "电源:开",
|
||||
"printers_power_off": "电源:关",
|
||||
"printers_power_off_confirm": "关闭打印机电源?请确认当前没有正在进行的打印任务。",
|
||||
"printers_power_on_short": "开",
|
||||
"printers_power_off_short": "关",
|
||||
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
|
||||
"printers_switch": "切换 →",
|
||||
"progress_action_clear": "清除",
|
||||
|
||||
Reference in New Issue
Block a user