Moves the coherent printer-credential unit (_kx_generate_signature,
_kx_decrypt_info, _kx_fetch_credentials plus the pycryptodome import guard)
out of the facade into credentials.py, re-exported for the "add printer"
flow. The remaining free functions (build_app, main, _build_per_printer_args,
_default_data_dir, _mqtt_error_msg) stay in the facade - they're entrypoint
logic or travel more naturally with the core mixin in the next stage.
All 184 tests green, no behavior change.
First stage of splitting the 6368-line kobrax_moonraker_bridge.py monolith.
The low-coupling, self-contained pieces move into their own modules;
kobrax_moonraker_bridge.py re-exports them so every caller (12 test files,
the PyInstaller spec) keeps working unchanged - not a single test or the
spec needed editing.
Extracted:
- spoolman_client.py <- SpoolmanClient (zero coupling)
- gcode_store.py <- GCodeStore (stdlib only)
- gcode_meta.py <- _parse_gcode_*/_extract_* metadata helpers
- camera.py <- CameraCache + _find_ffmpeg
- bridge_logging.py <- _BrowserLogHandler, the log ring buffer + SSE
queues, _set_verbose_http_log (the shared mutable
buffer/queues are re-imported so the log endpoints
still operate on the same objects the handler writes)
Facade shrinks from 6368 to 5566 lines. All 184 tests green after each
extraction. Verified the re-exported names resolve and the shared log
objects are identical by reference across modules. No behavior change.
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.
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).
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.
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.
Discovered while testing the smart-plug power-switch feature: unplugging
the printer left the dashboard stuck showing it as online/"ready"
indefinitely. Live-tested against a real printer to isolate two
independent, compounding causes:
1. The MQTT socket had no TCP keepalive. A connection killed without a
clean TCP close (unplugged, not a graceful shutdown) looks alive to the
OS for as long as its default dead-connection timeout - 15+ minutes on
Linux - since a send on a half-open connection is buffered by the
kernel and doesn't fail immediately. Fixed with SO_KEEPALIVE (short
idle/interval/count) plus TCP_USER_TIMEOUT, since keepalive probes alone
only fire on an idle connection - verified live that a printer
disappearing while a send was still in flight (the common case, since
the poll loop sends every few seconds) instead falls back to the far
slower normal TCP retransmission timer, which keepalive settings don't
affect at all.
2. Even after the socket was correctly detected as dead, the status poll
loop could hang indefinitely inside publish() waiting for a reconnect
attempt already running on the MQTT reader thread (the Issue #105
reconnect-lock serialization), and therefore never reached the
is_connected() check that flips kobra_state to "offline". _reconnect()
now takes wait_if_in_progress/persist flags so the poll loop's call
returns immediately with at most one attempt instead of blocking
through someone else's multi-minute backoff loop - persistent retrying
stays the reader thread's job.
A disconnected printer is now detected and reflected on the dashboard
within about 15 seconds. Live-verified across repeated disconnect/
reconnect cycles that no sockets, threads, or file descriptors are left
behind (checked via /proc/<pid>/fd and /proc/<pid>/task) - the transient
FIN-WAIT-2 entries seen while the printer's TLS service is still booting
belong to the kernel's own connection teardown, not to processes held by
the bridge, and clear on their own.
Frees up the printer's own limited storage automatically once a print
finishes, while keeping the file safely in the bridge's own GCode store.
Deliberately scoped to files uploaded through the bridge itself only
(matched via the GCode store, same lookup the job-history feature already
uses) - a print started directly from the printer or Anycubic Slicer has
no backup anywhere else, so it's never touched regardless of the setting.
Only triggers on a clean "finished" state, not on stopped/canceled prints,
since the user may want to retry those.
Off by default. The delete request is fire-and-forget, sent directly from
_on_print() (which runs on the MQTT reader thread) rather than through the
existing _wait_for_file_action() helper - that helper blocks waiting for a
reply dispatched from that same thread, which would deadlock if called
from within it.
_parse_combined_rfid_type()/_match_profile_by_vendor_family() (added in an
earlier nightly) were only ever invoked inside _build_lane_data(), which is
only reached when OrcaSlicer actively polls the Moonraker lane_data endpoint.
The actual MQTT receive path (_on_multicolor_box -> self._ams_slots ->
_push_status_update -> dashboard, and the /kx/filament/slots dashboard API,
and Happy-Hare gate data) never touched this logic at all, so a combined
RFID string like "GEEETECH PLA Bas" kept showing up unmatched everywhere
except the one endpoint nobody was looking at - exactly what @Blaim's
extensive debugging in the issue thread demonstrated.
Centralized the matching into _effective_slot_profile() itself, since all
three real consumers already call it. This fixes the dashboard and gate
data with a single change instead of duplicating the matching logic per
caller (which is what caused the gap in the first place).
While centralizing, found and fixed a related edge case: the stale-profile
guard compared a manual override's material family directly against the
raw combined RFID string, which _material_family() can't parse past the
vendor prefix - a valid manual override on an RFID-tagged slot would have
been incorrectly treated as stale and dropped. Now resolves the plain
material family through the RFID parser first for that comparison.
Also added variant-token disambiguation to _match_profile_by_vendor_family()
per @Blaim's follow-up request: when a vendor has multiple profiles of the
same material family (e.g. "Geeetech PLA Basic" vs. "Geeetech PLA Matte"),
the RFID string's truncated third token ("Bas") is now scored against
candidate profile names instead of always picking the first match.
New tests cover the actual runtime path end-to-end (a realistic
multiColorBox/report payload through _on_multicolor_box, verified against
the /kx/filament/slots response the dashboard consumes) - the previous
test suite only exercised the helper functions and _build_lane_data() in
isolation with hand-set state, which is how this gap went unnoticed.
API.md documents the full HTTP/WebSocket surface (Moonraker-compatible
endpoints for Mainsail/Fluidd/OrcaSlicer/moonraker-obico compatibility,
plus the bridge-specific /api and /kx routes) for integrators and plugin
authors.
MANUAL.md is a task-oriented end-user guide covering day-to-day usage:
dashboard, printing, filament/AMS management, multi-printer setup, the
power-switch feature, settings reference, and basic troubleshooting.
Logs every incoming MQTT message on INFO regardless of topic, including
dedup'd duplicates and topics with no registered callback - useful for
capturing printer behavior the bridge doesn't normally surface without
needing to know the topic name in advance (the existing wildcard
subscribe already receives everything, this just makes it visible).
Issue #103: the printer has no MQTT-level command to power off or enter
standby, so users on a separate-room setup have to physically walk over
or use a smart plug (e.g. Tasmota) manually. Added per-printer
power_on_url/power_off_url/power_status_url config (Settings > Power
Switch) and a power button with a live on/off indicator on each printer's
card in the Printers grid - plain HTTP GET calls, no Moonraker
device_power dependency.
Issue #104: the stable-release update check only ever requested the
single newest Gitea release (limit=1) regardless of type. Since
nightly/dev prereleases publish far more often than stable ones, that
newest release is almost always a prerelease, so the "not a prerelease"
filter found nothing and reported "no stable releases found" even
though a newer stable release existed further back in the list.
Fixed by requesting enough releases (limit=20) to look past a run of
prereleases.
Also fixes a related pre-existing bug surfaced while testing #103's
config: configparser's default string interpolation rejected any
config.ini value containing a literal '%' (ValueError: invalid
interpolation syntax) - this broke saving Tasmota-style power URLs
(cmnd=Power%20on) and would have broken any other value with a '%'
character. Fixed globally with interpolation=None on every
ConfigParser() instantiation in config_loader.py and
kobrax_moonraker_bridge.py.
The bridge could get permanently stuck after a printer went offline and
came back, even with the printer confirmed reachable via ping/nc - only
a full container restart recovered it. Two compounding bugs:
1. Two independent code paths could trigger _reconnect() concurrently
with no coordination: the reader thread (on a failed keepalive ping)
and publish()/publish_web() (on a failed sendall(), which happens
constantly once the socket is dead, since the poll loop calls
query_info() every poll_interval). Both would race into their own
_do_connect(), each opening a competing TLS handshake against a
printer that likely only accepts one mTLS session at a time - so
neither converges, and the failure repeats every ~3s instead of
backing off. Added a lock so a second _reconnect() call waits for
the first to finish instead of starting a competing handshake.
2. publish() swallows send/reconnect failures internally and returns
None instead of raising - so _poll_loop's `if info: ...` branch was
silently skipped on failure, but the surrounding except-block (which
would have triggered the existing, correct offline/reconnect
transition) was never reached, since no exception was ever thrown.
The poll loop had no way to tell "printer sent nothing this tick"
apart from "the MQTT session is dead". Added client.is_connected()
and check it explicitly when query_info() returns falsy, routing a
dead session into the same clean offline branch already used for a
TCP-unreachable printer.
Verified: bridge continued printing normally throughout (live print
in progress on the real printer during this fix), full test suite
green (111 tests), new tests cover the concurrent-reconnect lock and
the poll-loop offline transition on a swallowed send failure.
server/files/metadata returned broken placeholders (size: 1,
estimated_time: null) for any file that wasn't uploaded through the
bridge's own GCode store - e.g. prints started directly from Anycubic
Slicer Next.
Verified live against a real Kobra X (see memory
reference_buried_report_trigger.md) that the printer sends a
previously-unused MQTT topic, buried/report, exactly once per print
start - fires identically whether the print was started via Anycubic
Slicer Next or via OrcaSlicer/the bridge itself. It carries gcode_size,
estimate_duration, and total_layers: precisely the fields the metadata
endpoint was missing.
Add _on_buried() (registered alongside the existing file/report
callback) that caches the single most recent buried/report payload.
_build_file_metadata() now tries this cache - matched by task_name -
as a third fallback, between the existing GCodeStore lookup and the
final size:1 hardcoded placeholder. Ordering is deliberate: live
tracked-job state and the file's own GCodeStore row (if the file was
uploaded through the bridge) still take priority; buried/report only
fills the gap for files the bridge has no other record of.
Also surfaces the printer's own storage usage (storage_total_mb/
storage_used_mb from the same payload) in /api/state, previously not
exposed anywhere in the bridge.
Verified end-to-end against the real printer: after a print start,
server/files/metadata for that file returned real size (9573908),
estimated_time (3203s), and layer_count (497) instead of the
placeholders, and /api/state reported real storage_total_mb/
storage_used_mb.
- Replace outdated YouTube tutorial link with the current video
- Add recently shipped features: custom RFID vendor matching, Spoolman
integration, multi-ACE support, on-printer GCode browser tab with
thumbnails, free-form dashboard grid, automatic camera reconnect
- Mention docker-compose-KX.yml (full stack: bridge + Spoolman +
self-hosted Obico) as an option alongside the plain docker compose setup
The printer's file/fileDetails MQTT action extracts and base64-encodes
the embedded "; thumbnail begin" block from a GCode file's header on
demand and returns it inline as data.file_details.thumbnail - verified
live against a real Kobra X (a valid 230x110 PNG came back for an
existing print file).
New endpoint GET /kx/printer-files/{filename}/thumbnail wraps this via
the existing _wait_for_file_action() helper (same fire-and-forget +
file/report-callback pattern as listLocal/deleteBatch). Results are
cached in-memory per filename (self._printer_thumbnail_cache) - a
file's thumbnail never changes while it exists on the printer, and the
tab can list 100+ files at once.
Frontend fetches thumbnails lazily through a small sequential queue
(_printerThumbQueue/_pumpPrinterThumbQueue) after rendering the file
list, one request at a time rather than firing 100+ concurrent MQTT
roundtrips, and swaps each card's placeholder printer icon for the
real <img> once its thumbnail arrives. Client-side cache
(_printerThumbCache) avoids re-fetching on re-render (e.g. after a
selection change).
Verified end-to-end against the real printer and visually via
Playwright: all 10 listed files rendered their actual, distinct print
preview thumbnails instead of the generic icon.
The GCode browser previously only showed files the bridge itself had
stored (its own SQLite GCodeStore, uploaded through the bridge). Files
printed directly via Anycubic Slicer Next (bypassing the bridge) land
on the printer's internal storage instead, and were only visible/
manageable from the printer's own display.
Adds a second sub-tab ("On Printer") using the same master-detail
tab pattern already used for Settings categories (showSettingsCat),
backed by the printer's file/listLocal and file/deleteBatch MQTT
actions - verified live against a real Kobra X, see memory
reference_mqtt_listlocal.md.
Key implementation detail: publish()'s own return value for these
actions is just a generic immediate ACK skeleton (code=0, empty
fields) - the real response arrives asynchronously via the file/report
callback (_on_file), same as the existing fileDetails fire-and-forget
pattern. Added _wait_for_file_action() as a small reusable bridge
between that async callback delivery and the synchronous HTTP handler,
via a per-action threading.Event registered in _on_file.
New endpoints: GET /kx/printer-files, POST /kx/printer-files/delete
(single endpoint for both single- and multi-select delete, since
deleteBatch natively accepts a filename list).
Frontend mirrors the existing store multi-select pattern (Issue #94):
select mode, select-all (scoped to what's loaded), bulk delete with
confirmation. No print/download actions in this tab for now - printing
a file already on the printer without re-uploading needs its own MQTT
schema that hasn't been verified yet.
Verified end-to-end against the real printer: listed 146 files,
deleted one, confirmed via a follow-up list that it was gone and
nothing else was affected. Also verified visually (Playwright):
tab switching, card rendering, multi-select mode, and cancel all work
as expected.
NIGHTLY_CHANGELOG.md was never cleared by either nightly.yml or
release.yml - it only grew across every push, so each nightly release
kept re-listing every entry since the last manual reset instead of
just what changed since the previous build.
Add a step after the Gitea nightly release is created that resets the
file to its empty header and pushes the reset commit to nightly. Not
part of the workflow's own push-trigger paths, so it doesn't
re-trigger a nightly build.
Three related gaps found via careful moonraker-obico observation:
1. _build_file_metadata() read layer_height/total_layers/estimated_time
from live self._state before falling back to the queried file's own
GCodeStore row - so querying metadata for any file other than the
currently/last tracked job leaked that job's values into the response.
Live state is now only used when the query targets that same tracked
file; any other filename relies solely on its own stored row.
2. curr_layer/total_layers were never reset at print end in either
_on_print or _on_info - only explicitly overwritten if a later
payload happened to carry those keys, otherwise stuck indefinitely.
Additionally, a successful "finished" print only ever cleared
file_ready (Issue #29's fix), while stoped/canceled reset every
other per-job field (progress, filename, duration, ...) - finished
now gets the same full reset. Found and fixed a knock-on bug this
surfaced: the unconditional `self._state["filename"] = d.get(...)`
right after the reset block would immediately undo the filename
reset, since the printer's own finished/stoped/canceled payload
still carries the just-ended job's filename - now only applied
outside terminal states.
3. The printer's own "progress" during pre-print phases (auto_leveling/
preheating/checking/updated/init) was forwarded as-is to
virtual_sdcard.progress/display_status.progress, causing a
non-monotonic jump-then-reset once real printing began. These phases
no longer update the tracked progress value.
Reported by @fmontagna, who correctly identified all three as genuine
gaps rather than intentional behavior.
Anycubic's ACE RFID system concatenates vendor + material + a truncated
serial into one `type` string for custom (third-party) RFID tags, e.g.
"GEEETECH PLA Bas" for a Geeetech PLA spool. The bridge previously
treated this whole string as an unknown material and fell back to a
neutral "Generic <type>" profile, even when the user had already
imported a matching OrcaSlicer profile via the ZIP import feature
(Issue #41) - forcing a manual per-slot reassignment every time that
spool was loaded. Anycubic Slicer Next resolves the same tag correctly.
Add two helpers next to the existing _normalize_material/_material_family:
- _parse_combined_rfid_type(): splits the raw type string, recognizes a
known vendor as the first token (checked against the merged
system+user filament library, so custom vendors like "Geeetech" that
only exist in the user's imported profiles are included), and
extracts the material family from the remainder via the existing
_material_family() prefix search. Returns ("", "") for a vendorless
string like plain "PLA", leaving normal spool reports untouched.
- _match_profile_by_vendor_family(): looks up an imported/system profile
by (vendor, family) rather than exact name, since the truncated RFID
string never contains the full profile name verbatim.
Wired into _build_lane_data() as a third resolution layer, after the
existing manual per-slot override and before the Generic-library
fallback - not persisted to config.ini, so it re-derives fresh on every
call and can't go stale if a differently-tagged spool is loaded later.
Two dashboard tile CSS issues reported after resizing tiles in the
GridStack-based dashboard:
1. Shrinking the Progress tile's height clipped the lower content
(time grid, filename, buttons) out of the visible area instead of
making it scale or scroll - #card-progress had no flex layout, so
its fixed-size children just overflowed silently under the generic
overflow:auto rule. Applied the same display:flex;flex-direction:
column pattern already used for #card-camera, with flex-shrink:0
on the direct children so the container can properly scroll instead
of hiding content off-screen.
2. Tiles could show a scrollbar even with nothing to scroll, from
sub-pixel horizontal overflow under the blanket
overflow-y:auto/overflow-x:hidden rule (was overflow:auto covering
both axes) - split it so only vertical scroll is offered, which is
the only axis dashboard content actually needs.
Verified visually via Playwright: resizing the progress tile to
h=2/h=4 no longer clips content, and a fan-card resize with
scrollHeight==clientHeight now shows no scrollbar.
The printer rotates its FLV/RTSP stream token on reboot. CameraCache.set_url()
was a bare assignment, so the three running ffmpeg loops never noticed - they
only re-read self._url at the top of their outer loop, which they never reach
while permanently blocked in a stdout read on the now-silent, stale-token
connection (TCP stays ESTABLISHED with no data and no FIN, so a passive
reader can't tell "peer is quiet" from "peer is gone").
Three-part fix, all per the excellent root-cause analysis and reproduction
in the issue (thanks @fmontagna):
1. set_url() now detects a URL change and calls reset() to tear down the
stale ffmpeg loops, so the next ensure_running() respawns them against
the new URL.
2. ffmpeg's -timeout option (10s, applies to both RTSP and HTTP-FLW since
both share _input_args) as a second line of defense for a source that
goes silent while the URL stays the same (network loss, camera hang).
3. handle_camera_stream now waits up to 5s for the first frame BEFORE
calling resp.prepare() and returns 503 on timeout. Previously it had no
first-frame timeout at all, and prepare() commits the response status
to 200 - so a stalled source could never surface as an error to the
client, only as an infinite hang.
The printer's failure payload for a rejected slot assignment carries
no slot/type/color info of its own, only an opaque
["multi_color_box", [{"filaments": {"id": N}, "id": box_id}]] shape -
useless for diagnosing why an assignment was rejected.
Remember the last setInfo request (_last_ams_set_request) and log it
alongside the failure so bridge logs alone can answer "what exact
type/color was sent, and to which slot" without asking the user to
dig it out manually or run extra reproduction steps.
The printer replies with state="failed" and data as a 2-element list
(["multi_color_box", [...]]) instead of the usual dict when it rejects
a manual ACE slot filament assignment (e.g. custom-RFID/third-party
material). _on_multicolor_box called data.get(...) unconditionally,
crashing with AttributeError and silently dropping the report -
including the regular slot-state update that would otherwise follow.
Add a state="failed" guard plus a defensive isinstance check, and
surface the rejection via _state["last_ams_set_error"] so the caller
of handle_api_ams_set_slot's optimistic cache update isn't left
showing a false success.
Note: this fixes the crash, not necessarily the underlying rejection
itself - the printer/ACE may still refuse assignments for material
types it doesn't recognize.
Printing via OrcaSlicer "Upload and Print" failed (or fed the wrong
spool) when a slot below the used filament was empty. Gap placeholders
in _build_auto_ams_box_mapping now point their ams_index at a loaded
tray instead of the gap's own empty index. All-slots-full path
unchanged.
PR Check / lint-and-test (pull_request) Failing after 1s
Printing via OrcaSlicer "Upload and print" failed or fed the wrong spool
when the slot below the used filament was empty (e.g. Filament 4 with slot
3 empty); all-slots-full worked.
_start_print -> _build_auto_ams_box_mapping keeps the AMS mapping positional
(entry N = TN) by inserting a placeholder at each gap. The placeholder's
ams_index pointed at the gap's own index, which for an empty slot references
a physically empty tray. The printer rejects a mapping entry aimed at an
empty tray even for a tool the GCode never calls, so the print broke.
Point gap placeholders (ams_index/color/material) at a definitely-loaded
fallback tray (the highest loaded slot) instead. Positional alignment is
preserved; the all-full path is unchanged.
Adds tests/test_auto_ams_box_mapping_empty_slot.py (invariant: every mapping
entry references a loaded/status-5 tray).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ace_direct mode (no toolhead buffer) only kept the first reported ACE
unit and silently dropped any others. Affects e.g. the Kobra S1 with two
ACE Pro units - the dashboard and OrcaSlicer sync only ever saw 4 of 8
slots. Global slot index is now box_id * 4 + local slot across all
units, matching the existing //4-%4 fallback in _global_to_box_slot.
Single-ACE (Kobra X) behavior is unchanged (unit id 0 -> same indices
as before).
feat(store): multi-select + bulk delete in the GCode browser (Issue #94)
Checkbox on every file card enters select mode; clicking anywhere on a
selected-mode card toggles it, existing per-file actions keep working via
stopPropagation. 'Select All' only affects the currently filtered/visible
files. Bulk delete is N parallel calls to the existing single-file DELETE
endpoint (no new backend route) with one confirmation dialog.
Manually integrated from PR #91 (Pavulon87) - the PR branch itself had
unresolved git conflict markers in handle_camera_stream from a stale
rebase against nightly, so it couldn't be merged directly.
/api/camera/stream previously spawned a dedicated ffmpeg process and
printer connection per HTTP client. The printer only tolerates a very
limited number of concurrent camera connections, so two simultaneous
viewers (dashboard + a Moonraker client, two browser tabs, ...) could
already exhaust that limit and cause intermittent 429/"stream
unavailable" failures.
Adds a third CameraCache fanout channel (mjpeg_subscribers), mirroring
the existing h264 pattern, so all /api/camera/stream consumers share one
ffmpeg process. Also carries over two related fixes found in the PR:
ensure_running() checked self._proc_* instead of self._task_*, leaving a
race window where two callers could each spawn a duplicate ffmpeg
process before the first task got scheduled; and reset() didn't cancel
the owning task, so a loop stuck in its exponential backoff sleep (up to
300s) wouldn't restart immediately.
The stale-slot-profile-guard fix (walterioo, merged as bf94a8f) was never
mentioned in a nightly release body — it landed between two changelog
overwrites.
Replace the fixed dashboard layout with a fully customizable GridStack.js
grid (12-col snap grid, vendored + inlined so it also works in OrcaSlicer's
embedded webview). Cards can be dragged, resized, hidden and rearranged;
layout persists per browser. Includes two built-in presets (Standard,
Wide desktop per the original Issue #89 proposal) plus the ability to
save/apply/delete named custom presets.
fix(camera): stream freeze after ~15-30min from non-monotonic FLV
timestamps — ffmpeg's realtime pacing stalls on PTS jumps in the printer's
stream. Fixed with -use_wallclock_as_timestamps on both ffmpeg call sites
(CameraCache._input_args, _run_h264_loop). Issue #90.
test: fix tests/conftest.py referencing a stale bridge/ subfolder path and
missing args.data_dir (pre-existing breakage, unrelated to this feature);
rewrite the two test_settings.py cases that still mocked the removed
_find_env_path from the old .env-based settings storage.
PR Check / lint-and-test (pull_request) Has been cancelled
Nightly Build / build (push) Successful in 14m49s
A per-slot filament profile override (config.ini [filament_profiles]) stores
only {vendor, name, id} and is sticky: swapping the physical filament updates
the AMS colour + type live, but the saved profile persisted. Loading yellow PLA
into a slot that held "KINGROON PETG Basic" kept showing/sending PETG in the
panel and the OrcaSlicer lane hint, and survived restarts (config.ini).
Resolve the effective profile per slot as the saved override only when its
material *family* still matches the loaded AMS material — PLA / PLA+ / PLA SILK /
PLA MATTE are one family, so within-family swaps never invalidate a valid
profile (guards against the earlier over-strict material compare). On a family
change the override is suppressed (slot falls back to the generic default) but
NOT deleted, so re-loading the original material reactivates it. Profile
material is resolved from the Orca filament library; unknown → never suppress.
Wired into the three resolution sites: handle_kx_filament_slots (panel),
_build_lane_data (OrcaSlicer AMS array), _build_mmu_object (gate_filament_name).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
start.sh now pulls gitea.it-drui.de/viewit/kx-bridge:latest or :nightly
depending on user choice (interactive prompt or ./start.sh <channel>
argument), instead of building the image locally from source.
_restart_bridge() cleared a hardcoded, manually-maintained list of env
keys before restarting — every setting added since (vibration_compensation,
host_ip, poll_interval, verbose_http_log) was missing from it. The old
process's env var therefore survived into the new process and silently
overrode the freshly written config.ini value, making toggles appear to
revert right after saving.
Fixed at the root: config_loader.CONFIG_ENV_MAPPING is now the single
source of truth for which env keys back which config.ini options, and
_restart_bridge() derives its cleanup list from it. A newly added
setting can't be forgotten here again.
Previous commit hard-disabled aiohttp's per-request access log via
setLevel(WARNING), with no way to turn it back on. Added a
'verbose_http_log' setting (default off) — toggle in Settings, persisted
to config.ini, applied on bridge start via _set_verbose_http_log().
Every HTTP request logged an INFO line via aiohttp's access logger,
drowning out the bridge's own logs given the frontend's 2s poll
interval. Raised the aiohttp.access logger threshold to WARNING.
/kx/spoolman/status only exposed 'configured' (server URL is set),
not actual reachability — health_check() ran once at boot and was
only logged, never surfaced to the API or rechecked afterwards.
Now the poll loop rechecks reachability every 30s, the status endpoint
returns 'reachable', and the frontend dot shows red + '(unreachable)'
when Spoolman is configured but not responding.
The setting was persisted to config.ini and returned by /api/settings,
but --poll-interval was never registered as an argparse argument and
POLL_INTERVAL was missing from config_loader's env mapping, so
self._args.poll_interval never existed. The poll loop used a hardcoded
3.0s wait regardless of the configured value.
- OctoPrint upload response was built twice in handle_file_upload
- gcode_filaments cache load failure on dashboard reprint now logs a
warning (silent failure would degrade the Issue #84 fix unnoticed)
- filament metadata backfill failures now log at debug level