Compare commits

...

16 Commits

Author SHA1 Message Date
7be321d185 nightly: 0.9.30-nightly14 2026-08-25 13:27:36 +02:00
fba95d3564 chore: nightly 0.9.30-nightly14 2026-08-25 13:26:46 +02:00
bedd984011 chore: add AMS setInfo diagnostic probe script 2026-08-25 13:26:31 +02:00
ef1956f423 fix(spoolman): stop deducting filament from spools not used in the print
User report: with 4 spools mapped, every print deducted the same
several-gram amount from ALL 4 spools regardless of which one was
actually printing with, adding up to ~150g of drift over time on an
unused spool.

Root cause: _spoolman_last_sync was reset to 0.0 (epoch) at print
start instead of the current time. The poll loop calls
_spoolman_sync_midprint() before _spoolman_attribute_tick() in the
same iteration, so with last_sync=0.0 the sync-due check was true on
the very first tick after print start - before any per-slot
attribution existed yet. _spoolman_unreported() then fell back to
splitting the printer's full (possibly already nonzero) supplies_usage
equally across every mapped spool, matching the reported log
(identical mm reported to all 4 spools, right after upload before the
purge even started).

Fixes:
- _on_print() now resets _spoolman_last_sync to time.time(), not 0.0,
  so the first sync check is only due after a real interval has
  passed with attribution data available.
- _spoolman_unreported()'s fallback now only applies with exactly one
  mapped slot (single-extruder, no ambiguity) - with multiple slots
  and no attribution data it reports nothing instead of guessing,
  since a wrong equal-split is worse than a temporarily-missed report.

Added tests/test_spoolman_unreported.py covering both fixes.

(Same fix as testing branch commit b44c902, applied manually here
since nightly still has the pre-mixin-split monolithic
kobrax_moonraker_bridge.py layout - bridge_mqtt.py/bridge_spoolman.py
don't exist on this branch yet.)
2026-08-24 19:17:02 +02:00
e70720bc27 fix(ui): header power switch shows the available action, not both states
Replaces the segmented An/Aus toggle with a single button that always
shows the action a click would perform: printer on -> button offers
"Aus" (red), printer off -> button offers "An" (green). Matches how
the physical switch actually works (only one direction is ever
possible at a time) instead of presenting two options where one is
always a no-op.

_headerPowerState tracks the last known actual state so
toggleHeaderPower() knows which action to send; unrelated printer-tab
button/other UI untouched.
2026-08-24 19:09:36 +02:00
fb4a9414f4 fix(ui): move power switch from Dashboard card to header
Wrong placement: a standalone Dashboard card was awkward and easy to
miss depending on layout/scroll position, and didn't fit the
multi-printer case as naturally as the header does - the header
already tracks the active printer via renderPrinterDropdown() (single
name display + dropdown when >1 printer configured), so hooking the
power toggle in there means it always reflects whichever printer is
currently active, including after switching via the dropdown.

Replaces the removed card-power dashboard card with a compact
segmented An/Aus toggle in the header, next to the printer name and
before the status badge - same has_power_control gating and
/kx/printers/{id}/power + power-status endpoints as before, just
relocated and restyled for the header's smaller scale (.h-power-btn
instead of the dashboard-sized .spd-btn, same spd-active highlight
convention for the current state).

Also reverts the DASH_CARD_KEYS/DASH_DEFAULT_LAYOUT change and the
initDashGrid() backward-compat migration for old saved layouts, since
there's no longer a "power" grid card to add.
2026-08-24 19:05:44 +02:00
752f5c790c feat(ui): add power switch toggle to the Dashboard
User feedback: the power-control button for the smart-plug feature
was hard to find (only in the Printers tab, not the Dashboard where
users look first) and once found, easy to miss - a bare emoji icon
with no background/border, colored only via a muted gray/green text
color change.

Adds a "Steckdose"/Power Switch dashboard card with a segmented
An/Aus toggle styled like the print-speed selector (bordered pill,
accent-colored + tinted background when active) - much more visible
than a plain icon. Only rendered when the active printer has
power_on_url/power_off_url configured, mirroring the existing
has_power_control gating in the Printers tab. Reuses the existing
/kx/printers/{id}/power and power-status endpoints; the Printers-tab
button stays as-is for per-printer control in multi-printer setups.

Also handles the case where a user's saved GridStack dashboard layout
predates this card: initDashGrid() now appends any DASH_DEFAULT_LAYOUT
card missing from a saved layout instead of only updating matching
ids, so existing users get the new card without their custom layout
being wiped (which bumping DASH_LAYOUT_VERSION would otherwise do to
everyone).
2026-08-24 19:00:08 +02:00
287369ac90 fix(mqtt): wrap SUBSCRIBE packet ID instead of growing unbounded
self._pid was incremented on every _subscribe() call (i.e. every
connect/reconnect) but encoded as a 16-bit field via
pid.to_bytes(2, "big"), which only accepts 1-65535. A long-lived
bridge with frequent MQTT reconnects eventually overflowed the
counter, raising "OverflowError: int too big to convert" on every
subsequent connect attempt - including the manual "Connect" button,
which silently 500'd while the background poll loop kept reconnecting
regardless, making the bug easy to miss.

Wrap back to 1 at the 16-bit boundary instead of growing forever.
2026-08-15 11:28:10 +02:00
51951476a9 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.30-nightly12 release
Some checks are pending
Nightly Build / build (push) Has started running
2026-08-05 07:46:56 +00:00
5dd668e8ca docs: restore NIGHTLY_CHANGELOG entries after rebase (code-review fixes batch)
All checks were successful
Nightly Build / build (push) Successful in 8m51s
2026-08-05 09:37:43 +02:00
0c520ffa00 feat(update): treat the testing channel as docker-only in the update check
A testing-<sha> build has no Gitea releases (the testing workflow builds only
a Docker image), so the in-app update check must not fall through to the
stable path - which would wrongly offer a stable "update". handle_api_update_check
now short-circuits for a "testing" version with docker_only:true and nothing
to update (no Gitea round-trip at all), and handle_api_update_apply refuses
self-update on the testing channel the same way it already does for nightly.

Two new tests cover both. (Mirrors the same fix already on the testing
branch, applied here to the pre-refactor monolithic module.)
2026-08-05 09:35:26 +02:00
3daf945612 fix(filaments): guard against non-string profile names, log name collisions
Found during a targeted code review, not from a user report.

parse_profile()'s `name` field went straight through clean_name() without
routing through first_str() first, unlike filament_vendor/filament_type/
default_filament_colour right below it - all of which handle the
documented case where OrcaSlicer stores a field as ["value"] instead of a
plain string. If `data["name"]` was ever a list, clean_name()'s re.sub()
raised TypeError since it requires a string argument. Fixed by routing it
through first_str() like its neighbors.

Also added a debug log when sys_by_name (the system-profile lookup index)
overwrites an entry due to a name collision - clean_name() deliberately
collapses variant-suffixed profile names (e.g. "...@base" vs
"...@Anycubic Kobra X 0.4 nozzle") onto the same cleaned name, so a
collision is expected, but the resulting last-write-wins overwrite was
previously silent, making an unexpected inherits-parent resolution hard to
debug.

New tests in tests/test_orca_filaments_parser.py add the first dedicated
coverage for parse_profile()/parse_profile_bytes()/clean_name() - previous
tests only used pre-parsed profile dicts as fixtures and never exercised
the parsing logic itself.
2026-08-04 21:23:13 +02:00
60156fd589 fix(config): don't crash the whole bridge on a typo'd numeric config value
Found during a targeted code review, not from a user report.

MQTT_PORT, POLL_INTERVAL, and the other numeric module-level shortcuts in
config_loader.py ran int(get(...)) unguarded at import time. A hand-edited
config.ini with a typo (e.g. "mqtt_port = 98833x") raised an uncaught
ValueError before the bridge even started, with a raw traceback instead of
a usable diagnostic - list_printers() already guarded this exact class of
input the same way, but the module-level constants didn't. Added
_safe_int() (same try/except-with-fallback pattern) and applied it
everywhere int() was called unguarded on a config value.

Also wrapped migrate_env_to_config()'s filesystem writes (which also run
at import time during first-run .env migration) in try/except, so a
permission or disk-full error logs a clear message before re-raising
instead of surfacing as a bare traceback pointing into configparser.

New tests in tests/test_config_loader_robustness.py cover both, including
an end-to-end subprocess test that imports config_loader against a
malformed config.ini (a plain re-import wouldn't re-exercise the
import-time code path due to Python's module caching).
2026-08-04 21:22:54 +02:00
a106e4ab68 fix(bridge): camera process-race and unhandled JSON errors in two handlers
Found during a targeted code review, not from a user report:

- _run_jpeg_loop()/_run_h264_loop() operated directly on the shared
  self._proc_jpeg/self._proc_h264 instance attributes in their cleanup,
  unlike _run_mjpeg_loop() (already fixed for exactly this) which uses a
  local `proc` reference. If a loop's task is cancelled - e.g. by
  CameraCache.reset() after the printer rotates its stream URL on reboot -
  while a new task has already started and assigned its own process to the
  shared attribute, the cancelled task's cleanup killed the NEWER process
  instead of its own, leaking its own ffmpeg child as an orphan. Applied
  the same local-variable + identity-check pattern already used by
  _run_mjpeg_loop.

- handle_api_settings_post and handle_api_update_apply were the only two
  of ~84 handlers that called `await request.json()` without a try/except -
  every other handler follows the established pattern of returning a clean
  400 for a malformed body. A trivial malformed request to either endpoint
  produced an unhandled 500 with a full traceback instead.

New tests in tests/test_camera_process_race.py,
tests/test_settings.py, and tests/test_update_check.py cover both.
2026-08-04 21:22:54 +02:00
8384c69836 fix(mqtt): harden kobrax_client.py against malformed data and concurrent requests
Found during a targeted code review, not from a user report:

- _drain() could get permanently stuck: valid JSON that isn't an object
  (e.g. a bare number or list, which json.loads() happily accepts) crashed
  _dispatch()'s dict-oriented logic, and the exception escaped before
  self._buf was advanced past the bad packet - so the same malformed bytes
  sat at the front of the buffer and re-crashed every subsequent _drain()
  call, forcing a reconnect each time. Now the buffer always advances (via
  try/finally) and _dispatch() rejects non-dict payloads with a warning log
  instead of crashing.

- _pending_msgid/_pending_report were mutated without synchronization
  while the reader thread reads/resolves them in _dispatch() - a
  check-then-set race on the shared report_key slot meant two concurrent
  publish() calls for the same msg_type could have one silently miss its
  reply. Added a dedicated lock around all registration/cleanup.

- The report-topic resolution path never verified a reply's msgid matched
  the waiter it was about to resolve, so a late reply for an
  already-timed-out request could be delivered to an unrelated, newer
  caller waiting on the same report_key. Entries now carry their own msgid
  for this comparison; replies without a msgid still resolve normally
  (most printer push-reports don't carry one).

- upload_gcode() silently sent a request with an empty session token when
  the upload URL was missing "?s=", instead of raising a clear error at
  the actual point of failure. It also leaked the upload socket's file
  descriptor on any send/recv failure other than a timeout, since there
  was no try/finally around its lifetime.

New tests in tests/test_client_robustness.py cover all of the above.
2026-08-04 21:22:54 +02:00
a360b463f9 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.30-nightly10 release
All checks were successful
Nightly Build / build (push) Successful in 8m36s
2026-08-04 12:51:58 +00:00
26 changed files with 1334 additions and 136 deletions

21
CHANGES.md Normal file
View 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`

View File

@ -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.

View File

@ -1 +1 @@
0.9.27
0.9.30-nightly14

View File

@ -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
View 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"

View File

@ -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:

View File

@ -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(

View File

@ -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
View 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"
}

View 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

View 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()

View 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

View 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

View File

@ -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)."""

View 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()

View File

@ -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
View 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()

View File

@ -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).

View File

@ -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>

View File

@ -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}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "清除",