Compare commits

..

76 Commits

Author SHA1 Message Date
6ab5c31035 feat(kxgauge): add KXGauge round-display integration
All checks were successful
Testing Build / build (push) Successful in 8m44s
Adds an optional per-printer integration with KXGauge
(https://gitea.it-drui.de/viewit/kxgauge), a small ESP32 round-face
display that shows printer status as an emotion and hotend temperature
as a color ring. KXGauge only exposes a GET-only HTTP API with no
push/websocket, so the bridge actively pushes to it from the existing
MQTT callbacks (_on_temp for the heat ring, _on_print + offline
transitions for the emotion) whenever state actually changes, with a
built-in dedupe so it doesn't spam the device every poll tick.

- kxgauge_client.py: thin synchronous HTTP client (mirrors
  spoolman_client.py's shape - called from the MQTT reader thread).
- New [kxgauge]/[kxgauge_mapping] config.ini sections; the mapping
  (kobra_state -> KXGauge emotion) is user-editable with a sane
  default and falls back per-key if only partially configured.
- Settings UI: new card under Integrations with enable/URL/target-temp
  fields, a per-state emotion mapping list, and a connection-test
  button (/api/kxgauge/test).
- Multi-printer aware: kxgauge_url/enabled/heat_peak merge per
  [printer_N] like the existing power-switch settings.

Also fixes a real bug found while testing this: _find_config_path()
resolves to the live project config/config.ini, not a sandboxed path,
so any test hitting /api/settings POST without stubbing it out will
silently overwrite the real printer config. test_settings.py already
guards against this - test_kxgauge.py now does too.
2026-08-05 15:13:43 +02:00
55acd323f4 fix(docker): copy all refactored bridge modules into the image
All checks were successful
Testing Build / build (push) Successful in 8m40s
The Dockerfile still only COPYed the pre-refactor module list, so the
testing image built from the split-module branch crashed immediately
with ModuleNotFoundError: No module named 'spoolman_client'. Add the
new bridge_*.py mixins plus spoolman_client/gcode_store/gcode_meta/
camera/credentials. Verified with a local docker build + run against
the real printer.
2026-08-05 10:25:34 +02:00
c22e0d0919 feat(update): treat the testing channel as docker-only in the update check
All checks were successful
Testing Build / build (push) Successful in 8m47s
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.
2026-08-04 21:22:34 +02:00
2e4dbf0da1 ci: add isolated testing-branch Docker build workflow
Adds .gitea/workflows/testing.yml, which builds and pushes only the
gitea.it-drui.de/viewit/kx-bridge:testing (+ :testing-<shortsha>) image on a
push to the `testing` branch.

Strictly isolated from nightly/master by construction:
- triggers only on push to `testing` (plus workflow_dispatch), no cron
- contains no git push / tag / release step, so it never writes back to any
  branch (unlike nightly.yml, which resets NIGHTLY_CHANGELOG.md back to
  nightly)
- uses its own :testing* image tags, never overwriting :nightly / :latest

Conversely, the existing workflows don't react to testing pushes:
nightly.yml is bound to the nightly branch, release.yml to v* tags,
pr-check.yml to PRs against nightly. So a testing push can't trigger any of
them either.
2026-08-04 21:16:21 +02:00
701ef0d516 refactor(bridge): drop now-unused imports from the facade (stage 3 cleanup)
After extracting everything into modules/mixins, several stdlib imports in
kobrax_moonraker_bridge.py (sqlite3, uuid, hashlib, tempfile, subprocess,
pathlib, html, urllib.parse.quote) are no longer referenced there - they
travelled with GCodeStore/credentials/camera/endpoints. Removed.

Facade is now 1315 lines (down from 6368). All 184 tests green; live-verified
against a real printer that every mixin's endpoints resolve and serve
correctly (kobra_state, AMS slots flowing end to end). PyInstaller spec needs
no change - all 12 new modules are statically importable from the facade.
2026-08-04 21:07:10 +02:00
f76c059fca refactor(bridge): extract EndpointsMixin (stage 3, mixin 5/6) 2026-08-04 20:25:25 +02:00
49c2fe5a7a refactor(bridge): extract MoonrakerCompatMixin (stage 3, mixin 4/6) 2026-08-04 20:20:57 +02:00
cda0e984ec refactor(bridge): extract AmsFilamentMixin (stage 3, mixin 3/6) 2026-08-04 20:18:20 +02:00
5311d74fd0 refactor(bridge): extract MqttCallbacksMixin + bridge_constants (stage 3, mixin 2/6) 2026-08-04 20:16:17 +02:00
1c18a85021 refactor(bridge): extract SpoolmanMixin (stage 3, mixin 1/6) 2026-08-04 20:10:48 +02:00
ebb48aac82 refactor(bridge): extract printer credential fetch/decrypt into credentials.py (stage 2)
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.
2026-08-04 19:43:25 +02:00
cdf11f6bfe refactor(bridge): extract self-contained classes/helpers into modules (stage 1)
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.
2026-08-04 19:41:17 +02:00
2b2d5ee0a7 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 15:31:57 +02:00
ce670f5f93 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 15:31:46 +02:00
5eeed97514 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 15:31:36 +02:00
ce71299896 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 15:31:24 +02:00
7e33cc9eda fix(mqtt): detect a dead printer connection promptly instead of hanging offline detection forever
All checks were successful
Nightly Build / build (push) Successful in 6m38s
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.
2026-08-04 14:44:51 +02:00
5a44d0abab feat(print): add option to delete file from printer after successful print
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.
2026-08-03 13:12:47 +02:00
23e3831232 fix(filament): wire ACE-RFID auto-matching into the real MQTT status path (Issue #101)
_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.
2026-08-03 12:33:47 +02:00
f54783ad16 docs: add API.md and MANUAL.md, link them from README
All checks were successful
Nightly Build / build (push) Successful in 6m39s
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.
2026-08-02 23:02:46 +02:00
gitea-actions
b37dfb4dcf chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.30-nightly7 release 2026-08-02 14:46:13 +00:00
52baaa8f70 feat(debug): add MQTT_RAW_LOG env switch for unfiltered RX logging
All checks were successful
Nightly Build / build (push) Successful in 15m27s
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).
2026-08-02 15:59:41 +02:00
ecc53cd7cb feat(printer): add smart-plug power switch button (Issue #103); fix(update): stable update check missing behind prereleases (Issue #104)
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.
2026-08-02 15:59:35 +02:00
0a9bf6def6 fix(mqtt): resolve reconnect deadlock after printer disconnect (Issue #105)
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.
2026-08-02 13:15:23 +02:00
36cb97038f feat(moonraker): use buried/report as size/duration/layer fallback (Issue #102)
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.
2026-08-02 13:03:11 +02:00
157517ffb4 docs: update README video link and feature list
All checks were successful
Nightly Build / build (push) Successful in 6m31s
- 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
2026-07-27 20:31:32 +02:00
71664e7e8b docs: translate docker-compose-KX.yml comments to English 2026-07-27 20:18:44 +02:00
gitea-actions
c4f321c9b0 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly48 release 2026-07-27 12:33:09 +00:00
1f1d60d571 feat(browser): show real GCode thumbnails in the "On Printer" tab
All checks were successful
Nightly Build / build (push) Successful in 6m53s
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.
2026-07-27 14:25:50 +02:00
gitea-actions
9f76d28622 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly47 release 2026-07-27 12:13:12 +00:00
cbcb17f45a feat(browser): add second tab for files on the printer's own storage
All checks were successful
Nightly Build / build (push) Successful in 6m50s
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.
2026-07-27 14:04:22 +02:00
gitea-actions
4e7f851799 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.28-nightly46 release 2026-07-27 02:07:48 +00:00
1d5ac8dc4e chore(ci): reset NIGHTLY_CHANGELOG.md after each nightly release
All checks were successful
Nightly Build / build (push) Successful in 7m25s
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.
2026-07-27 00:18:44 +02:00
64c8bc32d7 README.de.md aktualisiert 2026-07-27 00:09:15 +02:00
0ca7618c85 fix(moonraker): metadata state-leak + terminal-state reset gaps (Issue #102)
All checks were successful
Nightly Build / build (push) Successful in 7m9s
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.
2026-07-26 23:29:32 +02:00
6d6df59ff2 feat(filament): auto-match combined ACE-RFID vendor+type strings (Issue #101)
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.
2026-07-26 23:19:36 +02:00
33b42e64cc fix(dashboard): tile resize clipping and phantom scrollbars (Issue #97)
All checks were successful
Nightly Build / build (push) Successful in 7m14s
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.
2026-07-24 15:13:19 +02:00
16c1a8ee73 fix(camera): recover automatically after printer reboot rotates stream token (Issue #99)
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.
2026-07-24 14:10:54 +02:00
884bdfc0c3 fix(ams): log rejected multiColorBox setInfo with the triggering request
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.
2026-07-24 14:01:04 +02:00
8f14580e30 fix(ams): don't crash on rejected multiColorBox setInfo (Issue #100)
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.
2026-07-24 13:57:17 +02:00
20daa6b6b8 Merge PR #98: fix(ams) don't map AMS placeholders to empty trays
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.
2026-07-24 13:48:08 +02:00
Walter Almada B
2f222a0b93 fix(ams): point gap placeholders at a loaded tray, not an empty one
Some checks failed
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>
2026-07-22 18:31:10 -07:00
6e72346129 fix(ams): support multiple daisy-chained ACE units (Issue #95)
All checks were successful
Nightly Build / build (push) Successful in 7m17s
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.
2026-07-19 18:44:46 +02:00
99bc1797c8 fix(camera): share one ffmpeg connection for /api/camera/stream
All checks were successful
Nightly Build / build (push) Successful in 9m34s
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.
2026-07-16 22:33:50 +02:00
e1b9480098 Merge branch 'pr92' into nightly
Some checks failed
Nightly Build / build (push) Has been cancelled
2026-07-16 21:37:09 +02:00
Pavulon87
03089db6af fix: self._db did not exist, use self._store
Some checks failed
Nightly Build / build (push) Has been cancelled
PR Check / lint-and-test (pull_request) Has been cancelled
2026-07-16 14:48:23 +02:00
Pavulon87
cd4a8ce48e feat(dashboard): show printer pause reason on the dashboard
Some checks failed
PR Check / lint-and-test (pull_request) Failing after 2s
2026-07-16 14:42:48 +02:00
bf3f043888 chore: add missing PR #88 entry to nightly changelog
The stale-slot-profile-guard fix (walterioo, merged as bf94a8f) was never
mentioned in a nightly release body — it landed between two changelog
overwrites.
2026-07-16 11:36:09 +02:00
b9594d4a22 feat(dashboard): free drag+resize grid with custom presets (Issue #89)
All checks were successful
Nightly Build / build (push) Successful in 13m35s
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.
2026-07-16 11:34:34 +02:00
Walter Almada B
bf94a8f563 fix(filament): suppress stale slot profile when AMS material family changes
Some checks failed
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>
2026-07-08 23:09:16 -07:00
eda14db897 feat: pull image from registry instead of local build, ask stable/nightly
All checks were successful
Nightly Build / build (push) Successful in 13m34s
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.
2026-07-07 15:11:19 +02:00
dcf852db49 chore: update nightly changelog
All checks were successful
Nightly Build / build (push) Successful in 9m59s
2026-07-07 01:26:29 +02:00
d2c92c2deb fix(settings): stale env vars survived bridge restart, reverting saved changes
_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.
2026-07-07 01:23:01 +02:00
b541aafc74 feat(settings): make the HTTP access log toggleable in the UI
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().
2026-07-07 01:20:34 +02:00
f2f4447809 chore: quiet aiohttp per-request access 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.
2026-07-07 01:12:30 +02:00
a3deb33b97 fix(spoolman): status dot showed green when Spoolman was unreachable
/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.
2026-07-07 01:08:41 +02:00
2e2061a269 fix(settings): poll_interval was saved but never applied
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.
2026-07-07 01:04:44 +02:00
e4bf2c9b95 chore: nightly changelog
All checks were successful
Nightly Build / build (push) Successful in 16m2s
2026-07-07 00:13:34 +02:00
aea6e457f3 refactor: dedupe upload response, log user-relevant swallowed errors
- 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
2026-07-06 22:39:09 +02:00
0ae8ae59be refactor: extract shared print payload builder and skip-state reset
The print/start payload was duplicated in three places (upload path,
KX store, Moonraker API) and had drifted: the settings-based
vibration_compensation value was missing in the upload path. All three
paths now use _build_print_payload() and _reset_skip_state().
2026-07-06 22:35:56 +02:00
0322ade606 refactor: translate all logs, comments and API error strings to English
Logs are user-facing across all locales; comments and docstrings switch
to English for external contributors. No behavior change.
2026-07-06 22:33:25 +02:00
786fa08ca0 fix: dashboard reprint slot-shift, startup IP log, resonance compensation toggle
Some checks failed
Nightly Build / build (push) Failing after 1m5s
- Dashboard reprint now delegates to _start_print with gcode_filaments from DB
  so the used_paint_indices filter applies correctly (Issue #84)
- Startup log no longer shows 0.0.0.0 — actual LAN IP is displayed (Issue #86)
- New vibration_compensation setting: toggle in Settings UI activates resonance
  compensation before each print, follows exact auto_leveling pattern (Issue #85)
2026-07-06 13:54:44 +02:00
cd11542352 fix(filament): PLA-Varianten (PLA+, Silk, Matte) korrekt erkennen und an OrcaSlicer übermitteln
All checks were successful
Nightly Build / build (push) Successful in 12m36s
- _normalize_material() normalisiert Drucker-Typen auf kanonische Keys
- _TRAY_INFO_IDX erweitert um Silk/Matte/CF-Varianten und Schreibweisen
- _default_filament_name() mappt Varianten auf korrekte Generic-Profile
- Filament-Dropdown zeigt Hersteller-Profile der jeweiligen Variante
- Material-Buttons: PLA+, PLA Silk, PLA Matte hinzugefügt

Fixes #82
2026-07-02 21:54:42 +02:00
51f22947c5 Merge pull request #83: fix(spoolman): repair dead slot-map persistence + isolate it per printer
Some checks failed
Nightly Build / build (push) Has been cancelled
2026-07-02 21:36:59 +02:00
Walter Almada B
a39226d2dd fix(spoolman): show vendor name in the spool dropdown (was "[object Object]")
Some checks failed
PR Check / lint-and-test (pull_request) Has been cancelled
The print-dialog spool dropdown built its option label from
sp.filament.vendor (the whole vendor object) instead of
sp.filament.vendor.name, so options rendered as "#5 [object Object] PLA+
(1000g)". The sibling builder in the slot card already uses .vendor.name;
this aligns the two.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:21:59 -07:00
Walter Almada B
2a13f1f0dd fix(spoolman): repair dead slot-map persistence + isolate it per printer
The AMS-slot -> Spoolman-spool persistence never worked: KobraXBridge
referenced `config_loader` in both the load (__init__) and save
(handle_kx_spoolman_set_active) paths, but the module alias is `env_loader`
(kobrax_moonraker_bridge.py:32). The resulting NameError was swallowed by a
bare `except`, so the map was neither loaded on startup nor written on change
- it only appeared to persist.

The map also lived in a single global `[spoolman] slot_spools` key, so on a
multi-printer bridge two AMS units clobbered each other's mapping (same class
of bug as #74/#75 for filament profiles).

- config_loader: add list_spool_map()/save_spool_map(printer_id) using a
  per-printer `[spoolman_<id>]` section with read-fallback to the legacy
  global key, mirroring _filament_section/list_filament_profiles. The global
  `[spoolman]` section keeps server/sync_rate.
- bridge: load via config_loader.list_spool_map(self._printer_id); persist via
  save_spool_map(..., self._printer_id); surface failures via log.warning
  instead of a silent except.
- _build_mmu_object: emit real gate_spool_id from the per-printer map (was
  hardcoded [-1]*num_gates) so Happy-Hare/OrcaSlicer can show the bound spool.
- config.ini.example: document the [spoolman] section.
- tests: tests/test_spoolman_slot_map.py (per-printer isolation, persistence
  round-trip, server/sync_rate preservation, parser robustness).

Verified on a 2-printer bridge: after restart KX1 loads its spools and KX2
loads its own, isolated; a real multicolor print deducted per slot (white spool
1.02g vs 0.98g slicer estimate) against the correct printer's spools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:42:40 -07:00
a16062f44f fix(ams): ams_box_mapping mit Platzhaltern für fehlende Paint-Indizes auffüllen
All checks were successful
Nightly Build / build (push) Successful in 11m17s
Drucker interpretiert ams_box_mapping als geordnete Liste (Eintrag N = TN).
Bei Drucken die T0 nicht nutzen wurden die Einträge um 1 verschoben,
sodass T2 (rot) auf den Slot von T3 (weiß) zeigte.

Fixes #78
2026-07-01 20:51:12 +02:00
4f5aa8d126 Revert "fix(ams): paint_index im auto-mapping auf global_index setzen statt enumerate-Zähler"
All checks were successful
Nightly Build / build (push) Successful in 10m12s
This reverts commit c313e014ad.
2026-06-30 23:01:03 +02:00
c313e014ad fix(ams): paint_index im auto-mapping auf global_index setzen statt enumerate-Zähler
Some checks failed
Nightly Build / build (push) Has been cancelled
Bei Multicolor-Drucken mit nicht bei 0 startenden Paint-Indizes (T2, T3...)
wurde paint_index als 0,1,2... statt als tatsächlicher GCode-T-Index gesendet.
Drucker hat dadurch die falschen Slots für die falschen Farben verwendet.

Fixes #78
2026-06-30 22:56:34 +02:00
6e9ba0672f fix(spoolman): Slot-Spool-Zuordnung in config.ini persistieren + beim Start laden; API-Feldname-Kompatibilität (slot_spools/slot_map)
All checks were successful
Nightly Build / build (push) Successful in 10m48s
2026-06-30 15:43:20 +02:00
44383fabec fix(docker): gcc + python3-dev für pycryptodome arm/v7 Kompilierung
All checks were successful
Nightly Build / build (push) Successful in 12m26s
2026-06-30 14:30:10 +02:00
48bec55611 feat(ci): linux/arm/v7 Platform zu Docker-Build hinzugefügt (Raspberry Pi 2/3 32-bit)
Some checks failed
Nightly Build / build (push) Failing after 4m53s
2026-06-30 12:21:20 +02:00
ab44e234be fix(ui): AMS-Spool-Dropdown bleibt offen während Poll-Tick (kein innerHTML-Reset bei fokussiertem Select)
Some checks failed
Nightly Build / build (push) Has been cancelled
2026-06-30 12:17:03 +02:00
74fc2ddab0 feat: color picker, unified UI styling, filament mismatch detection, Spoolman slot assignment
All checks were successful
Nightly Build / build (push) Successful in 4m27s
- Slot color editor: Pickr HSV color picker (offline, served from lib/),
  recent swatches (up to 16, localStorage), copy color from other slot
- Unified axes control panel: XY+Z merged, shared step size + custom mm input
- Language selector moved from header to Settings → Appearance
- Filament mismatch detection blocks Upload-and-Print on material mismatch,
  slot mapper opens automatically
- Spoolman spool-per-slot assignment in AMS status tab and Filaments settings
- Fix: Spoolman sync rate label — 0=end of print, not disabled (Issue #76)
- Fix: lib/ assets served by bridge static handler for offline use
- UI: global unified select + input styling, set-row labels match modal-field
2026-06-30 11:13:34 +02:00
771599be0c Merge pull request 'fix: isolate filament profiles per printer in multi-printer bridge' (#75) from walterioo/KX-Bridge-Release:fix/per-printer-filament-profiles into nightly
All checks were successful
Nightly Build / build (push) Successful in 4m21s
2026-06-30 10:21:56 +02:00
0e1d46ee7f fix: isolate filament profiles per printer in multi-printer bridge (#74)
Some checks failed
PR Check / lint-and-test (pull_request) Has been cancelled
Per-printer [filament_profiles_<id>] sections so configuring one printer no
longer overwrites another (read-fallback to the legacy global section keeps
single-printer setups unchanged). Dropdown/switch links now navigate to each
printer's own bridge_url. Adds pytest coverage and a CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:13:10 +02:00
71 changed files with 11818 additions and 4983 deletions

View File

@@ -94,7 +94,7 @@ jobs:
# VERSION-Datei im Arbeitsverzeichnis für den Docker-Build setzen (kein Commit)
echo "$VERSION" > VERSION
docker buildx build \
--platform linux/amd64,linux/arm64 \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push \
--provenance=false \
--no-cache \
@@ -174,3 +174,20 @@ jobs:
--data-binary @/tmp/release_body.json \
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases"
rm -f "$BODY_FILE" /tmp/release_body.json
- name: Reset NIGHTLY_CHANGELOG.md for the next build
env:
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
. /tmp/nightly_version.env
# The changelog just consumed above must not carry over into the
# next nightly - otherwise every build re-lists all prior entries
# since the last manual reset instead of just what's new. Not in
# nightly.yml's own push-trigger paths, so this commit does not
# re-trigger this workflow.
printf '## Changes in this build\n\n' > NIGHTLY_CHANGELOG.md
git config user.name "gitea-actions"
git config user.email "actions@gitea.it-drui.de"
git add NIGHTLY_CHANGELOG.md
git commit -m "chore: reset NIGHTLY_CHANGELOG.md after nightly-${VERSION} release" || exit 0
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git HEAD:nightly

View File

@@ -61,7 +61,7 @@ jobs:
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push \
--provenance=false \
--no-cache \

View File

@@ -0,0 +1,96 @@
name: Testing Build
# Isolierter Test-Kanal: baut ausschließlich das Docker-Image
# gitea.it-drui.de/viewit/kx-bridge:testing (+ :testing-<shortsha>).
#
# Bewusst KEIN Gitea-Release, KEIN Tag, KEIN Rück-Push in irgendeinen Branch -
# damit ein Push nach `testing` niemals nightly oder master berührt (und
# umgekehrt: nightly.yml/release.yml/pr-check.yml triggern nicht auf
# `testing`-Pushes, da sie an nightly / v*-Tags / PRs-gegen-nightly gebunden
# sind). Der Workflow schreibt nie ins Repo zurück.
on:
push:
branches:
- testing
paths:
- '**.py'
- 'Dockerfile'
- 'requirements.txt'
- 'web/**'
- 'data/**'
- '.gitea/workflows/testing.yml'
workflow_dispatch:
jobs:
build:
runs-on: server-runner
steps:
- name: Checkout
run: |
if [ -d .git ]; then
git fetch origin testing
git reset --hard origin/testing
git clean -fd
else
git clone --branch testing https://gitea.it-drui.de/viewit/KX-Bridge-Release.git .
fi
- name: Install Docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
DARCH="x86_64"
BARCH="amd64"
else
DARCH="aarch64"
BARCH="arm64"
fi
wget -qO- "https://download.docker.com/linux/static/stable/${DARCH}/docker-27.5.1.tgz" \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
chmod +x /usr/local/bin/docker
mkdir -p /usr/local/lib/docker/cli-plugins
wget -qO /usr/local/lib/docker/cli-plugins/docker-buildx \
"https://github.com/docker/buildx/releases/download/v0.23.0/buildx-v0.23.0.linux-${BARCH}"
chmod +x /usr/local/lib/docker/cli-plugins/docker-buildx
fi
docker version --format '{{.Client.Version}}'
- name: Set up QEMU
run: |
docker run --rm --privileged tonistiigi/binfmt:latest --install all
- name: Set up buildx
run: |
docker buildx inspect kxbuilder 2>/dev/null || \
docker buildx create --name kxbuilder --use
docker buildx use kxbuilder
- name: Login to Gitea registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | \
docker login gitea.it-drui.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Compute testing version
run: |
# Reiner Commit-SHA-Suffix - keine Tag-Zähllogik, keine Stable-Tag-
# Abhängigkeit, kein Commit. Nur die VERSION-Datei im Arbeitsverzeichnis.
VERSION="testing-$(git rev-parse --short HEAD)"
echo "VERSION=${VERSION}" > /tmp/testing_version.env
echo "Computed testing version: ${VERSION}"
- name: Build & push (amd64 + arm64)
run: |
. /tmp/testing_version.env
# VERSION-Datei nur im Arbeitsverzeichnis für den Docker-Build setzen
# (KEIN Commit, KEIN Push).
echo "$VERSION" > VERSION
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--push \
--provenance=false \
--no-cache \
-t "gitea.it-drui.de/viewit/kx-bridge:testing" \
-t "gitea.it-drui.de/viewit/kx-bridge:${VERSION}" \
.

256
API.md Normal file
View File

@@ -0,0 +1,256 @@
# KX-Bridge HTTP API Reference
This document lists the HTTP and WebSocket surface exposed by
`kobrax_moonraker_bridge.py`. It is a reference for integrators and
plugin authors, not a tutorial — for day-to-day usage of the bridge see
[MANUAL.md](MANUAL.md), and for setup see [README.md](README.md).
The API has two distinct parts:
1. **Moonraker-compatible surface** — a subset of the real
[Moonraker](https://moonraker.readthedocs.io/) HTTP + WebSocket API,
implemented just far enough to make Mainsail, Fluidd, OrcaSlicer, and
`moonraker-obico` work against the Kobra X. **This is not a full
Moonraker implementation** — many real Moonraker endpoints/methods do
not exist here, and some responses are static stubs that exist only
to stop a client from erroring/looping (noted below).
2. **Bridge-specific surface**`/api/...` and `/kx/...` endpoints for
things Moonraker has no concept of: multi-printer management, AMS/ACE
filament control, the GCode store, custom filament profile import,
Spoolman, and the smart-plug power switch.
## Security
**There is no authentication on any endpoint.** The bridge is designed
to be run on a trusted local network only. Do not expose port `7125`
(or any additional per-printer port) to the internet — anyone who can
reach the port can control the printer, read/delete files, and read
`/kx/printers` credentials indirectly through bridge behavior. `/access/api_key`
returns a hardcoded dummy value purely so `moonraker-obico` doesn't warn;
it is not a real credential.
CORS is enabled (`_json_cors` / `handle_kx_options` add
`Access-Control-Allow-*` headers and answer `OPTIONS` with 204) so the
Web UI can call sibling bridge instances directly in multi-printer setups.
---
## Moonraker-compatible endpoints (HTTP)
All responses follow Moonraker's `{"result": {...}}` envelope unless noted.
| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | `/server/info` | Server/klippy status | Always reports `klippy_connected: true`, `klippy_state: "ready"` |
| GET | `/printer/info` | Printer identity | Static hostname/paths; `software_version` from `KLIPPER_VERSION` |
| GET | `/machine/system_info` | System info stub | Mostly static/placeholder fields |
| GET | `/printer/objects/list` | List available printer objects | Keys of `_build_printer_objects()` |
| GET | `/printer/objects/query?objects=...` | Query object status | Comma-separated `objects` query param, or bare query keys |
| GET`/POST` | `/printer/objects/subscribe` | Subscribe (HTTP polling variant) | Returns full status snapshot immediately |
| GET | `/server/files/list` | List gcode files | Only returns the single currently-tracked file (if any) |
| GET | `/server/files/metadata?filename=...` | File metadata (layers, est. time, etc.) | Shared logic with WS `server.files.metadata`; falls back to GCode store / buried-report cache |
| POST | `/server/files/upload` | Upload a gcode file (multipart) | Same handler as `/api/files/local` |
| POST | `/printer/print/start?filename=...` | Start a print | Body may include `filament_assignments`, `excluded_objects`, `auto_leveling` |
| POST | `/printer/print/pause` | Pause current print | |
| POST | `/printer/print/resume` | Resume current print | |
| POST | `/printer/print/cancel` | Cancel current print | |
| GET | `/access/api_key` | Dummy API key | No real auth exists |
| GET | `/machine/update/status` | Update-manager stub | Always `busy: false`, empty `version_info` |
| GET | `/server/history/list?limit=` | Print job history | Backed by the bridge's own GCodeStore/job DB |
| GET | `/server/webcams/list` | Webcam descriptor | Rewrites `localhost`/`127.0.0.1` Host header to the bridge's LAN IP so remote Obico/Mainsail instances get a reachable URL |
| POST | `/printer/gcode/script` | Execute a (very limited) gcode command | See `_exec_gcode_script`; not a general gcode interpreter |
| GET | `/server/database/item?namespace=&key=` | Moonraker "database" KV read | Real payload only for `lane_data` (AMS/filament sync for OrcaSlicer); stub/empty responses for `AFC`, `afc-install`, `happy_hare`, `mainsail`; in-memory KV for `obico` |
| POST | `/server/database/item` | Moonraker "database" KV write | In-memory only (not persisted across restarts); used by `moonraker-obico` for its own settings |
| GET | `/server/database/list` | List KV namespaces | Static: `["lane_data", "mainsail", "obico"]` |
### OctoPrint-compatibility shim
| Method | Path | Purpose |
|---|---|---|
| GET | `/api/version` | OctoPrint-style version probe (some tools check this instead of Moonraker) |
| POST | `/api/files/local`, `/api/files/{path}` | Alias for the same multipart upload handler as `/server/files/upload` |
### WebSocket JSON-RPC (`/websocket`)
Moonraker's JSON-RPC 2.0 protocol over a single `/websocket` endpoint. On
connect the bridge immediately pushes `notify_klippy_ready` and then
periodic `notify_status_update` notifications. Supported `method` values:
| Method | Purpose |
|---|---|
| `printer.info` / `printer_info` | Same payload as `/printer/info` |
| `server.info` / `server_info` | Same payload as `/server/info` |
| `printer.objects.list` | Same as HTTP equivalent |
| `printer.objects.query` / `printer.objects.get` | Object status by requested keys |
| `printer.objects.subscribe` | Returns a status snapshot (no real push subscription semantics — status pushes happen automatically via `notify_status_update`) |
| `printer.print.start` | `params.filename` |
| `printer.print.pause` / `.resume` / `.cancel` | |
| `machine.system_info` | Minimal stub |
| `server.files.list` | Always returns `[]` over WS (unlike the HTTP version) |
| `printer.gcode.script` | `params.script`, same limited executor as the HTTP endpoint |
| `server.connection.identify` | Returns a dummy `connection_id` for Obico's handshake |
| `connection.register_remote_method` | Accepted and ignored (Obico registers a remote-event callback) |
| `server.webcams.list` | Same shape as HTTP, using the bridge's own LAN IP |
| `server.history.list` | Job history, same source as `/server/history/list` |
| `machine.update.status` | Stub |
| `server.files.metadata` | Same logic as `/server/files/metadata` |
Any other method is logged and answered with an empty `result: {}` — it
does not error, to avoid breaking clients that probe for optional
methods.
---
## Bridge-specific endpoints
All `/kx/...` (and most `/api/...`) responses use `{"result": ...}` on
success and `{"error": "..."}` with a 4xx/5xx status on failure, except
where noted.
### Printer control (`/api/...`)
| Method | Path | Purpose | Body / Query |
|---|---|---|---|
| POST | `/api/light` | Toggle chamber light | `{on, brightness}` |
| POST | `/api/fan` | Set part-cooling fan speed | `{speed}` (0100) |
| POST | `/api/connect` | Manually (re)connect the MQTT client | — |
| POST | `/api/disconnect` | Manually disconnect | — |
| POST | `/api/restart` | Restart the bridge process | — |
| POST | `/api/speed` | Set print speed mode | `{mode}` (int) |
| POST | `/api/axis` | Jog an axis, or `{"action":"turnoff"}` to disable steppers | `{axis, move_type, distance}` |
| POST | `/api/temperature` | Set nozzle/bed target temps | `{nozzle?, bed?}`; uses a different MQTT path mid-print vs. idle |
| GET | `/api/state` | Full dashboard status snapshot | Primary polling endpoint used by the Web UI |
| GET | `/api/camera` | Current camera stream URL | |
| GET | `/api/camera/stream` | MJPEG live view | `multipart/x-mixed-replace`, fed from a shared ffmpeg fanout |
| GET | `/api/camera/h264` | Raw H.264 stream (for Obico) | |
| GET | `/api/camera/snapshot` | Last cached JPEG frame | Instant, served from RAM |
| POST | `/api/camera/start` / `/api/camera/stop` / `/api/camera/reset` | Camera lifecycle control | `reset` clears the 429 backoff and restarts ffmpeg |
| GET | `/api/settings` | Read current config.ini-backed settings | |
| POST | `/api/settings` | Write settings, then restart the bridge | See config fields below |
| GET | `/api/update/check` | Check Gitea releases for a newer version | Branches on nightly/dev/stable channel |
| POST | `/api/update/apply` | Self-update (non-Docker builds only) | `{tag}` |
| POST | `/api/file_ready/clear` | Dismiss the "file ready to print" banner/dialog state | |
| GET | `/api/log/stream` | Server-Sent Events log tail | |
| GET | `/api/log/download` | Download buffered log as plaintext | |
| GET | `/serve/{filename}` | Internal file server used to hand the printer a URL to fetch gcode from | Not meant for direct browser use |
**`/api/settings` fields** (POST body, all optional — merges into
existing config.ini): `printer_ip`, `mqtt_port`, `username`, `password`,
`mode_id`, `device_id`, `power_on_url`, `power_off_url`,
`power_status_url`, `default_ams_slot`, `auto_leveling`,
`vibration_compensation`, `camera_on_print`, `web_upload_warning`,
`print_start_dialog`, `poll_interval`, `verbose_http_log`,
`printer_name`, `spoolman_server`, `spoolman_sync_rate`,
`ace_dry_presets`.
### AMS / ACE filament control (`/api/...`)
| Method | Path | Purpose | Body |
|---|---|---|---|
| POST | `/api/ams/set_slot` | Set material type + color for a slot | `{index, type, color:[r,g,b]}` |
| POST | `/api/ams/feed` | Feed filament in/out | `{slot_index, type}` (1=feed in, 2=feed out) |
| POST | `/api/ace/auto_feed` | Toggle auto-feed for an ACE unit | `{ace_id, on}` |
| POST | `/api/ace/dry` | Start/stop the ACE dryer | `{action: "start"|"stop", ace_id?, target_temp?, duration?}` |
### GCode store (bridge-managed uploads) (`/kx/files...`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/kx/files` | List files the bridge has stored, with last-print status/duration |
| DELETE | `/kx/files/{file_id}` | Delete a stored file |
| GET | `/kx/files/{file_id}/download` | Download a stored file |
| POST | `/kx/files/{file_id}/verify` | Clear the "web upload, unverified" flag |
| GET | `/kx/files/{id}/objects` | Print-object list + SVG preview (for the pre-print skip feature) |
| GET | `/kx/history?limit=&offset=` | Paginated print job history |
### Files on the printer's own storage (`/kx/printer-files...`)
Distinct from the GCode store above — these list/manage files that live
on the printer's internal storage (e.g. printed directly from Anycubic
Slicer Next, bypassing the bridge).
| Method | Path | Purpose |
|---|---|---|
| GET | `/kx/printer-files` | List files via the printer's `file/listLocal` MQTT action |
| POST | `/kx/printer-files/delete` | Delete one or more files: `{"filenames": [...]}` (single endpoint for single + bulk delete) |
| GET | `/kx/printer-files/{filename}/thumbnail` | Fetch (and cache) a file's embedded gcode thumbnail via `file/fileDetails` |
### Printing (`/kx/print`)
| Method | Path | Purpose | Body |
|---|---|---|---|
| POST | `/kx/print` | Start a print from a stored GCode-store file | `{file_id, filament_assignments?, excluded_objects?, auto_leveling?}` |
`filament_assignments` is `[{slot_index, material, color_hex}, ...]`; if
omitted, all currently-occupied AMS slots are auto-mapped.
### Pre-print / mid-print object skip (`/kx/skip...`)
| Method | Path | Purpose |
|---|---|---|
| POST | `/kx/skip` | Skip named objects mid-print: `{"names": [...]}` |
| POST | `/kx/skip/query` | Re-request the object list from the printer and return merged skip state |
| GET | `/kx/skip/state` | Current skip state (object list, already-skipped names, SVG, filename) |
### Filament profiles (`/kx/filament/...`)
| Method | Path | Purpose | Body / Query |
|---|---|---|---|
| GET | `/kx/filament/slots` | Current AMS slot contents + any user profile override | |
| GET | `/kx/filament/profiles?type=&vendor=` | Curated OrcaSlicer filament profile catalog (system + user-imported) | Optional filters |
| GET | `/kx/filament/profiles/user` | User-imported profiles only (for the settings management list) | |
| POST | `/kx/filament/profiles/user` | Import profiles from a ZIP or `.json` file(s) (multipart) | Multipart field `file`/`files`/`upload`; ZIP entries or bare `.json`, parsed via `orca_filaments.parse_profile_bytes` |
| DELETE | `/kx/filament/profiles/user?vendor=&name=` | Delete one user profile (both params) or all (no params) | |
| POST | `/kx/filament/slots/{idx}/profile` | Assign (or clear) a fixed profile override for one AMS slot | `{vendor, name}`; empty strings clear the mapping. Selector is `(vendor, name)`, not `id` — IDs are not unique across the Orca profile catalog |
| GET`/POST` | `/kx/filament/visible_vendors` | Get/set the vendor visibility filter for the slot profile dropdown | POST body `{"vendors": [...]}`; empty list = show all |
### Spoolman integration (`/kx/spoolman/...`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/kx/spoolman/status` | Whether Spoolman is configured/reachable, server URL, sync rate, current slot→spool map |
| GET | `/kx/spoolman/spools` | Proxied list of spools from the configured Spoolman server |
| POST | `/kx/spoolman/active-spool` | Assign spool IDs to AMS slots: `{"slot_map": {"0": 42, "2": 17}}` (AMS slot index → Spoolman spool ID) |
### Multi-printer management (`/kx/printers...`)
| Method | Path | Purpose | Body |
|---|---|---|---|
| GET | `/kx/printers` | List all configured printers with online-ish metadata | |
| POST | `/kx/printers/add` | Add a printer by IP (credentials auto-fetched from the printer) | `{printer_ip, name?}` — triggers a bridge restart |
| DELETE | `/kx/printers/{pid}` | Remove a printer from config; renumbers remaining `[printer_N]` sections | — triggers a bridge restart |
| POST | `/kx/printers/{pid}/power` | Toggle an external smart plug (Tasmota-style) for a printer | `{"action": "on"|"off"}` |
| GET | `/kx/printers/{pid}/power-status` | Query the smart plug's current on/off state | |
**Power switch is not the printer's own power state** — it's a plain
`GET` fired at a user-configured `power_on_url` / `power_off_url` /
`power_status_url` (e.g. a Tasmota `cmnd=Power%20on` URL). See
[MANUAL.md](MANUAL.md#power-switch-feature) for details.
It only exists for printers where `power_on_url` or `power_off_url` is
set in config; `/kx/printers` exposes this as `has_power_control`.
### Misc
| Method | Path | Purpose |
|---|---|---|
| GET | `/kx/ui/{name}` | Serves theme assets (JS/CSS/vendored libs) and translation JSON files under the active UI theme |
| GET | `/` , `/printer{N}` | Serves the Web UI (index.html with CSS/JS inlined for embedded-webview compatibility, e.g. OrcaSlicer's device tab) |
| GET | `/favicon.ico` | Favicon |
---
## Response conventions
- Moonraker-compatible endpoints wrap results as `{"result": {...}}` (or
`{"error": {"code": ..., "message": ...}}` for the `/server/database/*`
404 case) to match the real Moonraker schema.
- Bridge-specific `/api/...` and `/kx/...` endpoints generally return
`{"result": ...}` on success and `{"error": "message"}` with a
non-2xx HTTP status on failure — but this is not universal; check the
handler in `kobrax_moonraker_bridge.py` if exact shape matters (route
registrations are near the end of the file, search for
`r.add_get(`/`r.add_post(`/`r.add_delete(`).
- Endpoints that trigger a config write (`/api/settings`,
`/kx/printers/add`, `/kx/printers/{pid}` DELETE) restart the whole
bridge process shortly after responding — clients should expect a
brief connection drop.

View File

@@ -1,5 +1,32 @@
# Changelog
## [Unreleased]
### Fixed
- **Slot kept showing/printing a stale filament type after a spool swap.** The
per-slot profile override (config.ini `[filament_profiles]`) stores only
vendor+name and was sticky: swapping the physical filament updated the AMS
colour and type live, but the saved profile persisted, so a slot that held
e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the
OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts.
The override is now applied only while 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). On a family change the
slot falls back to the generic default; the override is not deleted, so
reloading the original material reactivates it.
- **Filament profiles not isolated between printers in a multi-printer bridge**
(issue #74). The slot→profile mapping and `visible_vendors` were stored in a
single global `[filament_profiles]` section, so configuring one printer
overwrote the other and after a restart both loaded the same mapping. Each
printer now persists to its own `[filament_profiles_<id>]` section, with a
read-fallback to the legacy global section (single-printer setups unchanged).
- **Printer dropdown showed the other printer's filament profiles** (issue #74).
The header dropdown and the printers-management "switch" link navigated within
the same port (`/printerN`), so viewing another printer pulled its profile
names cross-instance from the local origin. The links now point at each
printer's own `bridge_url`, so every printer is viewed same-origin on its own
port.
## [0.9.26] 2026-06-21
### New

View File

@@ -2,12 +2,14 @@ FROM python:3.11-slim-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg gcc python3-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt && \
apt-get purge -y gcc python3-dev && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
COPY kobrax_moonraker_bridge.py .
COPY bridge_*.py .
COPY web/ ./web/
# Statische Daten (orca_filaments.json etc.) liegen in /app/static/, NICHT in
# /app/data/ — letzteres wird vom User als Volume gemountet (Runtime-State).
@@ -16,6 +18,12 @@ COPY config_loader.py .
COPY env_loader.py .
COPY kobrax_client.py .
COPY orca_filaments.py .
COPY spoolman_client.py .
COPY kxgauge_client.py .
COPY gcode_store.py .
COPY gcode_meta.py .
COPY camera.py .
COPY credentials.py .
COPY VERSION .
COPY anycubic_slicer.crt .
COPY anycubic_slicer.key .

330
MANUAL.md Normal file
View File

@@ -0,0 +1,330 @@
# KX-Bridge User Manual
This is a day-to-day how-to guide for using KX-Bridge once it's running.
For installing/updating the bridge itself, see [README.md](README.md).
For the HTTP/WebSocket API (developers, plugin authors, integrators), see
[API.md](API.md).
---
## Getting Started
### Install and start the bridge
Follow the Quick Start in [README.md](README.md#-quick-start) — the short
version is:
```bash
docker compose up -d
```
then open `http://BRIDGE-IP:7125` in a browser.
### Connect to your printer for the first time
1. On the printer's display: **Settings → Enable LAN mode**.
2. In the bridge Web UI, the **Printers** tab shows **"+ Add printer"** on
first start. Click it, enter the printer's IP address, and confirm —
username, password and device ID are fetched from the printer and
decrypted automatically. No manual credential entry needed.
3. Click **⚡ Connect** in the top-right corner to open the
connection. The status badge next to it shows the current printer state
(Standby, Printing, …).
### Connect OrcaSlicer
In OrcaSlicer, set the printer's connection type to **Moonraker** and enter
`http://BRIDGE-IP:7125` as the host (full URL including `http://` and the
port). See the [Recommended Slicer](README.md#-recommended-slicer) section
of the README for the patched OrcaSlicer-KX build with proper per-slot
filament matching.
---
## Dashboard Overview
The Dashboard is the main screen and is made of movable/resizable tiles
("cards"). Click **🖉 Customize dashboard** (top right
of the dashboard) to enter edit mode: drag tiles to reorder, drag corners to
resize, then save the arrangement as a named **preset** via the preset
dropdown, or reset back to the default layout.
The default tiles are:
- **Camera** — live view from the printer's camera, with a light toggle and
a play/stop button. A small ↺ reset button appears if the stream needs to
reconnect (e.g. after a 429 rate-limit from the printer).
- **Progress** — print percentage, a thumbnail of the current file, current
layer, current Z-height, elapsed/remaining time, and the file name.
While printing this card shows **Pause**, **Objects** (partial cancel, see
[Printing](#printing)) and **Stop** buttons. When a file is loaded but not
yet started, it instead shows **Print**, **Assign Slots**, and **Clear**.
- **Temperatures** — current and target nozzle/bed temperature with
progress bars, quick "Set"/"Off" controls, and a rolling history chart of
the last 60 readings.
- **Axis control** — jog buttons for X/Y/Z, adjustable step size (0.1 / 1 /
5 / 10 mm or a custom value), Home XY, Home Z, Home All, and Motors Off.
- **Print Speed** — three presets (Quiet / Normal / Sport) matching the
printer's own speed modes.
- **Fan** — a slider plus quick buttons (0/25/50/75/100%) for the part-cooling
fan.
- **Filament / AMS** — one tile per AMS/ACE slot showing assigned material,
color and (if configured) the mapped OrcaSlicer profile. Click a slot to
open its edit dialog (see [Managing Filaments](#managing-filaments)).
If an ACE dryer is attached and active, a separate drying-status row
appears below the grid.
Two banners can appear above the dashboard: an upload-ready banner when a
GCode file finishes uploading (with Print / Assign Slots / Cancel actions),
and a pause-reason banner when the printer pauses itself (e.g. filament
runout).
---
## Printing
### Uploading GCode
Open the **Browser** tab (sidebar) → **Uploaded** sub-tab, and
either drag a `.gcode`/`.bgcode` file onto the drop zone or click it to pick
a file. Uploaded files get thumbnails (if embedded by the slicer), a search
box, a status filter (All / Successful / Failed / New) and a sort order
(date, name, print duration). Select the checkbox on a card to enter
multi-select mode for bulk deletion.
If you configured file-ready mode as "Print dialog" (see
[Settings Reference](#settings-reference)), a dialog opens right after
upload offering to start the print immediately or assign AMS slots first.
With "banner" mode you instead get a persistent banner at
the top of the screen with the same choices, so you can keep browsing
before deciding.
### Starting a print / assigning filament
When starting a file that uses multiple filament channels (AMS/ACE slots),
the **filament assignment dialog** opens automatically (or via "Assign
Slots"). It lets you:
- Map each GCode filament channel to a physical AMS/ACE slot, with a
mismatch warning if a channel's expected material/color doesn't match
what's actually loaded in the slot you pick.
- Expand **✂ Skip objects** to deselect specific
printable objects (for multi-object plates) before the print starts —
the same object list and skip mechanism is also available mid-print from
the Progress card's "Objects" button.
- Toggle **Auto-Leveling** for this print.
- If Spoolman is configured, assign a specific spool to each slot right
from this dialog.
Confirm with **▶ Print** to send the job to the printer.
### Print-start behavior settings
Under **Settings → Printer** you can control default behavior for
every print:
- **Default slot (single-color print)** —
which AMS slot to use automatically for single-material files.
- **Auto-leveling before print** — run bed leveling before every print.
- **Resonance compensation before print** — run input-shaper calibration
before every print.
- **After upload: print-start behavior**
— dialog vs. banner, as described above.
- **Turn camera on at print start** —
auto-start the camera stream whenever a print begins.
- **Show warning for web-upload
prints** — an extra confirmation step for files uploaded through the
browser rather than sliced directly for this printer, to catch
wrong-printer-profile mistakes.
### While printing
The Progress card provides **Pause/Resume**, **Stop** (with a confirmation
prompt), and **Objects** to skip specific objects on a multi-object plate
mid-print.
---
## Managing Filaments
### AMS / ACE slots
Each slot tile on the Dashboard can be opened (click it) to edit:
- **Color** — via a color picker, recent-color swatches, or "copy color
from slot" to match another slot.
- **Material** — quick buttons for common materials, or free text.
- **OrcaSlicer profile override** — pick a specific imported or built-in
OrcaSlicer filament profile for this slot. This is what gets sent to the
slicer during AMS sync instead of a generic "Generic PLA/PETG" fallback
(see the README's [OrcaSlicer-KX](README.md#-recommended-slicer) section
for why this matters).
- A **feed** button to extrude/load filament for that slot directly from
the UI.
If your printer has an ACE dryer unit, an additional drying panel appears
below the AMS grid when drying is active, and slot edit dialogs let you
configure drying **presets**: PLA, PLA+, PETG, TPU, ABS/ASA, PA/PC, and
three freely-nameable Custom presets, each with its own temperature
(3080 °C) and remaining-time (h:m:s) setting. Presets can be edited and
saved, or reset back to their defaults.
### Importing your own OrcaSlicer profiles
Under **Settings → Filament → OrcaSlicer-Profile** (or from the "★ Own
profiles" link inside a slot's profile dropdown), open the import dialog
and either drag a **ZIP** of your OrcaSlicer filament folder onto the drop
zone, or upload individual **.json** profile files. In OrcaSlicer, that
folder is reachable via **Help → Show Configuration Folder →
user/<id>/filament/**. Imported profiles show up in every slot's profile
dropdown under a "★ Own profiles" group and can be removed again from the
same import dialog's list.
### Filament-profile mapping and visible vendors
Still under **Settings → Filament**:
- **Filament profile mapping (per
slot)** — pin a fixed OrcaSlicer profile to each AMS slot so the bridge
always reports that profile during slicer sync, regardless of what
material/color is currently loaded.
- **Visible vendors** — restrict which vendors show
up in the slot profile dropdown (useful if you only ever use a handful of
brands); leaving nothing selected shows all vendors. "Generic" and your
own imported profiles are always visible regardless of this filter.
### Spoolman integration
Configure the Spoolman server URL under **Settings → Integrations →
Spoolman** (e.g. `http://spoolman:7912`) and a sync rate in seconds (`0`
means "sync only when a print finishes"). Once connected, a
**Spoolman — Slot assignment** panel appears under
**Settings → Filament**, letting you assign a specific spool from your
Spoolman inventory to each AMS slot. Filament usage is then tracked and
reported to Spoolman automatically as you print, and the filament
assignment dialog shown when starting a print also lets you pick/confirm
spools per slot at print time.
---
## Multi-Printer Setup
KX-Bridge can manage several printers from one running instance.
- **Add a printer:** go to the **Printers** tab and click
**"+ Add printer"**. Enter the IP (name is
optional); credentials are fetched automatically, same as during first
setup. Each additional printer gets its own port (7126, 7127, …).
- **Switch printers:** use the dropdown in the header (next to the printer
name), or open the **Printers** tab and click **"Switch"**
on any non-active printer's card. Each card also shows live status
(state, current file, progress bar, nozzle/bed temperature) fetched
directly from that printer's own bridge instance.
- **Remove a printer:** click the **✕** button on its card in the
**Printers** tab; you'll be asked to confirm.
---
## Power Switch Feature
The power switch feature lets the bridge turn an external **smart plug**
on or off, and query its state — it is **not** a connection to the
printer's own internal power management, since the printer has no
remotely controllable power state of its own. You need a smart plug
(commonly a Tasmota-flashed plug) wired between the wall outlet and the
printer's power supply, reachable over HTTP from the bridge.
Configure it under **Settings → Connection**, in the "Power Switch" card,
with three
URLs:
- **Power-On URL** — called to switch the plug on.
- **Power-Off URL** — called to switch the plug off.
- **Status URL** — polled to show the current on/off state.
For a Tasmota device, these are typically of the form:
```
http://192.168.x.x/cm?cmnd=Power%20on
http://192.168.x.x/cm?cmnd=Power%20off
http://192.168.x.x/cm?cmnd=Power
```
replacing `192.168.x.x` with the smart plug's own IP address (not the
printer's). Once configured, a 🔌 power icon appears next to that
printer's card in the **Printers** tab; click it to toggle the plug.
Turning it off asks for confirmation, since it will cut power to whatever
is plugged in — make sure nothing is printing first. The icon's color
reflects the last known state (green = on, gray = off) as reported by the
status URL.
---
## Settings Reference
Settings are organized into tabs on the **Settings** panel:
- **Connection** — printer name, printer IP, MQTT port,
MQTT username/password, device ID and mode ID (normally filled in
automatically by "Add printer"), plus the Power Switch URLs described
above.
- **Printer** — default slot for single-color prints,
auto-leveling and resonance-compensation defaults, upload/print-start
behavior, camera auto-start, and the web-upload confirmation warning
(see [Printing](#printing)).
- **Display** — UI language (DE/EN/ES/FR/IT/中文), light/dark
theme toggle, how often the bridge polls the printer for status updates,
and a verbose HTTP request logging toggle for troubleshooting.
- **Filament** — OrcaSlicer profile import, per-slot profile mapping,
visible-vendor filtering, and (if Spoolman is connected) the Spoolman
slot-assignment panel — all described in
[Managing Filaments](#managing-filaments).
- **Integrations** — Spoolman server URL and sync rate, and
an info box pointing to the `moonraker-obico.cfg` file used to configure
Obico (Obico itself is set up outside the bridge UI — see
[Camera / OrcaSlicer-KX / Obico](#camera--orcaslicer-kx--obico) below).
- **System** — shows the current bridge version and lets you check for and
install updates directly from the browser, including a changelog preview.
Most settings changes are applied via the **Save &
Restart** button at the bottom of the Settings panel, which restarts the
bridge process to apply them.
---
## Troubleshooting Basics
- **Logs:** the **Console** tab shows a live event log with
filters by direction (RX/TX), level (errors/warnings), and topic (AMS,
print, info, status), plus a free-text filter and a download button for
the full log file.
- **"Wrong MQTT credentials" on start:** re-add the printer via
"+ Add printer", or see the credential-refresh steps in the README's
[Troubleshooting](README.md#-troubleshooting) section.
- **Printer not found / no LAN mode:** confirm LAN mode is enabled on the
printer's display and that the printer and bridge are on the same
network.
- **Docker permission errors, upgrading from old versions, and other
install-level issues:** see the README's own
[Troubleshooting](README.md#-troubleshooting) section.
For anything not covered here or in the README, please check or open an
issue on the project's Gitea page:
<https://gitea.it-drui.de/viewit/KX-Bridge-Release/issues>.
---
## Camera / OrcaSlicer-KX / Obico
- **Camera:** the Dashboard's Camera tile plays the printer's live stream
directly; no separate setup is required beyond having the printer
connected.
- **OrcaSlicer-KX:** for filament brand/color to sync correctly into
OrcaSlicer's AMS view, use the patched community build — see the
README's [Recommended Slicer](README.md#-recommended-slicer) section for
the download link and what it changes.
- **Obico:** self-hosted failure-detection and time-lapse integration runs
through the separate `moonraker-obico` plugin/container, configured via
the config file referenced under **Settings → Integrations → Obico**.
Full setup instructions live in the README's
[Community & Integrations](README.md#-community--integrations) section.

View File

@@ -1,6 +1,6 @@
## Changes in this build
- Unified axes control panel: XY and Z merged into one card, shared step size selector (0.1 / 1 / 5 / 10 mm) plus custom mm input field, Home XY/Z buttons placed directly below their respective pads
- Language selector moved from header bar to Settings → Appearance
- Filament mismatch detection: Upload-and-Print is intercepted when GCode material differs from the loaded AMS slot — slot mapper dialog opens automatically to correct the assignment before printing
- Spoolman: assign a spool per AMS slot directly in the AMS status tab (dropdown per slot kachel) and in the Filaments settings tab (dedicated assignment card)
- 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.
- Fix: a range of smaller robustness issues found in an internal code review — a single malformed MQTT message from the printer could get permanently stuck at the front of the receive buffer and force a reconnect on every subsequent poll; concurrent requests of the same type could occasionally have their responses mixed up; the camera stream could leak an orphaned ffmpeg process after a printer reboot rotated its stream URL while a new stream was already starting; `/api/settings` and `/api/update/apply` returned an unhandled server error instead of a clean "invalid request" for a malformed request body; a typo'd numeric value in `config.ini` (e.g. a stray character in the port number) could prevent the bridge from starting at all instead of falling back to the default; and a rare filament-profile-name collision during import is now logged instead of silently resolved. None of these were reported as user-facing bugs — added as defense-in-depth after a targeted review, with new tests covering each case.

View File

@@ -23,15 +23,13 @@ Feedback willkommen.</sub>
&nbsp;
[![Downloads](https://img.shields.io/badge/Downloads-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM)
<sub>Gefällt dir KX-Bridge? Ein Kaffee auf <a href="https://ko-fi.com/viewitde">Ko-fi</a> hält das Projekt am Leben. ☕</sub>
</div>
> [!CAUTION]
> **Laufende Wartungsarbeiten** — Wir strukturieren das Repository um (Branch-Modell, CI-Workflows, Beitragsprozess). Es kann zu Änderungen bei Branch-Namen, PR-Templates und der Art der Veröffentlichungen kommen. Wir entschuldigen uns für etwaige Unannehmlichkeiten. Handhabung, Workflow und langfristige Wartbarkeit werden dadurch deutlich verbessert.
>
> 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen.
---
@@ -43,12 +41,16 @@ Feedback willkommen.</sub>
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
| 🎨 | **AMS / Multicolor** — Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück |
| 🏷️ | **Custom-RFID-Tag-Matching** — mit Drittanbieter-Tools (z.B. der „ACE RFID"-App) beschriebene Spulen werden automatisch nach Marke + Material gegen deine importierten OrcaSlicer-Profile gematcht, statt auf ein generisches Profil zurückzufallen |
| 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user/<id>/filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf |
| 🧵 | **Spoolman-Integration** — Spulen einzelnen AMS-Slots zuweisen, Filament-Verbrauch wird automatisch beim Drucken erfasst und synchronisiert |
| 🔗 | **Multi-ACE-Unterstützung** — mehrere aneinandergekettete ACE-Einheiten, auch bei Druckern ohne Toolhead-Buffer |
| 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) |
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget); erholt sich automatisch nach einem Drucker-Reboot mit rotiertem Stream-Token, kein manueller Reset nötig |
| 🗂️ | **GCode-Browser** zwei Tabs: hochgeladene Dateien (mit Thumbnail, Druckhistorie, Suche & Filter, Mehrfachauswahl + Sammel-Löschen) und Dateien direkt auf dem Drucker-Speicher (mit echten Thumbnails, Mehrfachauswahl + Löschen) |
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
| | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
| 🖱️ | **Frei anpassbares Dashboard** — Kacheln per Drag & Drop verschieben und in der Größe anpassen, als Preset speichern |
| 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart |
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / FR / IT / 中文, Browser-Sprache automatisch erkannt |
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
@@ -70,6 +72,11 @@ LAN-Modus am Kobra X aktivieren:
docker compose up -d
```
> Zusätzlich Spoolman und einen kompletten selbst gehosteten Obico-Setup
> (Spaghetti-Erkennung, Live-Stream) neben der Bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml)
> bündelt KX-Bridge + Spoolman + Obico (Web/ML/Tasks/Redis) + moonraker-obico
> in einem Netzwerk — Setup-Schritte in den Kommentaren am Dateianfang.
**Linux-Binary (kein Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
@@ -112,7 +119,7 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 📺 Video-Tutorial
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM)
---

View File

@@ -22,7 +22,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
&nbsp;
[![Downloads](https://img.shields.io/badge/Descargas-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM)
<sub>¿Te gusta KX-Bridge? Un café en <a href="https://ko-fi.com/viewitde">Ko-fi</a> mantiene el proyecto vivo. ☕</sub>
@@ -42,12 +42,16 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
| 🖨️ | **Control de impresora** — iniciar, pausar, reanudar, cancelar, temperaturas, velocidad de impresión |
| 📊 | **Estado en tiempo real** — temperatura, progreso, capas, tiempo restante, transmisión de cámara |
| 🎨 | **AMS / multicolor** — ranuras con **selector de perfil por ranura** (asigna tu propia marca de los perfiles de OrcaSlicer a cada ranura); el puente escribe material y color al display de la impresora |
| 🏷️ | **Coincidencia de etiquetas RFID personalizadas** — las bobinas etiquetadas con herramientas de terceros (p. ej. la app "ACE RFID") se emparejan automáticamente por marca + material con tus perfiles de OrcaSlicer importados, en lugar de caer en un perfil genérico |
| 📦 | **Importa tus propios perfiles de OrcaSlicer** — arrastra un ZIP de `~/.config/OrcaSlicer/user/<id>/filament/` al puente; aparecen en el desplegable de la ranura bajo ★ Perfiles propios |
| 🧵 | **Integración con Spoolman** — asigna bobinas a las ranuras del AMS, el consumo de filamento se registra y sincroniza automáticamente al imprimir |
| 🔗 | **Soporte multi-ACE** — múltiples unidades ACE encadenadas, incluso en impresoras sin buffer en el cabezal |
| 📷 | **Integración con Obico (experimental)** — Time-Lapse y stream en vivo WebRTC contra un [servidor Obico](https://github.com/TheSpaghettiDetective/obico-server) autoalojado vía moonraker-obico |
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso) |
| 🗂️ | **Explorador de GCode** — archivos subidos con vistas previas, historial de impresión, búsqueda y filtros |
| 📐 | **Stream H.264 directo + altura Z** — ruta de cámara de bajo consumo de CPU para Obico, Z actual derivada de la altura de capa (widget de progreso); se recupera automáticamente tras un reinicio de la impresora que rota el token del stream, sin necesidad de reinicio manual |
| 🗂️ | **Explorador de GCode** dos pestañas: archivos subidos (con vistas previas, historial de impresión, búsqueda y filtros, selección múltiple + borrado masivo) y archivos almacenados directamente en la memoria de la impresora (con vistas previas reales, selección múltiple + borrado) |
| 🧩 | **Multi-impresora** — múltiples impresoras en **una** instancia del puente, cambia mediante un menú desplegable |
| | **Añade una impresora con un clic** — solo introduce la IP, las credenciales se importan automáticamente |
| 🖱️ | **Panel de control libre** — arrastra y redimensiona las tarjetas del panel a tu gusto, guárdalo como preset |
| 🔁 | **Reconexión MQTT robusta** — el puente sobrevive a reinicios nocturnos de la impresora sin reinicio manual |
| 🌐 | **Interfaz multilingüe** — DE / EN / ES / FR / IT / 中文, detecta automáticamente el idioma del navegador |
| 🔄 | **Actualización automática** — instala nuevas versiones directamente desde el navegador |
@@ -69,6 +73,11 @@ Activa el modo LAN en la Kobra X:
docker compose up -d
```
> ¿Quieres Spoolman y un setup completo de Obico autoalojado (detección de
> espagueti, stream en vivo) junto al puente? [`docker-compose-KX.yml`](docker-compose-KX.yml)
> combina KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico
> en una sola red — consulta los comentarios al inicio del archivo para los pasos de configuración.
**Binario Linux (sin Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
@@ -111,7 +120,7 @@ Impresora → Tipo de conexión **Moonraker** → Host: `http://IP-DEL-PUENTE:71
## 📺 Vídeo tutorial
[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![Configuración y uso de KX-Bridge](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM)
---

View File

@@ -22,7 +22,7 @@ officially tested or supported. Feedback welcome.</sub>
&nbsp;
[![Downloads](https://img.shields.io/badge/Downloads-3.1k%2B-8957E5?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=E3sDigSeSdM)
<sub>Like KX-Bridge? A coffee on <a href="https://ko-fi.com/viewitde">Ko-fi</a> keeps the project alive. ☕</sub>
@@ -33,6 +33,8 @@ officially tested or supported. Feedback welcome.</sub>
>
> 👉 Want to contribute? Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
> 📖 Looking for day-to-day usage help? See the [User Manual](MANUAL.md). Building an integration? See the [API Reference](API.md).
---
## ✨ Features
@@ -42,12 +44,16 @@ officially tested or supported. Feedback welcome.</sub>
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
| 🎨 | **AMS / multicolor** — slots with per-slot **profile picker** (assign your own brand from OrcaSlicer profiles per slot); bridge writes material & colour back to the printer display |
| 🏷️ | **Custom RFID tag matching** — spools tagged with third-party tools (e.g. the "ACE RFID" app) auto-match vendor + material against your imported OrcaSlicer profiles instead of falling back to a generic default |
| 📦 | **Import your own OrcaSlicer profiles** — drag a ZIP from `~/.config/OrcaSlicer/user/<id>/filament/` into the bridge; they show up in the slot dropdown under ★ Own profiles |
| 🧵 | **Spoolman integration** — assign spools to AMS slots, filament usage tracked and synced automatically as you print |
| 🔗 | **Multi-ACE support** — multiple daisy-chained ACE units, including printers without a toolhead buffer |
| 📷 | **Obico integration (experimental)** — Time-Lapse and WebRTC live stream against a self-hosted [Obico server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget |
| 🗂️ | **GCode browser**uploaded files with thumbnails, print history, search & filter |
| 📐 | **Direct H.264 stream + Z-height** — low-CPU camera path for Obico, current Z derived from layer-height for the print-progress widget; auto-recovers after a printer reboot rotates its stream token, no manual reset needed |
| 🗂️ | **GCode browser**two tabs: files you've uploaded (with thumbnails, print history, search & filter, multi-select + bulk delete) and files stored directly on the printer's own storage (with real thumbnails, multi-select + delete) |
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
| | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
| 🖱️ | **Free-form dashboard** — drag & resize the dashboard tiles into your own layout, save it as a preset |
| 🔁 | **Robust MQTT reconnect** — bridge survives overnight printer reboots without manual restart |
| 🌐 | **Multi-language UI** — DE / EN / ES / FR / IT / 中文, auto-detect browser locale |
| 🔄 | **Self-update** — install new versions directly in the browser |
@@ -69,6 +75,11 @@ Enable LAN mode on the Kobra X:
docker compose up -d
```
> Want Spoolman and a full self-hosted Obico setup (spaghetti detection, live stream)
> alongside the bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml) bundles
> KX-Bridge + Spoolman + Obico (web/ML/tasks/redis) + moonraker-obico behind one
> network — see the file's header comments for setup steps.
**Linux binary (no Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
@@ -111,7 +122,7 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
## 📺 Video Tutorial
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/E3sDigSeSdM/hqdefault.jpg)](https://www.youtube.com/watch?v=E3sDigSeSdM)
---

862
bridge_ams.py Normal file
View File

@@ -0,0 +1,862 @@
"""
bridge_ams.py - AmsFilamentMixin for KobraXBridge.
AMS/ACE slot topology + aggregation, per-slot filament-profile resolution
(material normalization, RFID vendor matching, effective slot profile),
ACE dryer presets, the lane-data builder for OrcaSlicer sync, and the
_on_multicolor_box / _on_light MQTT callbacks (which live here because they
are all about AMS state). Mixed into KobraXBridge.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import re
import time
import logging
import threading
log = logging.getLogger("bridge")
class AmsFilamentMixin:
def _default_ace_dry_presets(self) -> dict[str, dict]:
return {
"pla": {"temp": 45, "duration_sec": 4 * 3600},
"pla_plus": {"temp": 45, "duration_sec": 4 * 3600},
"petg": {"temp": 50, "duration_sec": 4 * 3600},
"tpu": {"temp": 55, "duration_sec": 4 * 3600},
"abs_asa": {"temp": 45, "duration_sec": 8 * 3600},
"pa_pc": {"temp": 55, "duration_sec": 12 * 3600},
"custom_1": {"name": "Custom 1", "temp": 45, "duration_sec": 4 * 3600},
"custom_2": {"name": "Custom 2", "temp": 45, "duration_sec": 4 * 3600},
"custom_3": {"name": "Custom 3", "temp": 45, "duration_sec": 4 * 3600},
}
def _sanitize_ace_dry_presets(self, presets: dict) -> dict[str, dict]:
out = self._default_ace_dry_presets()
for key in list(out.keys()):
src = presets.get(key) if isinstance(presets, dict) else None
if not isinstance(src, dict):
continue
try:
t = int(src.get("temp", out[key]["temp"]))
except Exception:
t = out[key]["temp"]
try:
d = int(src.get("duration_sec", out[key]["duration_sec"]))
except Exception:
d = out[key]["duration_sec"]
out[key]["temp"] = max(30, min(80, t))
out[key]["duration_sec"] = max(10 * 60, min(24 * 3600, d))
if key.startswith("custom_"):
name = str(src.get("name", out[key].get("name", key.replace("_", " ").title()))).strip()
out[key]["name"] = name or out[key].get("name", "Custom")
return out
def _load_ace_dry_presets_config(self) -> dict[str, dict]:
import configparser
defaults = self._default_ace_dry_presets()
cfg_path = self._find_config_path()
if not cfg_path.is_file():
return defaults
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(cfg_path, encoding="utf-8")
sec = "ace_dry_presets"
if not cfg.has_section(sec):
return defaults
out = {}
for key, d in defaults.items():
temp_k = f"{key}_temp"
dur_k = f"{key}_duration_sec"
try:
temp = int(cfg.get(sec, temp_k, fallback=str(d["temp"])))
except Exception:
temp = d["temp"]
try:
dur = int(cfg.get(sec, dur_k, fallback=str(d["duration_sec"])))
except Exception:
dur = d["duration_sec"]
out[key] = {
"temp": max(30, min(80, temp)),
"duration_sec": max(10 * 60, min(24 * 3600, dur)),
}
if key.startswith("custom_"):
name_k = f"{key}_name"
name = cfg.get(sec, name_k, fallback=str(d.get("name", key.replace("_", " ").title()))).strip()
out[key]["name"] = name or str(d.get("name", "Custom"))
return out
@staticmethod
def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str:
"""Detect active filament topology mode.
Modes:
- toolhead: only toolhead slots
- ace_direct: ACE channels directly mapped, no toolhead box present.
Covers one unit (Kobra X) as well as multiple daisy-chained units
(Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a
block of 4 global slots at box_id * 4.
- ace_hub: toolhead + ACE via hub (slot 4 as hub path)
"""
toolhead = any(b.get("id") == -1 for b in boxes)
ace = any(b.get("id", -1) >= 0 for b in boxes)
if ace and toolhead:
return "ace_hub"
if ace:
return "ace_direct"
return "toolhead"
@staticmethod
def _aggregate_slots(boxes: list, mode: str = "toolhead") -> tuple:
"""Aggregate multi_color_box list into a flat global slot list."""
toolhead = next((b for b in boxes if b.get("id") == -1), None)
ace_boxes = sorted(
[b for b in boxes if b.get("id", -1) >= 0],
key=lambda b: b["id"]
)
global_slots: list = []
global_loaded: int = -1
if mode == "toolhead":
if toolhead:
for local_idx, s in enumerate(toolhead.get("slots") or []):
s = dict(s)
s["global_index"] = local_idx
s["box_id"] = -1
global_slots.append(s)
loaded = toolhead.get("loaded_slot", -1)
if loaded >= 0:
global_loaded = loaded
return global_slots, global_loaded
if mode == "ace_direct":
# One or more ACE units, no toolhead buffer (Kobra X: 1 unit,
# Kobra S1: up to 2+ units, Issue #95). Global index =
# box_id * 4 + local slot, so the numbering matches
# _global_to_box_slot's //4-%4 fallback and stays stable
# regardless of report order.
for ace in ace_boxes:
ace_id = int(ace["id"])
base = ace_id * 4
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
s = dict(s)
s["global_index"] = base + local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if 0 <= ace_loaded < 4:
global_loaded = base + ace_loaded
return global_slots, global_loaded
# ace_hub
if toolhead:
for local_idx, s in enumerate((toolhead.get("slots") or [])[:3]):
s = dict(s)
s["global_index"] = local_idx
s["box_id"] = -1
global_slots.append(s)
th_loaded = toolhead.get("loaded_slot", -1)
if 0 <= th_loaded <= 2:
global_loaded = th_loaded
for ace in ace_boxes:
ace_id = ace["id"]
base = 3 + ace_id * 4
for local_idx, s in enumerate(ace.get("slots") or []):
s = dict(s)
s["global_index"] = base + local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if ace_loaded >= 0:
global_loaded = base + ace_loaded
return global_slots, global_loaded
def _global_to_box_slot(self, global_index: int) -> tuple:
"""Convert a global slot index to (box_id, local_slot_index)."""
for s in self._ams_slots:
if s.get("global_index") == global_index:
return s.get("box_id", -1), s.get("index", global_index)
ace_present = any(s.get("box_id", -1) >= 0 for s in self._ams_slots)
if self._filament_mode == "ace_direct" and ace_present:
return global_index // 4, global_index % 4
if not ace_present or global_index < 3:
return -1, global_index
offset = global_index - 3
return offset // 4, offset % 4
def _slot_to_print_ams_index(self, global_index: int) -> int:
"""Convert UI/global slot index to printer print/start ams_index.
In ace_hub mode, print/start uses global channel numbering where
toolhead channels occupy 1..3 and ACE0 starts at index 4.
"""
idx = int(global_index)
if self._filament_mode == "ace_hub":
box_id, local_slot = self._global_to_box_slot(idx)
if box_id >= 0:
return 4 + box_id * 4 + int(local_slot)
return idx
return idx
def _slot_usable_for_print(self, global_index: int) -> bool:
"""Whether a global slot can be used for current filament mode."""
slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == int(global_index)), None)
if not slot:
return False
if int(slot.get("status", 0)) != 5:
return False
box_id = int(slot.get("box_id", -1))
if self._filament_mode == "ace_hub":
# In hub mode, toolhead channels (0..2) and ACE channels are both printable.
return box_id == -1 or box_id >= 0
if self._filament_mode == "ace_direct":
return box_id >= 0
return box_id == -1
def _loaded_slots_for_print(self) -> list[tuple[int, dict]]:
"""Loaded slots filtered for current filament mode."""
loaded = [
(int(s.get("global_index", i)), s)
for i, s in enumerate(self._ams_slots)
if s.get("status") == 5 and self._slot_usable_for_print(int(s.get("global_index", i)))
]
return loaded
def _select_loaded_slots_for_print(self, warn_on_empty_default: bool = False) -> list[tuple[int, dict]]:
"""Return loaded slots, honoring default_ams_slot when configured."""
default_slot = getattr(self._args, "default_ams_slot", "auto")
all_loaded = self._loaded_slots_for_print()
if default_slot == "auto":
return all_loaded
try:
slot_idx = int(default_slot)
except ValueError:
return all_loaded
selected = [(i, s) for i, s in all_loaded if i == slot_idx]
if selected:
return selected
if warn_on_empty_default:
log.warning(f"Default slot {slot_idx} is empty - falling back to auto")
return all_loaded
@staticmethod
def _slot_color_rgba(slot: dict) -> list[int]:
color = slot.get("color", [255, 255, 255])
if isinstance(color, list) and len(color) >= 3:
return [int(color[0]), int(color[1]), int(color[2]), 255]
return [255, 255, 255, 255]
def _build_auto_ams_box_mapping(
self,
warn_on_empty_default: bool = False,
loaded_slots: list[tuple[int, dict]] | None = None,
) -> list[dict]:
"""Build print mapping from currently loaded slots (no explicit dialog assignments)."""
loaded = loaded_slots
if loaded is None:
loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default)
if not loaded:
return []
loaded_map = {gidx: s for gidx, s in loaded}
max_idx = max(loaded_map.keys())
# The printer interprets ams_box_mapping as an ordered list (entry N = TN).
# Missing slots must be inserted as placeholders, otherwise everything shifts.
# A placeholder must NOT reference a physically empty tray: the printer
# rejects such an entry even for a tool the GCode never calls (printing
# Filament 4 with the slot below it empty fails; all-full works). Point
# gap placeholders at a definitely-loaded tray instead of the gap's own
# (empty) index.
fallback_gidx = max_idx # highest loaded slot -> loaded + printable
fallback_slot = loaded_map[fallback_gidx]
fallback_ams = self._slot_to_print_ams_index(fallback_gidx)
result = []
for i in range(max_idx + 1):
if i in loaded_map:
s = loaded_map[i]
result.append({
"paint_index": i,
"ams_index": self._slot_to_print_ams_index(i),
"paint_color": [255, 255, 255, 255],
"ams_color": self._slot_color_rgba(s),
"material_type": s.get("type", "PLA"),
})
else:
result.append({
"paint_index": i,
"ams_index": fallback_ams,
"paint_color": [255, 255, 255, 255],
"ams_color": self._slot_color_rgba(fallback_slot),
"material_type": fallback_slot.get("type", "PLA"),
})
return result
def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]:
"""Build print mapping from UI filament assignments.
Returns (mapping, unused_count, invalid_count).
"""
slot_by_global_index = {
int(s.get("global_index", i)): s
for i, s in enumerate(self._ams_slots)
}
ams_box_mapping: list[dict] = []
unused_count = 0
invalid_count = 0
for i, a in enumerate(assignments):
try:
if a.get("is_used") is False:
unused_count += 1
continue
global_slot = int(a["slot_index"])
except (ValueError, TypeError, KeyError):
invalid_count += 1
continue
if global_slot < 0:
unused_count += 1
continue
if not self._slot_usable_for_print(global_slot):
invalid_count += 1
continue
slot = slot_by_global_index.get(global_slot, {})
ams_box_mapping.append({
# Preserve slicer paint indices (can be sparse when paint 0 is unused).
"paint_index": a.get("paint_index", i),
"ams_index": self._slot_to_print_ams_index(global_slot),
"paint_color": a.get("paint_color", [255, 255, 255, 255]),
"ams_color": self._slot_color_rgba(slot),
"material_type": slot.get("type", a.get("material", "PLA")),
})
return ams_box_mapping, unused_count, invalid_count
def _box_local_to_global(self, box_id: int, local_slot: int, boxes: list) -> int:
"""Convert (box_id, local slot) to global slot index for current topology."""
if box_id == -1:
return local_slot
if self._filament_mode == "ace_direct":
# Multi-ACE (Issue #95): each unit occupies its own block of 4.
# Identical to the old `return local_slot` for a single unit (id 0).
return box_id * 4 + local_slot
return 3 + box_id * 4 + local_slot
def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
"""Build {global_slot_index: loading|unloading} from feed_status data."""
# Note: all boxes are considered — the old primary_ace_id filter (skip
# every ACE box except the first in ace_direct mode) is gone since the
# slot aggregation now handles multiple ACE units (Issue #95).
activity: dict = {}
for box in boxes:
fs = box.get("feed_status") or {}
current_status = int(fs.get("current_status", -1))
local_slot = int(fs.get("slot_index", -1))
feed_type = int(fs.get("type", -1))
if current_status in (-1, 10, 11) or local_slot < 0:
continue
box_slots = box.get("slots") or []
if local_slot >= len(box_slots) or (box_slots[local_slot] or {}).get("status") != 5:
continue
if feed_type == 1:
act = "loading"
elif feed_type == 2:
act = "unloading"
else:
continue
global_slot = self._box_local_to_global(int(box.get("id", -1)), local_slot, boxes)
if feed_type == 1 and self._pending_load_slot >= 0 and global_slot != self._pending_load_slot:
# Ignore transient firmware-reported loading slots that differ from the requested target.
if global_loaded >= 0 and global_loaded != self._pending_load_slot:
activity[global_loaded] = "unloading"
continue
if feed_type == 1 and global_loaded >= 0 and global_slot != global_loaded:
# During a slot swap the firmware reports the target slot immediately,
# while the previously loaded slot is still being unloaded first.
activity[global_loaded] = "unloading"
activity[global_slot] = act
return activity
def _on_multicolor_box(self, payload: dict):
if payload.get("state") == "failed":
req = getattr(self, "_last_ams_set_request", None)
log.warning(
f"multiColorBox setInfo rejected by printer: request={req} raw_response={payload.get('data')}"
)
self._state["last_ams_set_error"] = True
return
data = payload.get("data") or {}
if not isinstance(data, dict):
log.warning(f"multiColorBox/report: unexpected data shape: {data!r}")
return
boxes = data.get("multi_color_box") or []
if not boxes:
return
self._state["last_ams_set_error"] = False
self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model))
self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model)
self._state["filament_mode"] = self._filament_mode
global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode)
self._ams_loaded_slot = global_loaded
self._update_ace_drying_state(data, boxes)
for box in boxes:
bid = int(box.get("id", -1))
if 0 <= bid <= 3 and "auto_feed" in box:
self._ace_auto_feed[bid] = int(box["auto_feed"])
if self._pending_load_slot >= 0 and global_loaded == self._pending_load_slot:
self._pending_load_slot = -1
activity_map = self._slot_activity_map(boxes, global_loaded)
for s in global_slots:
s["activity"] = activity_map.get(s.get("global_index"), "")
# Tip forming: after feed-in (status=10) or feed-out (status=11)
# the original slicer automatically sends type=3 (extruder retract).
# Check ALL boxes so ACE-triggered events are handled correctly.
for box in boxes:
fs = box.get("feed_status") or {}
current_status = fs.get("current_status")
slot_index = fs.get("slot_index", 0)
box_id = box.get("id", -1)
if current_status in (10, 11):
def _tip_form(bi=box_id, si=slot_index, cs=current_status):
import time; time.sleep(2)
self.client.publish(
"multiColorBox", "feedFilament",
{"multi_color_box": [{"id": bi, "feed_status": {"slot_index": si, "type": 3}}]},
timeout=0
)
log.info(f"Tip forming (type=3) after status={cs} box={bi} slot={si}")
threading.Thread(target=_tip_form, daemon=True).start()
if global_slots:
self._ams_slots = global_slots
log.info(f"AMS slots received: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}")
self._push_status_update()
def _update_ace_drying_state(self, data: dict, boxes: list):
"""Extract ACE drying state from multiColorBox report/getInfo payloads."""
ace_ids = sorted({int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0})
self._ace_box_ids = [i for i in ace_ids if 0 <= i <= 3]
def _num_from(src: dict, keys: tuple[str, ...], default=None):
for k in keys:
v = src.get(k)
if v is not None:
try:
return float(v)
except Exception:
return default
return default
def _humidity_from(src: dict, default=None):
return _num_from(src, ("humidity", "current_humidity", "cur_humidity", "relative_humidity", "humidity_value"), default)
def _current_temp_from(src: dict, default=None):
return _num_from(src, ("current_temp", "cur_temp", "temperature", "temp", "drying_temp", "chamber_temp"), default)
def _minutes_from(src: dict, key: str, default=0):
raw = src.get(key, default)
try:
value = int(float(raw))
except Exception:
return int(default)
# Some firmware payloads report dryer times in seconds while the UI uses minutes.
if value > (24 * 60):
return max(0, int(round(value / 60.0)))
return max(0, value)
per_unit: list[dict] = []
for box in boxes:
bid = int(box.get("id", -1))
if bid < 0:
continue
bs = box.get("drying_status") or box.get("drying_settings")
bs = bs if isinstance(bs, dict) else {}
hu = _humidity_from(bs, _humidity_from(box))
ct = _current_temp_from(bs, _current_temp_from(box))
if bs or hu is not None or ct is not None:
per_unit.append({
"id": bid,
"status": int(bs.get("status", 0)),
"target_temp": int(bs.get("target_temp", 0)),
"duration": _minutes_from(bs, "duration", 0),
"remain_time": _minutes_from(bs, "remain_time", 0),
"humidity": hu,
"current_temp": ct,
})
src = data.get("drying_status") or data.get("drying_settings")
if not isinstance(src, dict):
for box in boxes:
if int(box.get("id", -1)) < 0:
continue
cand = box.get("drying_status") or box.get("drying_settings")
if isinstance(cand, dict):
src = cand
break
if isinstance(src, dict):
cur = self._state.get("ace_drying") or {}
active = [u for u in per_unit if u.get("status", 0)]
primary = active[0] if active else (per_unit[0] if per_unit else {})
self._state["ace_drying"] = {
"status": int(src.get("status", cur.get("status", 0))),
"target_temp": int(src.get("target_temp", cur.get("target_temp", 0))),
"duration": _minutes_from(src, "duration", cur.get("duration", 0)),
"remain_time": _minutes_from(src, "remain_time", cur.get("remain_time", 0)),
"humidity": _humidity_from(src, primary.get("humidity", cur.get("humidity"))),
"current_temp": _current_temp_from(src, primary.get("current_temp", cur.get("current_temp"))),
"units": per_unit,
}
elif per_unit:
active = [u for u in per_unit if u.get("status", 0)]
primary = active[0] if active else per_unit[0]
self._state["ace_drying"] = {
"status": int(primary.get("status", 0)),
"target_temp": int(primary.get("target_temp", 0)),
"duration": int(primary.get("duration", 0)),
"remain_time": int(primary.get("remain_time", 0)),
"humidity": primary.get("humidity"),
"current_temp": primary.get("current_temp"),
"units": per_unit,
}
def _on_light(self, payload: dict):
d = payload.get("data") or {}
self._state["light_on"] = bool(d.get("status", 0))
self._state["light_brightness"] = int(d.get("brightness", 80))
self._push_status_update()
# OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
# Default mapping per material type when the user has not set a slot
# profile override. For the Kobra X we prefer Anycubic's own
# filament IDs from the `@Anycubic Kobra X 0.4 nozzle` profiles - those
# are printer-specific is_compatible and are picked up by OrcaSlicer directly
# matched. Library fallbacks (OGF*) only for material types without
# Kobra X-specific Anycubic profile - their @system profiles have
# `compatible_printers: []` (= compatible with all printers).
_TRAY_INFO_IDX = {
# Anycubic-eigene Kobra-X-Profile
"PLA": "GFPLA",
"PLA+": "GFPLA+",
"PLA SILK": "GFPLA Silk",
"PLA-SILK": "GFPLA Silk",
"PLASILK": "GFPLA Silk",
"SILK PLA": "GFPLA Silk",
"PLA MATTE": "GFPLA",
"PLA-MATTE": "GFPLA",
"PLA MARBLE": "GFPLA",
"PLA WOOD": "GFPLA",
"PETG": "GFPETG",
"PETG+": "GFPETG",
"ABS": "GFABS",
"ASA": "GFASA",
"TPU": "GFTPU 95A",
"TPE": "GFTPU 95A",
"PVA": "GFPVA",
# Kein Anycubic-Kobra-X-Profil → Library-Fallback
"PLA-CF": "OGFL98",
"PLA CF": "OGFL98",
"PETG-CF": "OGFG98",
"PETG CF": "OGFG98",
"PA": "OGFN99",
"PA-CF": "OGFN98",
"PA CF": "OGFN98",
"PC": "OGFC99",
"HIPS": "OGFS98",
}
# Normalizes material type strings to the canonical key for _TRAY_INFO_IDX
# and _default_filament_name. PLA variants without an exact match fall
# back to their base family (PLA+ -> PLA+, PLA Matte -> PLA, etc.).
@staticmethod
def _normalize_material(mat: str) -> str:
m = mat.upper().strip().replace("-", " ").replace("_", " ")
# Bekannte Varianten normalisieren
_ALIASES = {
"PLAPLUS": "PLA+", "PLA PLUS": "PLA+",
"SILK PLA": "PLA SILK", "PLASILK": "PLA SILK",
"PLA MATTE": "PLA MATTE", "PLA MARBLE": "PLA MARBLE",
"PLA WOOD": "PLA WOOD",
"TPE": "TPU",
"PETG PLUS": "PETG+",
"PA6": "PA", "PA12": "PA", "PA66": "PA",
}
if m in _ALIASES:
return _ALIASES[m]
return m
@staticmethod
def _material_family(mat: str) -> str:
"""Reduce a material to its base polymer family.
PLA / PLA+ / PLA SILK / PLA MATTE -> "PLA"; PETG / PETG+ -> "PETG"; etc.
Used by the stale-profile guard: only a change of *family* (e.g. PETG ->
PLA) invalidates a saved slot profile — a change within the family
(PLA -> PLA SILK) must not discard an otherwise valid profile.
"""
if not mat:
return ""
m = AmsFilamentMixin._normalize_material(mat)
# Longer prefixes first so "PETG" is not swallowed by "PET".
for fam in ("PETG", "PLA", "ABS", "ASA", "TPU", "PVA", "HIPS", "PA", "PC", "PET"):
if m.startswith(fam):
return fam
return m
def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]:
"""Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
"GEEETECH PLA Bas", written via third-party RFID tools) into
(vendor, material_family).
Anycubic's ACE RFID system concatenates vendor + material + a
truncated serial/variant into one `type` string for custom tags -
unlike a normal spool report where `type` is just "PLA"/"PETG"/etc.
Returns ("", "") when the first token isn't a known vendor (from the
merged system+user filament library), which leaves plain type
strings like "PLA" completely unaffected (Issue #101).
"""
tokens = raw_type.split()
if len(tokens) < 2:
return "", ""
first = tokens[0].strip().lower()
vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()}
vendor = vendors.get(first)
if not vendor:
return "", ""
family = self._material_family(" ".join(tokens[1:]))
if not family:
return "", ""
return vendor, family
@staticmethod
def _rfid_variant_tokens(raw_type: str) -> list[str]:
"""Tokens after "VENDOR TYPE" in a combined ACE-RFID string (e.g.
["bas"] for "GEEETECH PLA Bas") - the truncated variant/serial that
distinguishes multiple profiles of the same (vendor, material family),
e.g. "Basic" vs. "Matte". Kept separate from _parse_combined_rfid_type()
so that function's 2-tuple signature (and its existing callers/tests)
stay unchanged (Issue #101)."""
tokens = raw_type.split()
return [t.lower() for t in tokens[2:]]
def _match_profile_by_vendor_family(self, vendor: str, family: str,
variant_tokens: list[str] | None = None) -> dict:
"""Find an imported/system filament profile by (vendor, material
family) - used to auto-resolve a combined ACE-RFID type string to
the user's already-imported OrcaSlicer profile (Issue #101), since
the exact profile `name` never appears verbatim in the truncated
RFID string.
When multiple profiles share the same (vendor, family) - e.g. "Geeetech
PLA Basic" and "Geeetech PLA Matte" both matching (Geeetech, PLA) -
variant_tokens (the RFID string's remaining tokens, e.g. ["bas"] for
"Basic") are scored against each candidate's name: a word-prefix match
scores higher than a plain substring match, so "bas" prefers "Basic"
over "Matte" or an unrelated profile name containing "bas" as noise.
Falls back to the first match when nothing disambiguates."""
matches = [
p for p in self._load_orca_filaments()
if p.get("vendor", "").lower() == vendor.lower()
and self._material_family(p.get("type", "")) == family
]
if not matches:
return {}
if len(matches) == 1 or not variant_tokens:
return matches[0]
best = matches[0]
best_score = -1
for p in matches:
name_words = p.get("name", "").lower().split()
score = 0
for tok in variant_tokens:
if any(w.startswith(tok) for w in name_words):
score += 2
elif tok in p.get("name", "").lower():
score += 1
if score > best_score:
best_score = score
best = p
log.debug(
f"_match_profile_by_vendor_family: {len(matches)} profiles match "
f"vendor={vendor!r} family={family!r}, variant_tokens={variant_tokens!r} "
f"-> {best.get('name')!r} (score={best_score})"
)
return best
def _profile_material(self, profile: dict) -> str:
"""Material type (e.g. "PETG") of a saved slot profile, resolved by
(vendor, name) from the Orca filament library. Returns "" when the
profile is not in the library — we do NOT guess in that case."""
name = (profile or {}).get("name", "")
if not name:
return ""
vendor = profile.get("vendor", "")
for p in self._load_orca_filaments():
if p.get("vendor") == vendor and p.get("name") == name:
return p.get("type", "") or ""
return ""
def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict:
"""Saved slot-profile override — but only while its material *family*
still matches the material currently loaded in the AMS. Falls back to
auto-resolving a combined ACE-RFID type string (Issue #101) when there
is no (usable) manual override.
Non-destructive suppression (Option A): when the family no longer matches
(e.g. a PETG profile but PLA loaded) the override is skipped → falls
through to the RFID auto-match / generic default. The override stays in
config.ini and reactivates as soon as the matching material is loaded
again. When the profile's family is unknown we do NOT suppress (fail-safe).
Centralized here (rather than duplicated per caller) so every consumer -
the dashboard's /kx/filament/slots, Happy-Hare gate data, and the
OrcaSlicer lane-data sync - benefits from RFID auto-matching identically,
instead of only the one call site that happened to also call
_parse_combined_rfid_type() directly."""
# A combined ACE-RFID string ("GEEETECH PLA Bas") carries a vendor
# prefix that _material_family() alone can't see past (it only
# strips known polymer prefixes, so "GEEETECH PLA BAS" resolves to
# itself, not "PLA") - resolve the plain material family through the
# RFID parser first so the stale-profile guard below compares against
# the actual polymer family, not the raw combined string.
vendor, family = self._parse_combined_rfid_type(ams_material)
plain_material = family or ams_material
profile = self._filament_profiles.get(global_idx) or {}
if profile.get("name"):
prof_fam = self._material_family(self._profile_material(profile))
ams_fam = self._material_family(plain_material)
if not (prof_fam and ams_fam and prof_fam != ams_fam):
return profile
if vendor:
variant_tokens = self._rfid_variant_tokens(ams_material)
auto = self._match_profile_by_vendor_family(vendor, family, variant_tokens)
if auto.get("name"):
return auto
return {}
def _build_lane_data(self) -> dict:
"""Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0.
POSITION-FAITHFUL: every physical slot keeps its position (tray id =
slot position). Empty slots are reported as placeholder trays, NOT
filtered out/compacted - otherwise colors shift to wrong positions
(e.g. slot 1=yellow, 2=empty, 3=red -> red must not land on position 2).
"""
slots = self._ams_slots
total = len(slots)
if total == 0:
return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"}
ams_count = (total + 3) // 4
ams_exist_bits = 0
tray_exist_bits = 0
ams_array = []
for ams_id in range(ams_count):
ams_exist_bits |= (1 << ams_id)
tray_array = []
max_slot = min(3, total - ams_id * 4 - 1)
for slot_id in range(max_slot + 1):
slot_index = ams_id * 4 + slot_id
slot = slots[slot_index] if slot_index < total else {}
occupied = slot.get("status") == 5
if occupied:
tray_exist_bits |= (1 << slot_index)
color_raw = slot.get("color", [255, 255, 255])
if isinstance(color_raw, list) and len(color_raw) >= 3:
color_hex = "{:02X}{:02X}{:02X}FF".format(
int(color_raw[0]), int(color_raw[1]), int(color_raw[2])
)
elif isinstance(color_raw, str) and len(color_raw) >= 6:
color_hex = color_raw[:6].upper() + "FF"
else:
color_hex = "FFFFFFFF"
material = self._normalize_material(slot.get("type", "PLA"))
# User override from config.ini [filament_profiles].slot_N_id
# takes precedence over the default mapping by material type.
# The vendor is sent along (tray_sub_brands + filament_vendor),
# so a patched OrcaSlicer can match by brand + type +
# color (analogous to SnapmakerPrinterAgent).
# Three-layer resolution for the filament hint sent to OrcaSlicer,
# all handled inside _effective_slot_profile() (Issue #101):
# 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
# 2. Combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
# "GEEETECH PLA Bas") auto-matched against the user's
# already-imported profile library. Not persisted to
# config.ini - re-derives on every call, so a differently
# tagged spool loaded later isn't stuck with a stale match.
# 3. Generic fallback (_TRAY_INFO_IDX) per material type - no
# vendor hint; OrcaSlicer then picks its own generic preset
user_profile = self._effective_slot_profile(slot_index, material)
if user_profile.get("name"):
material = self._material_family(user_profile.get("type", material)) or material
vendor = user_profile.get("vendor", "")
fila_name = user_profile.get("name", "")
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
else:
# Default: Library-Generic-Profil (siehe _default_filament_name) —
# is compatible with all printers and guaranteed to be visible.
# The user deliberately picks a concrete brand per slot if they
# want one; the default stays neutral.
fila_name = self._default_filament_name(material)
vendor = "Generic" if fila_name.startswith("Generic ") else ""
tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
tray_array.append({
"id": str(slot_id),
"tag_uid": "0000000000000000",
"tray_info_idx": tray_info_idx,
"tray_type": material,
"tray_color": color_hex,
"tray_sub_brands": vendor,
# OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
# `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
# filament_id_by_type). We send both spellings so that
# older patch variants + future upstream PRs are both
# covered.
"name": fila_name,
"vendor_name": vendor,
# Aliases for older patch variants (variant 2,
# MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
# otherwise resolve the preset name via find_preset().
"filament_id": tray_info_idx,
"filament_vendor": vendor,
"filament_name": fila_name,
"preset": fila_name,
})
else:
tray_array.append({
"id": str(slot_id),
"tag_uid": "0000000000000000",
"tray_info_idx": "",
"tray_type": "",
"tray_color": "00000000",
"tray_slot_placeholder": "1",
})
ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array})
return {
"ams": ams_array,
"ams_exist_bits": format(ams_exist_bits, "X"),
"tray_exist_bits": format(tray_exist_bits, "X"),
}

56
bridge_constants.py Normal file
View File

@@ -0,0 +1,56 @@
"""
bridge_constants.py - shared constants for the bridge modules.
Extracted so the mixin modules can import these without a circular dependency
on the kobrax_moonraker_bridge facade. Re-exported from there for callers.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
# Maps the printer's own MQTT state strings to Klipper/Moonraker print states.
KOBRA_TO_KLIPPER_STATE = {
"free": "standby",
"busy": "printing",
"printing": "printing",
"preheating": "printing",
"auto_leveling": "printing",
"checking": "printing",
"updated": "printing",
"init": "printing",
"pausing": "paused",
"paused": "paused",
"resuming": "printing",
"resumed": "printing",
"stopping": "printing",
"stoped": "standby",
"finished": "complete",
"failed": "error",
"canceled": "standby",
}
MOONRAKER_VERSION = "v0.9.3-1"
KLIPPER_VERSION = "v0.12.0-1"
# Default kobra_state -> KXGauge emotion mapping (used when [kxgauge_mapping]
# is absent or incomplete in config.ini).
DEFAULT_KXGAUGE_MAPPING = {
"free": "neutral",
"printing": "happy",
"paused": "worried",
"pause": "worried",
"finished": "glee",
"error": "scared",
"offline": "tired",
}
# Valid KXGauge emotion names (see kxgauge/API.md) - used to reject garbage
# values coming in through the settings save.
KXGAUGE_VALID_EMOTIONS = {
"neutral", "happy", "angry", "tired", "surprised", "curious", "confused",
"sleepy", "love", "dizzy", "crying", "focused", "glee", "sad", "worried",
"annoyed", "skeptic", "frustrated", "unimpressed", "suspicious", "squint",
"furious", "scared", "awe",
}

2688
bridge_endpoints.py Normal file

File diff suppressed because it is too large Load Diff

60
bridge_logging.py Normal file
View File

@@ -0,0 +1,60 @@
"""
bridge_logging.py - browser log stream plumbing for the bridge.
Holds the ring buffer + SSE queues that feed the Web UI's live log view, the
logging.Handler that populates them, and the verbose-HTTP-log toggle.
Extracted from kobrax_moonraker_bridge.py; the buffer/queues are re-imported
there so the log-stream/download endpoints keep working against the same
shared objects.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import collections as _collections
import logging
def _set_verbose_http_log(enabled: bool):
logging.getLogger("aiohttp.access").setLevel(logging.INFO if enabled else logging.WARNING)
# Ring buffer for the browser log stream + the open SSE consumer queues.
# These are shared mutable objects: kobrax_moonraker_bridge re-imports the
# same buffer/list so its log-stream/download handlers append/read the very
# same instances this handler writes to.
_log_buffer: "_collections.deque[dict]" = _collections.deque(maxlen=500)
_log_sse_queues: list = []
class _BrowserLogHandler(logging.Handler):
"""Sends log records to the ring buffer and all open SSE queues."""
_fmt = logging.Formatter(datefmt="%H:%M:%S")
def emit(self, record: logging.LogRecord):
msg = record.getMessage()
# Pass exceptions with traceback through to the browser (otherwise the
# user only sees "Error: X" without context).
if record.exc_info:
try:
msg += "\n" + self._fmt.formatException(record.exc_info)
except Exception:
pass
entry = {
"ts": self._fmt.formatTime(record, "%H:%M:%S"),
"lvl": record.levelname,
"name": record.name,
"msg": msg,
}
_log_buffer.append(entry)
for q in list(_log_sse_queues):
try:
q.put_nowait(entry)
except Exception:
pass
_browser_handler = _BrowserLogHandler()
logging.getLogger().addHandler(_browser_handler)

289
bridge_moonraker.py Normal file
View File

@@ -0,0 +1,289 @@
"""
bridge_moonraker.py - MoonrakerCompatMixin for KobraXBridge.
The Moonraker/Klipper-compatible HTTP surface (/server/*, /printer/*,
/machine/*) that Mainsail/Fluidd/OrcaSlicer/moonraker-obico talk to:
server/printer info, printer.objects query/list/subscribe, files list +
metadata, history, webcams, access api-key + update-manager stubs.
Mixed into KobraXBridge.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import sys
import time
import logging
from aiohttp import web
from bridge_constants import MOONRAKER_VERSION, KLIPPER_VERSION
log = logging.getLogger("bridge")
class MoonrakerCompatMixin:
async def handle_server_info(self, request):
return web.json_response({
"result": {
"klippy_connected": True,
"klippy_state": "ready",
"components": ["file_manager", "job_state", "virtual_sdcard"],
"failed_components":[],
"registered_directories": ["gcodes"],
"warnings": [],
"websocket_count": len(self.ws_clients),
"moonraker_version": MOONRAKER_VERSION,
"api_version": [1, 3, 0],
"api_version_string": "1.3.0",
}
})
async def handle_printer_info(self, request):
s = self._state
return web.json_response({
"result": {
"state": "ready",
"state_message": "Printer is ready",
"hostname": "kobrax-bridge",
"klipper_path": "/home/pi/klipper",
"python_path": "/home/pi/klippy-env/bin/python",
"log_file": "/tmp/klippy.log",
"config_file": "/home/pi/printer.cfg",
"software_version": KLIPPER_VERSION,
"cpu_info": s["printer_name"],
}
})
async def handle_machine_system_info(self, request):
return web.json_response({
"result": {
"system_info": {
"cpu_info": {"cpu_count": 4, "bits": "64bit", "processor": "armv7l",
"cpu_desc": "Anycubic Kobra X Bridge", "serial_number": "",
"hardware_desc": "", "model": "Kobra X Bridge",
"total_memory": 524288, "memory_units": "kB"},
"sd_info": {},
"distribution": {"name": "Linux", "id": "linux", "version": "1.0",
"version_parts": {}, "like": "", "codename": ""},
"available_services": [],
"service_state": {},
"python": {"version": list(sys.version_info[:3]), "version_string": sys.version},
"network": {},
"canbus": {},
}
}
})
async def handle_objects_query(self, request):
objects = self._build_printer_objects()
requested = []
query = request.rel_url.query
if "objects" in query:
requested = [x.strip() for x in str(query.get("objects", "")).split(",") if x.strip()]
elif query:
requested = [k for k in query.keys() if k]
filtered = {k: objects[k] for k in requested if k in objects} if requested else objects
return web.json_response({"result": {"status": filtered, "eventtime": time.time()}})
async def handle_objects_list(self, request):
return web.json_response({
"result": {
"objects": list(self._build_printer_objects().keys())
}
})
async def handle_objects_subscribe(self, request):
return web.json_response({
"result": {
"status": self._build_printer_objects(),
"eventtime": time.time(),
}
})
async def handle_files_list(self, request):
filename = self._state.get("filename", "")
files = []
if filename:
files.append({
"path": filename,
"modified": time.time(),
"size": 0,
"permissions": "rw",
})
return web.json_response({"result": files})
def _build_file_metadata(self, filename: str) -> dict:
"""Builds the Moonraker file metadata for a file. Shared source
for HTTP /server/files/metadata AND the WS RPC server.files.metadata
(previously the WS path had its own broken logic with a non-existent
existierenden Store-Methode → leere Antwort → Mobileraker fragte in
endless loop, app hung on refresh, Issue #48).
Liefert Mobileraker-kompatible Pflichtfelder: `filename`, `size`,
`modified` are non-nullable in GCodeFile; `print_start_time` and the
Slicer-Felder optional."""
s = self._state
# Live _state values are only relevant for the currently/last tracked
# job's own file - using them as a starting point for a DIFFERENT
# filename leaked the tracked job's layer count/time into unrelated
# metadata queries (Issue #102). For any other filename, rely solely
# on that file's own GCodeStore row.
is_tracked_file = bool(filename) and filename == s.get("filename")
layer_h = float(s.get("layer_height") or 0.0) if is_tracked_file else 0.0
first_h = float(s.get("first_layer_height") or 0.0) if is_tracked_file else 0.0
total_layers = int(s.get("total_layers") or 0) if is_tracked_file else 0
est_time = int(s.get("slicer_time") or 0) if is_tracked_file else 0
size_bytes = 0
try:
gf = self._store.get_file_by_name(filename) or {}
if not layer_h:
layer_h = float(gf.get("layer_height") or 0.0)
first_h = float(gf.get("first_layer_height") or layer_h)
if not total_layers:
total_layers = int(gf.get("layer_count") or 0)
if not est_time:
est_time = int(gf.get("est_print_time_sec") or 0)
size_bytes = int(gf.get("size_bytes") or 0)
except Exception:
pass
# Third fallback: the printer's own buried/report analytics event
# (fires once per print start regardless of slicer), for files that
# are neither the currently-tracked job nor in our own GCodeStore -
# e.g. printed directly via Anycubic Slicer Next (Issue #102).
buried = self._buried_cache
if buried and buried.get("task_name") == filename:
if not total_layers:
total_layers = buried.get("total_layers") or total_layers
if not est_time:
est_time = buried.get("estimate_duration") or est_time
if not size_bytes:
size_bytes = buried.get("gcode_size") or size_bytes
if not layer_h:
layer_h = self._layer_height_from_filename(filename)
if layer_h and not first_h:
first_h = layer_h
object_height = round(first_h + max(0, total_layers - 1) * layer_h, 3) if (layer_h and total_layers) else 0.0
return {
"filename": filename,
# GCodeFile (Mobileraker) requires size as a non-nullable int.
"size": size_bytes or 1,
"modified": time.time(),
"estimated_time": est_time or None,
"layer_height": layer_h or None,
"first_layer_height": first_h or None,
"layer_count": total_layers or None,
"object_height": object_height or None,
"thumbnails": [],
}
async def handle_files_metadata(self, request):
"""Moonraker /server/files/metadata — moonraker-obico + Mobileraker
holen Datei-Metadaten (Slicer-Zeit, Layer, object_height).
Logic in _build_file_metadata (shared with WS RPC)."""
filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "")
if not filename:
return web.json_response({"result": {}})
return web.json_response({"result": self._build_file_metadata(filename)})
# -- Moonraker stubs for moonraker-obico ----------------------------------
async def handle_access_api_key(self, request):
"""Moonraker /access/api_key - we have no auth, return a dummy.
moonraker-obico logs a WARNING otherwise."""
return web.json_response({"result": "kx-bridge-no-auth-required"})
async def handle_machine_update_status(self, request):
"""Moonraker /machine/update/status - Obico uses this to show installed plugins."""
return web.json_response({
"result": {
"busy": False,
"github_rate_limit": 60,
"github_requests_remaining": 60,
"github_limit_reset_time": time.time() + 3600,
"version_info": {},
}
})
async def handle_history_list(self, request):
"""Moonraker /server/history/list - job history from the GCodeStore.
moonraker-obico only uses the last element (limit=1, order=desc)."""
try:
limit = int(request.rel_url.query.get("limit", "50"))
except ValueError:
limit = 50
try:
jobs = self._store.list_jobs(limit=limit) or []
except Exception:
jobs = []
# Mapping to the Moonraker schema. Moonraker returns start_time as a Unix
# timestamp (float), not an ISO string - moonraker-obico parses it with
# int(start_time) and crashes otherwise.
def _to_unix_ts(iso: str | None) -> float:
if not iso:
return 0.0
try:
from datetime import datetime
# Format from GCodeStore: "2026-05-27T21:22:25Z"
dt = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
return dt.replace(tzinfo=__import__("datetime").timezone.utc).timestamp()
except Exception:
return 0.0
result_jobs = []
for j in jobs:
start_ts = _to_unix_ts(j.get("started_at"))
dur = j.get("duration_sec") or 0
result_jobs.append({
"job_id": j.get("id"),
"exists": True,
"end_time": (start_ts + dur) if start_ts and dur else None,
"filament_used": 0.0,
"filename": j.get("filename", ""),
"metadata": {},
"print_duration": dur,
"status": j.get("status") or "completed",
"start_time": start_ts,
"total_duration": dur,
})
return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}})
async def handle_webcams_list(self, request):
"""Moonraker /server/webcams/list - Obico fetches the webcam URLs here.
When the client comes from another host (e.g. moonraker-obico on a
separate server), it needs absolute URLs to reach the stream.
A Host header with localhost/127.0.0.1 is replaced by the real LAN IP."""
host_hdr = request.headers.get("Host", "") if request else ""
host_name = (host_hdr or "").split(":")[0]
port_part = f":{host_hdr.split(':')[1]}" if ":" in (host_hdr or "") else f":{self._args.port}"
local_ip = getattr(self, "_local_ip", None) or host_name
if host_name in ("localhost", "127.0.0.1", ""):
host_name = local_ip
base = f"http://{host_name}{port_part}"
stream_url = f"{base}/api/camera/stream"
snapshot_url = f"{base}/api/camera/snapshot"
return web.json_response({
"result": {
"webcams": [
{
"name": "KX-Bridge",
"location": "printer",
"service": "mjpegstreamer",
"enabled": True,
"icon": "mdiWebcam",
"target_fps": 5,
"target_fps_idle": 2,
"stream_url": stream_url,
"snapshot_url": snapshot_url,
"flip_horizontal": False,
"flip_vertical": False,
"rotation": 0,
"aspect_ratio": "16:9",
"extra_data": {},
}
]
}
})

418
bridge_mqtt.py Normal file
View File

@@ -0,0 +1,418 @@
"""
bridge_mqtt.py - MqttCallbacksMixin for KobraXBridge.
The MQTT reader-thread callbacks (_on_temp/_on_print/_on_info/_on_skip/
_on_buried/_on_file) and their helpers (_wait_for_file_action,
_delete_printer_file_fire_and_forget, _apply_preprint_skip_after_start).
Mixed into KobraXBridge; relies on shared bridge state (self._state,
self.client, self._store, self._current_job_*, self._spoolman*, etc.).
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import copy
import time
import asyncio
import logging
import threading
try:
import config_loader as env_loader
except ImportError:
import env_loader
from bridge_constants import KOBRA_TO_KLIPPER_STATE
log = logging.getLogger("bridge")
class MqttCallbacksMixin:
# -------------------------------------------------------------------------
# MQTT callbacks (called from reader thread)
# -------------------------------------------------------------------------
def _on_temp(self, payload: dict):
d = payload.get("data") or {}
self._state["nozzle_temp"] = float(d.get("curr_nozzle_temp", 0))
self._state["nozzle_target"] = float(d.get("target_nozzle_temp", 0))
self._state["bed_temp"] = float(d.get("curr_hotbed_temp", 0))
self._state["bed_target"] = float(d.get("target_hotbed_temp", 0))
if self._kxgauge:
self._kxgauge.set_heat_celsius(self._state["nozzle_temp"])
self._push_status_update()
def _on_print(self, payload: dict):
d = payload.get("data") or {}
kobra_state = payload.get("state", "")
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing")
if kobra_state:
self._state["kobra_state"] = kobra_state
if self._kxgauge:
emotion = self._kxgauge_mapping.get(kobra_state)
if emotion:
self._kxgauge.set_emotion(emotion)
# Automatically switch on the camera at print start (settings option).
# Centralized here so it covers all print start paths (OrcaSlicer + UI).
# _camera_autostarted verhindert Mehrfach-Trigger pro Druck.
if kobra_state == "printing":
if (getattr(self._args, "camera_on_print", 0)
and not self._camera_autostarted
and not self._camera_user_stopped):
self._camera_autostarted = True
try:
self.client.start_camera()
log.info("Camera switched on automatically at print start")
except Exception as e:
log.warning(f"Camera auto-start failed: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # release for the next print
if kobra_state in ("pause", "paused"):
pause_msg = payload.get("msg", "")
if pause_msg:
error_code = payload.get("code", 0)
self._state["error_code"] = error_code
self._state["pause_msg"] = pause_msg
log.warning(f"Printer paused: [{error_code}] {pause_msg}")
elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"):
self._state["error_code"] = 0
self._state["pause_msg"] = ""
# Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id:
filename = d.get("filename", self._state.get("filename", ""))
if filename:
gf = self._store.get_file_by_name(filename)
if gf:
self._current_job_id = self._store.start_job(
gcode_file_id=gf["id"],
printer_id=self._printer_id,
)
self._current_job_filename = filename
log.info(f"Job started: {self._current_job_id} for {filename}")
self._spoolman_slot_usage = {}
self._spoolman_slot_reported = {}
self._spoolman_last_usage = 0.0
self._spoolman_last_sync = 0.0
# Job-History: Druckende erkennen
if kobra_state in ("finished",) and self._current_job_id:
self._store.finish_job(self._current_job_id, status="completed")
log.info(f"Job abgeschlossen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
# Optional cleanup (Settings -> Print): only for files that are
# also backed by the bridge's own GCode store - never for prints
# started directly from the printer/Anycubic Slicer, which would
# otherwise be deleted with no copy left anywhere (Issue: delete
# printer file after successful print). Deliberately only on a
# clean "finished" - stoped/canceled prints keep their file.
if getattr(self._args, "delete_printer_file_after_print", 0) and self._current_job_filename:
self._delete_printer_file_fire_and_forget(self._current_job_filename)
self._current_job_filename = ""
elif kobra_state in ("stoped", "canceled") and self._current_job_id:
self._store.finish_job(self._current_job_id, status="cancelled")
log.info(f"Job abgebrochen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
self._current_job_filename = ""
# Terminal states (successful finish AND stop/cancel) must leave the
# same clean end state - a "finished" print used to only clear
# file_ready (Issue #29), leaving progress/filename/duration/layer
# fields stuck at the last job's values until the *next* print
# happened to overwrite them (Issue #102).
if kobra_state in ("finished", "stoped", "canceled"):
self._state["progress"] = 0.0
self._state["filename"] = ""
self._state["file_ready"] = ""
self._state["print_duration"] = 0
self._state["remain_time"] = 0
self._state["slicer_time"] = 0
self._state["layer_height"] = 0.0
self._state["first_layer_height"] = 0.0
self._state["supplies_usage"] = 0
self._state["curr_layer"] = 0
self._state["total_layers"] = 0
self._thumbnail_b64 = ""
else:
# Only adopt the payload's filename outside terminal states - the
# printer often still reports the just-finished job's filename in
# the same "finished"/"stoped"/"canceled" message that triggered
# the reset above, which would otherwise immediately undo it.
self._state["filename"] = d.get("filename", self._state["filename"])
# Pre-print phases (leveling/preheating/checking) report their own
# "progress" - passing it through would make display_status.progress/
# virtual_sdcard.progress jump non-monotonically once real printing
# starts and the value resets (Issue #102).
if "progress" in d and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"):
self._state["progress"] = float(d["progress"]) / 100.0
if "print_time" in d:
self._state["print_duration"] = int(d["print_time"]) * 60
if "remain_time" in d:
self._state["remain_time"] = int(d["remain_time"]) * 60
if "curr_layer" in d:
self._state["curr_layer"] = d["curr_layer"]
if "total_layers" in d:
self._state["total_layers"] = d["total_layers"]
if "taskid" in d:
self._state["taskid"] = str(d["taskid"])
if "supplies_usage" in d:
self._state["supplies_usage"] = int(d["supplies_usage"])
settings = d.get("settings") or {}
if "print_speed_mode" in settings:
self._state["print_speed_mode"] = int(settings["print_speed_mode"])
self._push_status_update()
def _on_info(self, payload: dict):
d = payload.get("data") or {}
# Only adopt the MQTT name if no custom name is set (env or per-printer config)
if not env_loader.get("BRIDGE_PRINTER_NAME") and not getattr(self, "_name_locked", False):
self._state["printer_name"] = d.get("printerName", self._state["printer_name"])
self._state["firmware_version"] = d.get("version", self._state["firmware_version"])
# The real print state lives in info/report inside the nested
# project.state ("printing"/"paused"/...). The top-level data.state is only
# the device state ("busy"/"free") and would swallow "paused".
project = d.get("project") or {}
proj_state = project.get("state", "")
kobra_state = proj_state or d.get("state", "")
if kobra_state:
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby")
self._state["kobra_state"] = kobra_state
# Hide the upload banner after the print ends (Issue #29) - the state also
# arrives via info/report (project.state) depending on the printer, not only print/report.
# Layer fields must reset here too (Issue #102) - info/report is the
# only source for curr_layer/total_layers on some printers, and they
# otherwise stay stuck at the last job's values indefinitely.
if kobra_state in ("finished", "stoped", "canceled"):
self._state["file_ready"] = ""
self._state["curr_layer"] = 0
self._state["total_layers"] = 0
# Camera auto-start here as well (OrcaSlicer often reports the start via info/report).
# The _camera_autostarted guard prevents a double start with _on_print.
if kobra_state == "printing":
if (getattr(self._args, "camera_on_print", 0)
and not self._camera_autostarted
and not self._camera_user_stopped):
self._camera_autostarted = True
try:
self.client.start_camera()
log.info("Camera switched on automatically at print start")
except Exception as e:
log.warning(f"Camera auto-start failed: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # release for the next print
if project:
if "filename" in project:
self._state["filename"] = project["filename"]
# Same non-monotonic-progress guard as _on_print (Issue #102).
if "progress" in project and kobra_state not in ("preheating", "auto_leveling", "checking", "updated", "init"):
self._state["progress"] = float(project["progress"]) / 100.0
if "print_time" in project:
self._state["print_duration"] = int(project["print_time"]) * 60
if "remain_time" in project:
self._state["remain_time"] = int(project["remain_time"]) * 60
if "curr_layer" in project:
self._state["curr_layer"] = project["curr_layer"]
if "total_layers" in project:
self._state["total_layers"] = project["total_layers"]
t = d.get("temp") or {}
if t:
self._state["nozzle_temp"] = float(t.get("curr_nozzle_temp", 0))
self._state["nozzle_target"] = float(t.get("target_nozzle_temp", 0))
self._state["bed_temp"] = float(t.get("curr_hotbed_temp", 0))
self._state["bed_target"] = float(t.get("target_hotbed_temp", 0))
urls = d.get("urls") or {}
if urls.get("fileUploadurl"):
self._state["upload_url"] = urls["fileUploadurl"]
if urls.get("rtspUrl"):
self._state["camera_url"] = urls["rtspUrl"]
self.camera_cache.set_url(urls["rtspUrl"])
fan = d.get("fan_speed_pct")
if fan is not None:
self._state["fan_speed"] = int(fan)
speed_mode = d.get("print_speed_mode")
if speed_mode is not None:
self._state["print_speed_mode"] = int(speed_mode)
self._push_status_update()
def _on_skip(self, payload: dict):
"""skip/report-Callback (Part-Skip-Feature, v0.9.10).
The printer ALWAYS reports the list of already-skipped objects here
(objects_skip_parts), whether on query_obj or after skip/start.
The full object list comes from file/report.
"""
d = payload.get("data") or {}
skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or []
# While a pre-print skip is still pending, ignore empty early reports
# so the UI doesn't snap back before the printer confirms the skip.
now = time.time()
if (not skipped and self._pending_preprint_skip
and now <= self._pending_preprint_skip_deadline):
return
# During an active print, skip states are effectively monotonic.
# Some firmware reports come back empty/partial in between;
# those must not remove already-confirmed skip objects from the UI.
existing_skipped = [str(n) for n in (self._skip_state.get("skipped") or []) if n]
existing_set = set(existing_skipped)
incoming_skipped = [str(n) for n in (skipped or []) if n]
incoming_set = set(incoming_skipped)
active_print = self._state.get("print_state") in ("printing", "paused")
if active_print and existing_set:
if not incoming_set:
skipped = list(existing_skipped)
elif not incoming_set.issuperset(existing_set):
merged = list(existing_skipped)
for n in incoming_skipped:
if n not in existing_set:
merged.append(n)
skipped = merged
# Release the pending lock once the printer confirms the requested objects
if self._pending_preprint_skip and set(skipped) >= set(self._pending_preprint_skip):
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
self._skip_state = {
"skipped": list(skipped),
"ts": int(time.time()),
}
if payload.get("state") == "done" or payload.get("code") == 200:
log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}")
def _delete_printer_file_fire_and_forget(self, filename: str) -> None:
"""Deletes a file from the printer's own storage without waiting for
the response - called from _on_print(), which runs on the MQTT
reader thread itself, so blocking here (like _wait_for_file_action
does) would deadlock: the file/report reply that would unblock it is
dispatched from that same thread. Fire-and-forget is safe because the
bridge's own copy in the GCode store is what matters for correctness
here; a failed delete just leaves the printer's storage as it is
(Settings -> Print -> "Delete file from printer after successful print")."""
try:
self.client.publish(
"file", "deleteBatch",
{"root": "local", "files": [{"path": "/", "filename": filename}]},
timeout=0,
)
log.info(f"Requested printer-storage delete for {filename} after successful print")
except Exception as e:
log.warning(f"Delete-after-print request failed for {filename}: {e}")
def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None:
"""Sends a file/* MQTT request (via send_fn, which must call
self.client.publish(..., timeout=0) fire-and-forget) and blocks the
calling thread until a matching file/report with this `action`
arrives via _on_file, or the timeout elapses.
Needed because the printer's publish() return value for actions like
listLocal/deleteBatch is just a generic immediate ACK skeleton
(code=0, empty fields) - the real response is a separate, later
file/report message, same as the existing fileDetails pattern.
Must be called from a worker thread (e.g. via run_in_executor), not
the asyncio event loop, since it blocks on a threading.Event.
"""
event = threading.Event()
waiter = {"event": event, "result": None}
self._file_action_waiters[action] = waiter
try:
send_fn()
event.wait(timeout)
return waiter["result"]
finally:
if self._file_action_waiters.get(action) is waiter:
del self._file_action_waiters[action]
def _on_buried(self, payload: dict):
"""buried/report - the printer's own analytics event, fired once per
print start (verified live against a real Kobra X: fires identically
for prints started via Anycubic Slicer Next and via OrcaSlicer/the
bridge). Carries gcode_size/estimate_duration/total_layers, which
_build_file_metadata() falls back to for files not in our own
GCodeStore (Issue #102), plus printer storage usage."""
d = payload.get("data") or {}
task_name = d.get("task_name") or ""
if not task_name:
return
self._buried_cache = {
"task_name": task_name,
"gcode_size": int(d.get("gcode_size") or 0),
"estimate_duration": int(d.get("estimate_duration") or 0),
"total_layers": int(d.get("total_layers") or 0),
}
self._state["storage_total_mb"] = int(d.get("storage_total") or 0)
self._state["storage_used_mb"] = int(d.get("storage_used") or 0)
log.info(
f"buried/report: {task_name} size={d.get('gcode_size')} "
f"est={d.get('estimate_duration')}s layers={d.get('total_layers')}"
)
def _on_file(self, payload: dict):
# Deliver to any pending listLocal/deleteBatch waiter first (see
# _wait_for_file_action) - these actions carry no file_details/
# thumbnail payload of their own, so this doesn't interfere with the
# handling below.
action = payload.get("action") or ""
waiter = self._file_action_waiters.get(action)
if waiter is not None:
waiter["result"] = payload
waiter["event"].set()
d = payload.get("data") or {}
details = d.get("file_details") or {}
thumb = details.get("thumbnail") or details.get("png_image") or ""
file_name = d.get("filename") or details.get("filename") or self._last_uploaded_file
active_print = self._state.get("print_state") in ("printing", "paused")
current_print_file = self._state.get("filename") or ""
# Uploads during a running print must not overwrite the active
# progress preview.
if thumb and (not active_print or (file_name and file_name == current_print_file)):
self._thumbnail_b64 = thumb
log.info(f"Thumbnail received: {len(thumb)} base64 chars")
# Part-Skip: Objekt-Liste + optionales SVG (v0.9.10)
objs = details.get("objects_skip_parts") or []
svg = details.get("svg_image") or ""
if objs:
filename = file_name
if filename:
try:
self._store.update_file_objects(filename, objs, svg)
log.info(f"Skip objects for {filename}: {len(objs)} ({'with SVG' if svg else 'no SVG'})")
except Exception as e:
log.warning(f"update_file_objects failed: {e}")
self._push_status_update()
def _apply_preprint_skip_after_start(self, names: list[str], retries: int = 20, delay_s: float = 0.75):
"""Sends the skip command only after the printer switched to the printing state.
Before that, the command goes nowhere (no active print).
"""
wanted = [str(n) for n in (names or []) if isinstance(n, str) and n]
if not wanted:
return False
for i in range(max(1, int(retries))):
try:
if self._state.get("print_state") not in ("printing", "paused"):
time.sleep(max(0.1, float(delay_s)))
continue
resp = self.client.skip_objects(wanted)
if resp is not None:
log.info(f"Pre-Print skip applied ({len(wanted)} objects) on attempt {i+1}/{retries}")
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
return True
except Exception as e:
log.debug(f"Pre-Print skip attempt {i+1}/{retries} failed: {e}")
time.sleep(max(0.1, float(delay_s)))
log.warning(f"Pre-Print skip could not be confirmed after {retries} attempts")
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
return False

154
bridge_spoolman.py Normal file
View File

@@ -0,0 +1,154 @@
"""
bridge_spoolman.py - SpoolmanMixin for KobraXBridge.
Filament-usage attribution + reporting to a Spoolman server, and the
/kx/spoolman/* endpoints. Mixed into KobraXBridge; relies on the shared bridge
state (self._state, self._store, self._spoolman*, self._json_cors) provided by
the core class.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import asyncio
import logging
import threading
log = logging.getLogger("bridge")
class SpoolmanMixin:
# ── Spoolman helpers ──────────────────────────────────────────────────────
def _spoolman_filament_mm(self) -> float:
"""Total filament_used_mm for the current print file from the GCode DB."""
filename = self._state.get("filename", "")
if not filename:
return 0.0
try:
gf = self._store.get_file_by_name(filename)
return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0
except Exception:
return 0.0
def _spoolman_attribute_tick(self, activity_map: dict) -> None:
"""Attribute the supplies_usage delta since last tick to the active slot.
Skips attribution during loading/unloading transitions (tool changes +
purges) to avoid charging the wrong spool for purge material."""
if not self._spoolman or not self._spoolman_slot_spools:
return
if self._state.get("print_state") != "printing":
return
current = self._state.get("supplies_usage", 0)
delta = current - self._spoolman_last_usage
self._spoolman_last_usage = current
if delta <= 0:
return
loaded = self._ams_loaded_slot
if loaded < 0:
return
if activity_map.get(loaded):
return
self._spoolman_slot_usage[loaded] = self._spoolman_slot_usage.get(loaded, 0.0) + delta
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)."""
total_used = self._state.get("supplies_usage", 0)
if self._spoolman_slot_usage:
return {
slot: self._spoolman_slot_usage.get(slot, 0.0)
- 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}
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."""
sm = self._spoolman
for slot_idx, mm in unreported.items():
if mm < min_mm:
continue
spool_id = self._spoolman_slot_spools.get(slot_idx)
if not spool_id:
continue
self._spoolman_slot_reported[slot_idx] = (
self._spoolman_slot_reported.get(slot_idx, 0.0) + mm
)
def _send(sid=spool_id, length=mm):
try:
sm.use_filament(sid, length)
log.info(f"Spoolman: {length:.1f} mm → spool {sid}")
except Exception as e:
log.warning(f"Spoolman: report failed (spool {sid}): {e}")
threading.Thread(target=_send, daemon=True, name="spoolman-report").start()
def _spoolman_notify_end(self):
"""Report remaining filament on print end."""
if not self._spoolman or not self._spoolman_slot_spools:
return
self._spoolman_report(self._spoolman_unreported())
def _spoolman_sync_midprint(self):
"""Report incremental filament usage during a print (sync_rate interval)."""
if not self._spoolman or not self._spoolman_slot_spools:
return
self._spoolman_report(self._spoolman_unreported(), min_mm=10.0)
# ── Spoolman API handlers ─────────────────────────────────────────────────
async def handle_kx_spoolman_status(self, request):
"""GET /kx/spoolman/status"""
return self._json_cors({
"configured": bool(self._spoolman),
"reachable": self._spoolman_reachable if self._spoolman else False,
"server": self._spoolman.server_url if self._spoolman else "",
"sync_rate": self._spoolman.sync_rate if self._spoolman else 0,
"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()},
})
async def handle_kx_spoolman_spools(self, request):
"""GET /kx/spoolman/spools — proxied from Spoolman."""
if not self._spoolman:
return self._json_cors({"error": "Spoolman not configured"}, status=503)
try:
spools = await asyncio.get_event_loop().run_in_executor(
None, self._spoolman.list_spools
)
return self._json_cors({"spools": spools})
except Exception as e:
log.warning(f"Spoolman: list_spools failed: {e}")
return self._json_cors({"error": str(e)}, status=502)
async def handle_kx_spoolman_set_active(self, request):
"""POST /kx/spoolman/active-spool
Body: {"slot_map": {"0": 42, "2": 17}} — AMS slot index → Spoolman spool ID."""
try:
data = await request.json()
except Exception:
return self._json_cors({"error": "invalid JSON"}, status=400)
slot_map = data.get("slot_map") or data.get("slot_spools") or {}
self._spoolman_slot_spools = {
int(k): int(v) for k, v in slot_map.items()
if str(v).isdigit() and int(v) > 0
}
# Persist per printer (own [spoolman_<id>] section) so the
# assignment survives bridge restarts and two AMS units don't overwrite each other.
# (Previously: NameError on `config_loader` -> nothing was ever saved.)
try:
import config_loader as _cl
_cl.save_spool_map(self._spoolman_slot_spools, self._printer_id)
except Exception as _e:
log.warning("Spoolman: failed to save slot map: %s", _e)
self._spoolman_slot_usage = {}
self._spoolman_slot_reported = {}
self._spoolman_last_usage = 0.0
return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}})

409
camera.py Normal file
View File

@@ -0,0 +1,409 @@
"""
camera.py - central camera demuxer (CameraCache) plus the ffmpeg locator.
Keeps one ffmpeg process per output type (jpeg/h264/mjpeg) open, reading the
printer's FLV stream and fanning it out to dashboard/OrcaSlicer/Obico
consumers. Extracted from kobrax_moonraker_bridge.py; re-exported from there
so existing imports keep working.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import os
import sys
import time
import asyncio
import logging
log = logging.getLogger("bridge")
# Same base-path logic as the main module: next to sys.executable in a
# PyInstaller binary, otherwise next to this file.
_BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))
try:
import imageio_ffmpeg
def _find_ffmpeg() -> str:
return imageio_ffmpeg.get_ffmpeg_exe()
except ImportError:
def _find_ffmpeg() -> str:
exe_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
local = os.path.join(_BASE, exe_name)
if os.path.isfile(local):
return local
return "ffmpeg"
class CameraCache:
"""Zentraler Kamera-Demuxer.
Keeps ONE ffmpeg process per output type open that reads the FLV stream
from the printer and produces:
- MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot
- MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers
- MJPEG @ 15fps/640px -> fanout to all /api/camera/stream subscribers
(the live-view used by the dashboard AND by every Moonraker-compatible
client, since server.webcams.list advertises this same stream_url)
Damit:
* Only ONE FLV connection to the printer per output type (solves the
single-client limit / 429) - previously /api/camera/stream opened a
brand-new, uncached ffmpeg + printer connection per HTTP client, which
competed with the cached jpeg/h264 connections for the printer's very
limited number of concurrent camera clients and caused intermittent
"stream unavailable" failures.
* Snapshots are instant (memory read, no ffmpeg spawn per request)
* Multiple parallel H.264/MJPEG consumers possible (plugin + web UI + ...)
Lazy start on the first consumer, auto-restart on ffmpeg crash.
"""
JPEG_SOI = b"\xff\xd8"
JPEG_EOI = b"\xff\xd9"
TS_CHUNK = 65536
def __init__(self):
self._url: str = ""
self.latest_jpeg: bytes = b""
self.latest_jpeg_ts: float = 0.0
self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set()
self.mjpeg_subscribers: "set[asyncio.Queue[bytes]]" = set()
self._proc_jpeg: "asyncio.subprocess.Process | None" = None
self._proc_h264: "asyncio.subprocess.Process | None" = None
self._proc_mjpeg: "asyncio.subprocess.Process | None" = None
self._task_jpeg: "asyncio.Task | None" = None
self._task_h264: "asyncio.Task | None" = None
self._task_mjpeg: "asyncio.Task | None" = None
self._lock = asyncio.Lock()
self._fail_count_jpeg: int = 0
self._fail_count_h264: int = 0
self._fail_count_mjpeg: int = 0
def set_url(self, url: str):
# A changed URL means the printer rotated its stream token (typically
# after a reboot). Running ffmpeg processes still hold the stale URL
# and will never pick it up on their own - they only re-read self._url
# at the top of their outer loop, which they never reach while blocked
# in a stdout read on the old, now-silent connection. Tear them down;
# the next ensure_running() respawns them against the new URL.
changed = bool(url and self._url and url != self._url)
self._url = url
if changed:
self.reset()
def reset(self):
"""Reset backoff counters and forcefully tear down any running
ffmpeg loops - including cancelling their background tasks.
Only killing the ffmpeg subprocess is not enough: the owning task
might currently be sitting in `await asyncio.sleep(delay)` from a
previous exponential backoff (up to 300s) after an earlier failure.
Resetting the fail-count doesn't wake it up early, so a user
clicking "reset" could see nothing happen for minutes. Cancelling
the task guarantees an immediate, clean restart on the next
ensure_running() call.
"""
self._fail_count_jpeg = 0
self._fail_count_h264 = 0
self._fail_count_mjpeg = 0
for task in (self._task_jpeg, self._task_h264, self._task_mjpeg):
if task is not None and not task.done():
task.cancel()
for proc in (self._proc_jpeg, self._proc_h264, self._proc_mjpeg):
if proc is not None:
try:
proc.kill()
except Exception:
pass
self._task_jpeg = self._task_h264 = self._task_mjpeg = None
self._proc_jpeg = self._proc_h264 = self._proc_mjpeg = None
async def ensure_running(self):
# NOTE: we check the *task* state, not self._proc_* - the process
# handle is only assigned later, inside the task body, once ffmpeg
# has actually been spawned. Checking self._proc_* here left a race
# window: two callers arriving before the newly-created task got a
# chance to run would both see "no process yet" and each spawn a
# duplicate ffmpeg + duplicate printer connection, silently
# orphaning the older one (whichever task's coroutine runs last
# overwrites the shared self._proc_* reference, so nobody keeps a
# handle to kill the earlier orphaned process). Task creation is
# synchronous, so checking self._task_* here is race-free.
if self._task_jpeg is None or self._task_jpeg.done():
self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
if self._task_h264 is None or self._task_h264.done():
self._task_h264 = asyncio.create_task(self._run_h264_loop())
if self._task_mjpeg is None or self._task_mjpeg.done():
self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop())
def _input_args(self, url: str) -> list[str]:
args = ["-fflags", "nobuffer", "-flags", "low_delay",
# Bail out if the source goes silent. A printer reboot or
# network loss leaves the TCP connection ESTABLISHED with no
# data and no FIN, so a passive stdout read blocks forever
# without this (Issue #99). Value is microseconds.
"-timeout", "10000000"]
if url.lower().startswith("rtsp://"):
args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else:
# The printer's FLV source occasionally emits non-monotonic container
# timestamps (PTS jumps of days) while the video data itself stays
# valid. Without this flag ffmpeg's realtime pacing breaks on such a
# jump and the stream stalls after ~15-30 min (Issue #90).
args += ["-use_wallclock_as_timestamps", "1",
"-probesize", "500000", "-analyzeduration", "500000"]
return args
async def _run_jpeg_loop(self):
"""Keeps an ffmpeg process alive that writes MJPEG@2fps into the cache."""
while True:
url = self._url
if not url:
await asyncio.sleep(2.0)
continue
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-vf", "fps=2",
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
"-flush_packets", "1", "pipe:1",
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)
continue
buf = b""
rc = None
try:
while True:
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
buf += chunk
# extract complete JPEG frames
while True:
start = buf.find(self.JPEG_SOI)
if start == -1:
buf = b""
break
end = buf.find(self.JPEG_EOI, start + 2)
if end == -1:
buf = buf[start:]
break
self.latest_jpeg = buf[start:end + 2]
self.latest_jpeg_ts = time.time()
buf = buf[end + 2:]
except Exception as e:
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
finally:
# 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:
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
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)
log.warning(f"CameraCache: ffmpeg-jpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_jpeg})")
await asyncio.sleep(delay)
else:
self._fail_count_jpeg = 0
await asyncio.sleep(2.0)
async def _run_h264_loop(self):
"""Keeps an ffmpeg process alive that fans out MPEG-TS to all subscribers."""
while True:
url = self._url
if not url:
await asyncio.sleep(2.0)
continue
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-c:v", "copy", "-an",
"-f", "mpegts", "pipe:1",
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)
continue
rc = None
try:
while True:
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
# Fanout: non-blocking per subscriber; slow clients
# get their oldest chunk dropped (queue full -> drop).
for q in list(self.h264_subscribers):
if q.full():
try:
q.get_nowait()
except Exception:
pass
try:
q.put_nowait(chunk)
except Exception:
pass
except Exception as e:
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
finally:
# 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:
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
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)
log.warning(f"CameraCache: ffmpeg-h264 exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_h264})")
await asyncio.sleep(delay)
else:
self._fail_count_h264 = 0
await asyncio.sleep(2.0)
async def _run_mjpeg_loop(self):
"""Keeps an ffmpeg process alive that fans out MJPEG@15fps/640px
(complete JPEG frames) to all /api/camera/stream subscribers."""
while True:
url = self._url
if not url:
await asyncio.sleep(2.0)
continue
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-vf", "fps=15,scale=640:-1",
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
"-flush_packets", "1", "pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._proc_mjpeg = proc
except Exception as e:
log.warning(f"CameraCache: ffmpeg-mjpeg start failed: {e}")
await asyncio.sleep(3.0)
continue
buf = b""
rc = None
try:
while True:
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
buf += chunk
# extract complete JPEG frames and fan them out whole
# (so every subscriber gets clean multipart boundaries,
# not arbitrary byte chunks like the h264/mpegts fanout)
while True:
start = buf.find(self.JPEG_SOI)
if start == -1:
buf = b""
break
end = buf.find(self.JPEG_EOI, start + 2)
if end == -1:
buf = buf[start:]
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
for q in list(self.mjpeg_subscribers):
if q.full():
try:
q.get_nowait()
except Exception:
pass
try:
q.put_nowait(frame)
except Exception:
pass
except Exception as e:
log.debug(f"CameraCache: mjpeg-loop unterbrochen: {e}")
finally:
# NOTE: cleanup operates on the local `proc` reference, not
# on self._proc_mjpeg. If this task got cancelled (e.g. by
# reset()) a new task may already have started and assigned
# its own process to self._proc_mjpeg by the time we reach
# here - killing that shared attribute instead of our own
# local proc would kill the WRONG (newer) process.
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode
if rc:
try:
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-mjpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
if self._proc_mjpeg is proc:
self._proc_mjpeg = None
if rc:
self._fail_count_mjpeg += 1
delay = min(2.0 * (2 ** self._fail_count_mjpeg), 300.0)
log.warning(f"CameraCache: ffmpeg-mjpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_mjpeg})")
await asyncio.sleep(delay)
else:
self._fail_count_mjpeg = 0
await asyncio.sleep(2.0)

View File

@@ -41,6 +41,21 @@ web_upload_warning = 1
# Poll-Intervall in Sekunden
poll_interval = 3
# ─── Spoolman (optional) ───────────────────────────────────────────────────────
# Verfolgt den Filamentverbrauch je AMS-Slot und bucht ihn automatisch vom
# passenden Spool ab (mm-basiert, wie Moonraker; Spoolman rechnet mm→Gramm).
# [spoolman]
# # Server-URL der Spoolman-Instanz (aus Sicht des Bridge-Containers erreichbar):
# server = http://192.168.x.x:7912
# # 0 = nur am Druckende abbuchen, >0 = alle N Sekunden während des Drucks:
# sync_rate = 0
#
# Die AMS-Slot → Spool-Zuordnung wird in der Weboberfläche gesetzt und je Drucker
# automatisch persistiert (nicht von Hand eintragen):
# Einzeldrucker : [spoolman] slot_spools = 0:42,1:17
# Multi-Printer : [spoolman_1] slot_spools = 0:42,1:17
# [spoolman_2] slot_spools = 0:5,1:6
# ─── Multi-Printer (optional) ──────────────────────────────────────────────────
# Mehrere Drucker können als [printer_1], [printer_2], … definiert werden.
# Jede Bridge-Instanz verbindet sich mit einem Drucker (je eigener Port).

View File

@@ -116,3 +116,23 @@ custom_3_duration_sec = 14400
# Wie oft (Sekunden) der Filamentverbrauch während des Drucks gemeldet wird
# (0 = nur beim Druckende)
# sync_rate = 0
[kxgauge]
# URL des KXGauge-Displays (leer = deaktiviert), siehe
# https://gitea.it-drui.de/viewit/kxgauge
# url = http://192.168.x.x
enabled = 0
# Ziel-Temperatur für die Heat-Ring-Skala (Grad Celsius, meist Hotend-Solltemperatur)
heat_peak = 250
[kxgauge_mapping]
# kobra_state -> KXGauge-Emotion. Nur belegte States werden gesendet, gültige
# Emotionsnamen siehe KXGauge API.md.
free = neutral
printing = happy
paused = worried
pause = worried
finished = glee
error = scared
offline = tired

View File

@@ -1,12 +1,16 @@
"""
config_loader.py lädt Verbindungsparameter aus config/config.ini (primär)
oder .env (Fallback / Migration).
Umgebungsvariablen haben immer Vorrang.
config_loader.py - loads connection parameters from config/config.ini (primary)
or .env (fallback / migration).
Environment variables always take precedence.
"""
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
@@ -14,6 +18,7 @@ CONFIG_SECTION_CONNECTION = "connection"
CONFIG_SECTION_PRINT = "print"
CONFIG_SECTION_BRIDGE = "bridge"
CONFIG_SECTION_SPOOLMAN = "spoolman"
CONFIG_SECTION_KXGAUGE = "kxgauge"
def _find_config_file() -> pathlib.Path | None:
@@ -33,7 +38,7 @@ def _find_env_file() -> pathlib.Path | None:
def _load_env_file(path: pathlib.Path):
"""Lädt .env-Datei als Fallback setzt nur Keys die noch nicht in os.environ sind."""
"""Loads the .env file as a fallback - only sets keys not yet in os.environ."""
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
@@ -46,28 +51,46 @@ def _load_env_file(path: pathlib.Path):
os.environ[key] = val
# Single source of truth for env-var <-> config.ini mapping. _restart_bridge()
# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ
# before restarting, so a value removed here or in the UI settings save can
# never survive as a stale env var read by the new process. Add new settings
# here ONLY - no second list to keep in sync.
CONFIG_ENV_MAPPING = {
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"POWER_ON_URL": (CONFIG_SECTION_CONNECTION, "power_on_url"),
"POWER_OFF_URL": (CONFIG_SECTION_CONNECTION, "power_off_url"),
"POWER_STATUS_URL": (CONFIG_SECTION_CONNECTION, "power_status_url"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"DELETE_PRINTER_FILE_AFTER_PRINT": (CONFIG_SECTION_PRINT, "delete_printer_file_after_print"),
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
"POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"),
"VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"),
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
"KXGAUGE_URL": (CONFIG_SECTION_KXGAUGE, "url"),
"KXGAUGE_ENABLED": (CONFIG_SECTION_KXGAUGE, "enabled"),
"KXGAUGE_HEAT_PEAK": (CONFIG_SECTION_KXGAUGE, "heat_peak"),
}
def _load_config_file(path: pathlib.Path):
"""Lädt config.ini und setzt Keys in os.environ (nur wenn nicht bereits gesetzt)."""
cfg = configparser.ConfigParser()
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
mapping = {
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
}
for env_key, (section, option) in mapping.items():
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
if env_key not in os.environ:
try:
val = cfg.get(section, option)
@@ -100,8 +123,7 @@ 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()
cfg = configparser.ConfigParser(interpolation=None)
cfg[CONFIG_SECTION_CONNECTION] = {
"printer_ip": env_vals.get("PRINTER_IP", ""),
"mqtt_port": env_vals.get("MQTT_PORT", "9883"),
@@ -112,21 +134,32 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
}
cfg[CONFIG_SECTION_PRINT] = {
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
}
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("# Automatisch migriert aus .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:
"""Gibt den Pfad zur config.ini zurück (auch wenn sie noch nicht existiert)."""
"""Returns the path to config.ini (even if it does not exist yet)."""
for base in (_BASE, _BASE.parent):
config_dir = base / "config"
if config_dir.is_dir():
@@ -142,7 +175,7 @@ _env_path = _find_env_file()
if _config_path:
_load_config_file(_config_path)
elif _env_path:
# Kein config.ini vorhanden → aus .env migrieren
# No config.ini present -> migrate from .env
_target = find_config_path()
migrate_env_to_config(_env_path, _target)
_load_config_file(_target)
@@ -150,19 +183,19 @@ elif _env_path:
def list_printers() -> list[dict]:
"""Liest alle [printer_N]-Sektionen aus config.ini.
"""Reads all [printer_N] sections from config.ini.
Jede Sektion kann folgende Keys haben:
Each section may contain the following keys:
name, printer_ip, mqtt_port, username, password, mode_id, device_id,
bridge_url, default_ams_slot, auto_leveling
Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind
Returns an empty list when no [printer_N] sections exist
(Single-Printer-Betrieb via [connection]).
"""
path = _find_config_file()
if not path:
return []
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
printers: list[dict] = []
idx = 1
@@ -182,37 +215,58 @@ def list_printers() -> list[dict]:
return printers
def list_filament_profiles() -> dict[int, dict]:
"""Liest die [filament_profiles]-Sektion aus config.ini.
def _filament_section(printer_id: Optional[str] = None) -> str:
"""Section name holding a printer's filament-profile mapping.
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
Multi-printer (one bridge, N printers): each printer keeps its own
``[filament_profiles_<id>]`` section so the mappings cannot overwrite each
other. ``printer_id is None`` (single-printer / legacy callers) maps to the
original global ``[filament_profiles]`` section — full backward compatibility.
"""
pid = str(printer_id).strip() if printer_id is not None else ""
if pid and pid != "0":
return f"filament_profiles_{pid}"
return "filament_profiles"
def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
"""Reads the [filament_profiles] section from config.ini.
With ``printer_id`` set, reads the per-printer ``[filament_profiles_<id>]``
section and falls back to the legacy global ``[filament_profiles]`` while
that printer has no own section yet.
Format per AMS slot - the primary selector is (vendor, name); the `id` is
looked up from orca_filaments.json on save and carried along
(as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing
the same filament_id like 'OGFL99', i.e. the ID is not unique):
[filament_profiles]
slot_0_vendor = Polymaker
slot_0_name = PolyTerra PLA
slot_0_id = OGFL01
Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}
zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
(per filament_type) in der Bridge bleibt dann aktiv.
Returns a dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}.
Empty/missing slots are NOT included - the default mapping
(per filament_type) in the bridge then stays active.
Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name`
fehlt dann und der Aufrufer kann optional aus der orca_filaments.json
Backwards compat: old configs with only (vendor, id) stay readable; `name`
is then missing and the caller can optionally resolve it from orca_filaments.json
rekonstruieren.
"""
path = _find_config_file()
if not path:
return {}
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
if not cfg.has_section("filament_profiles"):
section = _filament_section(printer_id)
if not cfg.has_section(section):
section = "filament_profiles" # fallback: legacy global section
if not cfg.has_section(section):
return {}
result: dict[int, dict] = {}
for key, value in cfg.items("filament_profiles"):
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
for key, value in cfg.items(section):
# Expects: slot_<idx>_id or slot_<idx>_vendor or slot_<idx>_name
if not key.startswith("slot_"):
continue
parts = key.split("_", 2)
@@ -231,74 +285,173 @@ def list_filament_profiles() -> dict[int, dict]:
return result
def save_filament_profiles(profiles: dict[int, dict]) -> bool:
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool:
"""Writes the given slot profiles into the [filament_profiles]
section of config.ini. Existing entries are completely replaced.
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
At least vendor+name must be set; id is optional (hint).
With ``printer_id`` set, writes the per-printer ``[filament_profiles_<id>]``
section only — other printers and the legacy global section are untouched.
"""
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
section = _filament_section(printer_id)
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
# replacing the section, otherwise the vendor filter is lost on slot save.
# First save of a per-printer section inherits the legacy global filter.
preserved_vendors = None
if cfg.has_option("filament_profiles", "visible_vendors"):
if cfg.has_option(section, "visible_vendors"):
preserved_vendors = cfg.get(section, "visible_vendors")
elif cfg.has_option("filament_profiles", "visible_vendors"):
preserved_vendors = cfg.get("filament_profiles", "visible_vendors")
if cfg.has_section("filament_profiles"):
cfg.remove_section("filament_profiles")
if cfg.has_section(section):
cfg.remove_section(section)
if profiles or preserved_vendors:
cfg["filament_profiles"] = {}
cfg[section] = {}
if preserved_vendors:
cfg["filament_profiles"]["visible_vendors"] = preserved_vendors
cfg[section]["visible_vendors"] = preserved_vendors
for slot_idx in sorted(profiles.keys()):
entry = profiles[slot_idx] or {}
if entry.get("vendor"):
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
cfg[section][f"slot_{slot_idx}_vendor"] = entry["vendor"]
if entry.get("name"):
cfg["filament_profiles"][f"slot_{slot_idx}_name"] = entry["name"]
cfg[section][f"slot_{slot_idx}_name"] = entry["name"]
if entry.get("id"):
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
cfg[section][f"slot_{slot_idx}_id"] = entry["id"]
with open(path, "w", encoding="utf-8") as f:
cfg.write(f)
return True
def list_visible_vendors() -> list[str]:
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
"""Reads [filament_profiles] visible_vendors (comma-separated) from config.ini.
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
Vendor visibility filter for the slot profile dropdown (Issue #41 option A).
Empty list = no restriction (backwards compatible: all vendors).
With ``printer_id`` set, reads the per-printer section and falls back to the
legacy global ``[filament_profiles]`` filter.
"""
path = _find_config_file()
if not path:
return []
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
if not cfg.has_option("filament_profiles", "visible_vendors"):
section = _filament_section(printer_id)
if not cfg.has_option(section, "visible_vendors"):
section = "filament_profiles" # fallback: legacy global section
if not cfg.has_option(section, "visible_vendors"):
return []
raw = cfg.get("filament_profiles", "visible_vendors")
raw = cfg.get(section, "visible_vendors")
return [v.strip() for v in raw.split(",") if v.strip()]
def save_visible_vendors(vendors: list[str]) -> bool:
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder."""
def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool:
"""Writes visible_vendors into [filament_profiles] without touching the
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.
With ``printer_id`` set, writes the per-printer section. When that section is
created here for the first time, the slot mappings are seeded from the legacy
global section so they are not orphaned by the read-fallback in
``list_filament_profiles``."""
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
if not cfg.has_section("filament_profiles"):
cfg.add_section("filament_profiles")
section = _filament_section(printer_id)
if not cfg.has_section(section):
cfg.add_section(section)
if section != "filament_profiles" and cfg.has_section("filament_profiles"):
for key, value in cfg.items("filament_profiles"):
if key.startswith("slot_"):
cfg[section][key] = value
clean = [v.strip() for v in (vendors or []) if v and v.strip()]
if clean:
cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean)
elif cfg.has_option("filament_profiles", "visible_vendors"):
cfg.remove_option("filament_profiles", "visible_vendors")
cfg[section]["visible_vendors"] = ", ".join(clean)
elif cfg.has_option(section, "visible_vendors"):
cfg.remove_option(section, "visible_vendors")
with open(path, "w", encoding="utf-8") as f:
cfg.write(f)
return True
def _spoolman_map_section(printer_id: Optional[str] = None) -> str:
"""Section name holding a printer's AMS-slot → Spoolman-spool map.
Multi-printer (one bridge, N printers): each printer keeps its map in its
own ``[spoolman_<id>]`` section so two AMS units cannot overwrite each
other's mapping. ``printer_id is None`` (single-printer / legacy callers)
uses the original ``[spoolman] slot_spools`` key — full backward
compatibility. The global ``[spoolman]`` section keeps ``server`` /
``sync_rate`` regardless.
"""
pid = str(printer_id).strip() if printer_id is not None else ""
if pid and pid != "0":
return f"{CONFIG_SECTION_SPOOLMAN}_{pid}"
return CONFIG_SECTION_SPOOLMAN
def _parse_slot_spools(raw: str) -> dict[int, int]:
"""Parse ``"0:42,1:17"`` → ``{0: 42, 1: 17}`` (positive spool ids only)."""
result: dict[int, int] = {}
for pair in (raw or "").split(","):
pair = pair.strip()
if ":" not in pair:
continue
k, _, v = pair.partition(":")
k, v = k.strip(), v.strip()
if k.isdigit() and v.lstrip("-").isdigit() and int(v) > 0:
result[int(k)] = int(v)
return result
def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]:
"""Read the AMS-slot → Spoolman-spool-id map from config.ini.
With ``printer_id`` set, reads the per-printer ``[spoolman_<id>]
slot_spools`` key and falls back to the legacy global ``[spoolman]
slot_spools`` while that printer has no own section yet. Returns
``{slot_index: spool_id}`` (only positive ids).
"""
path = _find_config_file()
if not path:
return {}
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
section = _spoolman_map_section(printer_id)
if cfg.has_option(section, "slot_spools"):
return _parse_slot_spools(cfg.get(section, "slot_spools", fallback=""))
if cfg.has_option(CONFIG_SECTION_SPOOLMAN, "slot_spools"): # legacy global fallback
return _parse_slot_spools(cfg.get(CONFIG_SECTION_SPOOLMAN, "slot_spools", fallback=""))
return {}
def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None) -> bool:
"""Persist the AMS-slot → Spoolman-spool-id map to config.ini.
With ``printer_id`` set, writes only the per-printer ``[spoolman_<id>]``
section so other printers and the global ``[spoolman]`` server config stay
untouched. An empty map clears the key.
"""
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
section = _spoolman_map_section(printer_id)
clean = {int(k): int(v) for k, v in (slot_spools or {}).items() if int(v) > 0}
if clean:
if not cfg.has_section(section):
cfg.add_section(section)
cfg[section]["slot_spools"] = ",".join(f"{k}:{v}" for k, v in sorted(clean.items()))
elif cfg.has_option(section, "slot_spools"):
cfg.remove_option(section, "slot_spools")
with open(path, "w", encoding="utf-8") as f:
cfg.write(f)
return True
@@ -308,17 +461,44 @@ def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
# Häufig verwendete Shortcuts
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", "")
DEVICE_ID = get("DEVICE_ID", "")
POWER_ON_URL = get("POWER_ON_URL", "")
POWER_OFF_URL = get("POWER_OFF_URL", "")
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
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)
KXGAUGE_URL = get("KXGAUGE_URL", "")
KXGAUGE_ENABLED = _safe_int(get("KXGAUGE_ENABLED", "0"), 0)
KXGAUGE_HEAT_PEAK = _safe_int(get("KXGAUGE_HEAT_PEAK", "250"), 250)
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
POLL_INTERVAL = _safe_int(get("POLL_INTERVAL", "3"), 3)
VERBOSE_HTTP_LOG = _safe_int(get("VERBOSE_HTTP_LOG", "0"), 0)

75
credentials.py Normal file
View File

@@ -0,0 +1,75 @@
"""
credentials.py - fetch + decrypt Anycubic Kobra printer credentials.
Talks to the printer's HTTP /info + /ctrl endpoints (port 18910) and decrypts
the AES-256-CBC response to recover username/password/device_id/mode_id.
Algorithm reverse-engineered in tools/fetch_credentials.py. Extracted from
kobrax_moonraker_bridge.py; _kx_fetch_credentials is re-imported there for the
"add printer" flow.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root.
Protocol reverse-engineered for interoperability (§69e UrhG). See NOTICE.md.
"""
import json
import time
import hashlib
import aiohttp
try:
import base64 as _base64
from Crypto.Cipher import AES as _AES
from Crypto.Util.Padding import unpad as _unpad
_HAS_CRYPTO = True
except ImportError:
_HAS_CRYPTO = False
def _kx_generate_signature(token: str, ts: int, nonce: str) -> str:
first = hashlib.md5(token[:16].encode()).hexdigest()
return hashlib.md5((first + str(ts) + nonce).encode()).hexdigest()
def _kx_decrypt_info(encrypted_b64: str, key: str, iv: str) -> dict:
cipher = _AES.new(key.encode(), _AES.MODE_CBC, iv.encode())
raw = _base64.b64decode(encrypted_b64)
return json.loads(_unpad(cipher.decrypt(raw), _AES.block_size).decode())
async def _kx_fetch_credentials(ip: str, port: int = 18910) -> dict:
"""Fetches + decrypts printer credentials via HTTP /info + /ctrl.
Raises an exception on network/decrypt errors. Algorithm from
tools/fetch_credentials.py (AES-256-CBC, Key=token[16:32], IV=ctrl-token).
"""
if not _HAS_CRYPTO:
raise RuntimeError("pycryptodome is not installed")
import random, string
nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6))
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession() as s:
async with s.get(f"http://{ip}:{port}/info", timeout=timeout) as r:
r.raise_for_status()
info = await r.json()
token = info["token"]
ts = int(time.time() * 1000)
sign = _kx_generate_signature(token, ts, nonce)
params = {"ts": ts, "nonce": nonce, "sign": sign, "did": "random"}
async with s.post(f"http://{ip}:{port}/ctrl", params=params, timeout=timeout) as r:
r.raise_for_status()
data = await r.json()
result = _kx_decrypt_info(data["data"]["info"], token[16:32], data["data"]["token"])
if "error" in result:
raise RuntimeError(result.get("error", "decrypt failed"))
return {
"printer_ip": result.get("ip", ip),
"username": result.get("username", ""),
"password": result.get("password", ""),
"device_id": result.get("deviceId", ""),
"mode_id": str(result.get("modeId", "20030")),
"model": result.get("modelName", "Anycubic Kobra"),
}

View File

@@ -1,8 +1,8 @@
# KobraX Full Stack — KX-Bridge + Obico Self-Hosted + Spoolman
#
# Für Portainer: Stack → Add Stack → Upload → diese Datei wählen
# For Portainer: Stack → Add Stack → Upload → select this file
#
# Voraussetzung: Obico-Images einmalig in Gitea-Registry pushen:
# Prerequisite: push the Obico images to the Gitea registry once:
# docker tag obico-server-web:latest gitea.it-drui.de/viewit/obico-web:latest
# docker tag obico-server-ml_api:latest gitea.it-drui.de/viewit/obico-ml:latest
# docker tag obico-server-tasks:latest gitea.it-drui.de/viewit/obico-tasks:latest
@@ -10,14 +10,14 @@
# docker push gitea.it-drui.de/viewit/obico-ml:latest
# docker push gitea.it-drui.de/viewit/obico-tasks:latest
#
# Persistente Daten: /mnt/dockerdata/KobraXStack/<service>/
# Persistent data: /mnt/dockerdata/KobraXStack/<service>/
#
# Ports:
# 7125 — KX-Bridge (Moonraker-API)
# 3334 — Obico (Web-UI)
# 7912 — Spoolman (Web-UI)
# 7125 — KX-Bridge (Moonraker API)
# 3334 — Obico (Web UI)
# 7912 — Spoolman (Web UI)
#
# Obico Admin-Account nach dem ersten Start:
# Obico admin account after first start:
# docker exec obico-web python manage.py createsuperuser
x-obico-base: &obico-base
@@ -162,12 +162,12 @@ services:
max-size: "10m"
max-file: "3"
# ── moonraker-obico Plugin ──────────────────────────────────
# Verbindet KX-Bridge mit dem Obico-Server (Spaghetti-Detektion, Remote-UI)
# Voraussetzung: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
# muss existieren und einen gültigen auth_token enthalten.
# ── moonraker-obico plugin ──────────────────────────────────
# Connects KX-Bridge to the Obico server (spaghetti detection, remote UI)
# Prerequisite: /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg
# must exist and contain a valid auth_token.
#
# Token holen (nach erstem obico-web Start):
# Getting a token (after the first obico-web start):
# docker exec obico-web python manage.py shell -c "
# from app.models import OneTimeVerificationCode, User
# from django.utils import timezone; from datetime import timedelta; import random
@@ -175,7 +175,7 @@ services:
# c = OneTimeVerificationCode.objects.create(user=u, code='%06d' % random.randint(100000,999999), expired_at=timezone.now()+timedelta(hours=2))
# print('CODE:', c.code)"
# curl -X POST 'http://localhost:3334/api/v1/octo/verify/?code=<CODE>'
# → printer.auth_token aus der Antwort in die cfg eintragen
# → enter printer.auth_token from the response into the cfg
moonraker-obico:
image: gitea.it-drui.de/viewit/moonraker-obico:latest
container_name: moonraker-obico
@@ -195,7 +195,7 @@ networks:
kobrax-stack:
driver: bridge
# Verzeichnisse müssen auf dem Host existieren:
# Directories must exist on the host:
# mkdir -p /mnt/dockerdata/KobraXStack/kx-bridge/config \
# /mnt/dockerdata/KobraXStack/kx-bridge/data \
# /mnt/dockerdata/KobraXStack/spoolman \
@@ -203,8 +203,8 @@ networks:
# /mnt/dockerdata/KobraXStack/obico/frontend \
# /mnt/dockerdata/KobraXStack/obico/redis \
# /mnt/dockerdata/KobraXStack/moonraker-obico/logs
# Spoolman benötigt UID/GID 1000:
# Spoolman requires UID/GID 1000:
# sudo chown -R 1000:1000 /mnt/dockerdata/KobraXStack/spoolman
#
# moonraker-obico Config anlegen (auth_token nach Obico-Setup eintragen):
# Create the moonraker-obico config (enter auth_token after Obico setup):
# cp /path/to/moonraker-obico.cfg.example /mnt/dockerdata/KobraXStack/moonraker-obico/moonraker-obico.cfg

View File

@@ -10,6 +10,8 @@ services:
- ./.env:/app/.env:ro
ports:
- "7125-7130:7125-7130"
# environment:
# - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige)
restart: unless-stopped
logging:
driver: json-file

View File

@@ -1,6 +1,6 @@
"""
env_loader.py lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsverzeichnis).
Umgebungsvariablen haben Vorrang vor .env-Werten.
env_loader.py - loads connection parameters from .env (repo root or working directory).
Environment variables take precedence over .env values.
"""
import os
import sys
@@ -39,15 +39,21 @@ def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
# Häufig verwendete Shortcuts
# Frequently used shortcuts
PRINTER_IP = get("PRINTER_IP", "")
MQTT_PORT = int(get("MQTT_PORT", "9883"))
USERNAME = get("MQTT_USERNAME", "")
PASSWORD = get("MQTT_PASSWORD", "")
MODE_ID = get("MODE_ID", "")
DEVICE_ID = get("DEVICE_ID", "")
POWER_ON_URL = get("POWER_ON_URL", "")
POWER_OFF_URL = get("POWER_OFF_URL", "")
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
DELETE_PRINTER_FILE_AFTER_PRINT = int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")

182
gcode_meta.py Normal file
View File

@@ -0,0 +1,182 @@
"""
gcode_meta.py - GCode file metadata extraction helpers (estimated print time,
layer heights, embedded thumbnail, per-slot filament info).
Extracted from kobrax_moonraker_bridge.py; re-exported from there so existing
call sites keep working. Used by the file-upload and print-start paths.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import re
import base64
import logging
log = logging.getLogger("bridge")
def _parse_gcode_estimated_time(data: bytes) -> int:
"""Reads the estimated print time from GCode (OrcaSlicer + PrusaSlicer).
Returns seconds, 0 when not found.
PrusaSlicer writes the time into the header (first 16KB),
OrcaSlicer writes it at the end of the file (last 16KB)."""
import re
# Search the beginning + end of the file (OrcaSlicer writes the time at the end)
search_text = (data[:16384] + data[-65536:]).decode("utf-8", errors="ignore")
# OrcaSlicer: ; total estimated time: 9m 20s
# PrusaSlicer: ; estimated printing time (normal mode) = 1h 9m 20s
m = (re.search(r";\s*total estimated time:\s*(.*)", search_text) or
re.search(r";\s*estimated printing time \(normal mode\)\s*=\s*(.*)", search_text))
if not m:
return 0
parts = re.findall(r"(\d+)\s*([hms])", m.group(1))
secs = 0
for val, unit in parts:
if unit == "h": secs += int(val) * 3600
elif unit == "m": secs += int(val) * 60
elif unit == "s": secs += int(val)
if secs:
log.info(f"Slicer estimate: {secs}s ({m.group(1).strip()})")
return secs
def _parse_gcode_layer_heights(data: bytes) -> tuple[float, float]:
"""Reads (layer_height, initial_layer_height) from the OrcaSlicer/PrusaSlicer
GCode header. Both are stored as a config block at the end of the GCode.
Beispiel-Zeilen:
; layer_height = 0.2
; initial_layer_print_height = 0.2
Returns (0.0, 0.0) when not found - the caller decides what to do
(typisch: keinen Z-Wert anzeigen)."""
import re
head = data[:16384].decode("utf-8", errors="ignore")
tail = data[-65536:].decode("utf-8", errors="ignore")
search = head + "\n" + tail
def _grab(pat):
m = re.search(pat, search)
if not m:
return 0.0
try:
return float(m.group(1))
except Exception:
return 0.0
layer_h = _grab(r";\s*layer_height\s*=\s*([0-9.]+)")
first_h = (_grab(r";\s*initial_layer_print_height\s*=\s*([0-9.]+)") or
_grab(r";\s*first_layer_height\s*=\s*([0-9.]+)") or
layer_h)
return layer_h, first_h
def _extract_thumbnail(data: bytes) -> str:
"""Extracts the base64 PNG thumbnail from GCode (OrcaSlicer format)."""
try:
marker = b"; thumbnail begin"
end_marker = b"; thumbnail end"
start = data.find(marker)
if start == -1:
return ""
start = data.find(b"\n", start) + 1
end = data.find(end_marker, start)
if end == -1:
return ""
lines = data[start:end].split(b"\n")
b64 = b"".join(
line[2:].strip() if line.startswith(b"; ") else line.strip()
for line in lines
)
return b64.decode("ascii")
except Exception:
return ""
def _extract_filament_info(data: bytes) -> list[dict]:
"""Reads filament colors/materials incl. tool order from Orca/Prusa GCode.
Gibt Liste von {slot_index, color_hex, material} in Tool-/Paint-Reihenfolge
(T0, T1, ...).
Searches both the start and the end of the file since Orca can insert
large thumbnail blocks, pushing the metadata into the tail.
"""
try:
head = data[:131072]
tail = data[-131072:] if len(data) > 131072 else b""
header = (head + b"\n" + tail).decode("utf-8", errors="ignore")
colors, materials = [], []
paint_count_hint = 0
tool_filament_order = []
for line in header.splitlines():
if re.match(r"^\s*;\s*filament_colour\s*=", line):
val = line.split("=", 1)[-1].strip()
colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()]
elif re.match(r"^\s*;\s*filament_multi_colour\s*=", line) and not colors:
val = line.split("=", 1)[-1].strip()
colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()]
elif re.match(r"^\s*;\s*filament_type\s*=", line):
val = line.split("=", 1)[-1].strip()
parts = [m.strip() for m in re.split(r"[;,]", val) if m.strip()]
materials = parts
paint_count_hint = max(paint_count_hint, len(parts))
elif re.match(r"^\s*;\s*filament_density\s*:", line):
val = line.split(":", 1)[-1].strip()
parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()]
paint_count_hint = max(paint_count_hint, len(parts))
elif re.match(r"^\s*;\s*filament_diameter\s*:", line):
val = line.split(":", 1)[-1].strip()
parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()]
paint_count_hint = max(paint_count_hint, len(parts))
elif re.match(r"^\s*;\s*filament\s*:", line):
raw = line.split(":", 1)[-1]
parsed = []
for p in [x.strip() for x in raw.split(",") if x.strip()]:
try:
parsed.append(int(p))
except Exception:
pass
if parsed:
tool_filament_order = parsed
total_paints = max(len(colors), len(materials), paint_count_hint)
if tool_filament_order:
total_paints = max(total_paints, max(tool_filament_order))
if total_paints <= 0:
return []
# Keep full paint list visible; mark paints referenced by Orca tool order as used.
if len(colors) < total_paints:
colors.extend(["FFFFFF"] * (total_paints - len(colors)))
if len(materials) < total_paints:
materials.extend(["PLA"] * (total_paints - len(materials)))
# Prefer actual tool-change commands from the GCode body.
# This avoids forwarding paints that are present in metadata but never used.
used_paints_zero_based = set()
try:
for m in re.finditer(br"(?m)^[ \t]*T([0-9]+)\b", data):
used_paints_zero_based.add(int(m.group(1)))
except Exception:
used_paints_zero_based = set()
# Fallback for slicers that only provide paint usage in header metadata.
used_paints_from_header = set()
for n in tool_filament_order:
try:
# Orca/Prusa filament: list is typically 1-based.
used_paints_from_header.add(max(0, int(n) - 1))
except Exception:
pass
result = []
for i in range(total_paints):
hex_color = colors[i] if i < len(colors) else "FFFFFF"
result.append({
"slot_index": i,
"color_hex": "#" + hex_color.upper() if hex_color else "#FFFFFF",
"material": materials[i] if i < len(materials) else "PLA",
"is_used": (i in used_paints_zero_based) if used_paints_zero_based else ((i in used_paints_from_header) if used_paints_from_header else True),
})
return result
except Exception:
return []

226
gcode_store.py Normal file
View File

@@ -0,0 +1,226 @@
"""
gcode_store.py - persistent per-bridge SQLite store for uploaded GCode files
and print-job history.
Extracted from kobrax_moonraker_bridge.py; re-exported from there so existing
imports keep working.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import os
import json
import time
import uuid
import sqlite3
import threading
class GCodeStore:
"""Persistenter GCode-Store pro Bridge-Instanz (SQLite)."""
def __init__(self, data_dir: str):
os.makedirs(data_dir, exist_ok=True)
self._gcode_dir = os.path.join(data_dir, "gcodes")
os.makedirs(self._gcode_dir, exist_ok=True)
db_path = os.path.join(data_dir, "kx-bridge.db")
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._lock = threading.Lock()
self._init_schema()
def _init_schema(self):
with self._lock:
self._conn.executescript("""
CREATE TABLE IF NOT EXISTS gcode_files (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
path TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
uploaded_at TEXT NOT NULL,
thumbnail_b64 TEXT,
est_print_time_sec INTEGER,
filament_used_mm REAL,
layer_count INTEGER,
gcode_filaments TEXT,
objects_skip_parts TEXT,
svg_image TEXT,
web_unverified INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS print_jobs (
id TEXT PRIMARY KEY,
gcode_file_id TEXT NOT NULL,
printer_id TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
status TEXT NOT NULL,
duration_sec INTEGER,
filament_assignments TEXT,
abort_reason TEXT
);
""")
# Migration: add gcode_filaments column for older databases
try:
self._conn.execute("ALTER TABLE gcode_files ADD COLUMN gcode_filaments TEXT")
self._conn.commit()
except Exception:
pass
# Migration: columns objects_skip_parts + svg_image (part-skip feature, v0.9.10)
# Plus layer_height / first_layer_height (Obico Z height, v0.9.18)
for col, typ in (
("objects_skip_parts", "TEXT"),
("svg_image", "TEXT"),
("layer_height", "REAL"),
("first_layer_height", "REAL"),
):
try:
self._conn.execute(f"ALTER TABLE gcode_files ADD COLUMN {col} {typ}")
self._conn.commit()
except Exception:
pass
# Migration: flag for web uploads (warning before print)
try:
self._conn.execute("ALTER TABLE gcode_files ADD COLUMN web_unverified INTEGER NOT NULL DEFAULT 0")
self._conn.commit()
except Exception:
pass
def save_file(self, file_id: str, filename: str, data: bytes,
est_time_sec: int = 0, thumbnail_b64: str = "",
gcode_filaments: list | None = None,
web_unverified: bool = False,
layer_height: float = 0.0,
first_layer_height: float = 0.0) -> str:
"""Saves a GCode file to disk and DB. Returns the path."""
safe_name = os.path.basename(filename)
path = os.path.join(self._gcode_dir, safe_name)
with open(path, "wb") as f:
f.write(data)
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with self._lock:
filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None
self._conn.execute(
"""INSERT OR REPLACE INTO gcode_files
(id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified, layer_height, first_layer_height)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0, layer_height or None, first_layer_height or None)
)
self._conn.commit()
return path
def list_files(self) -> list:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM gcode_files ORDER BY uploaded_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_file(self, file_id: str) -> dict | None:
with self._lock:
row = self._conn.execute(
"SELECT * FROM gcode_files WHERE id=?", (file_id,)
).fetchone()
return dict(row) if row else None
def get_file_by_name(self, filename: str) -> dict | None:
with self._lock:
row = self._conn.execute(
"SELECT * FROM gcode_files WHERE filename=? ORDER BY uploaded_at DESC LIMIT 1",
(filename,)
).fetchone()
return dict(row) if row else None
def update_file_objects(self, filename: str, objects: list, svg: str = "") -> None:
"""Saves the object list + optional SVG for a file (matched via filename)."""
if not filename:
return
with self._lock:
self._conn.execute(
"UPDATE gcode_files SET objects_skip_parts=?, svg_image=? "
"WHERE filename=?",
(json.dumps(objects), svg or "", filename),
)
self._conn.commit()
def update_file_filaments(self, file_id: str, gcode_filaments: list | None) -> None:
"""Updates parsed GCode filaments for an existing DB entry."""
with self._lock:
self._conn.execute(
"UPDATE gcode_files SET gcode_filaments=? WHERE id=?",
(json.dumps(gcode_filaments) if gcode_filaments else None, file_id),
)
self._conn.commit()
def clear_web_unverified(self, file_id: str) -> bool:
with self._lock:
cur = self._conn.execute(
"UPDATE gcode_files SET web_unverified=0 WHERE id=?",
(file_id,),
)
self._conn.commit()
return cur.rowcount > 0
def delete_file(self, file_id: str) -> bool:
row = self.get_file(file_id)
if not row:
return False
try:
os.remove(row["path"])
except OSError:
pass
with self._lock:
self._conn.execute("DELETE FROM gcode_files WHERE id=?", (file_id,))
self._conn.commit()
return True
def start_job(self, gcode_file_id: str, printer_id: str,
filament_assignments: list | None = None) -> str:
job_id = str(uuid.uuid4())
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
assignments_json = json.dumps(filament_assignments) if filament_assignments else None
with self._lock:
self._conn.execute(
"""INSERT INTO print_jobs
(id, gcode_file_id, printer_id, started_at, status, filament_assignments)
VALUES (?,?,?,?,'printing',?)""",
(job_id, gcode_file_id, printer_id, now, assignments_json)
)
self._conn.commit()
return job_id
def finish_job(self, job_id: str, status: str = "completed",
abort_reason: str = "") -> None:
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with self._lock:
row = self._conn.execute(
"SELECT started_at FROM print_jobs WHERE id=?", (job_id,)
).fetchone()
duration = None
if row:
try:
import calendar
start = time.strptime(row["started_at"], "%Y-%m-%dT%H:%M:%SZ")
duration = int(time.time() - calendar.timegm(start))
except Exception:
pass
self._conn.execute(
"""UPDATE print_jobs SET ended_at=?, status=?, duration_sec=?, abort_reason=?
WHERE id=?""",
(now, status, duration, abort_reason or None, job_id)
)
self._conn.commit()
def list_jobs(self, limit: int = 50, offset: int = 0) -> list:
with self._lock:
rows = self._conn.execute(
"""SELECT j.*, f.filename, f.thumbnail_b64
FROM print_jobs j
LEFT JOIN gcode_files f ON j.gcode_file_id = f.id
ORDER BY j.started_at DESC LIMIT ? OFFSET ?""",
(limit, offset)
).fetchall()
return [dict(r) for r in rows]

View File

@@ -1,10 +1,10 @@
"""
kobrax_client.py Anycubic Kobra X LAN-MQTT-Client
Protokoll vollständig rekonstruiert via Sniffer 2026-04-17 (953 Nachrichten).
Protocol fully reconstructed via sniffer 2026-04-17 (953 messages).
Voraussetzungen:
- /tmp/anycubic_slicer.crt und .key (aus cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
- /tmp/anycubic_slicer.crt and .key (from cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
- Drucker im LAN-Modus erreichbar auf Port 9883
Verwendung:
@@ -101,6 +101,46 @@ def _parse_publish(pkt: bytes):
return topic, payload
def _enable_tcp_keepalive(sock: socket.socket) -> None:
"""Without this, a printer that goes dark without a clean TCP close (e.g.
unplugged, not gracefully shut down) leaves the socket looking alive to
is_connected() for as long as the OS's default dead-connection timeout
(often 15+ minutes on Linux) - sendall() on a half-open connection is
buffered by the kernel and doesn't fail immediately, so the poll loop's
is_connected() check (kobrax_moonraker_bridge.py's _poll_loop) never
sees the failure it needs to flip kobra_state to "offline". Short
keepalive probes make the OS notice and fail the socket within seconds
instead. Linux/macOS only (TCP_KEEPIDLE/INTVL/CNT); best-effort on other
platforms - not fatal if unsupported.
SO_KEEPALIVE alone is NOT enough, verified live by unplugging a real
printer mid-connection: keepalive probes only fire while the connection
is idle (no unacknowledged data outstanding). If the printer disappears
while a send is still in flight - the common case, since the poll loop
sends a request roughly every poll_interval - the kernel instead retries
that specific send via the normal TCP retransmission timer
(tcp_retries2, default 15 attempts with exponential backoff = 13-30+
minutes on Linux), which keepalive settings don't affect at all.
TCP_USER_TIMEOUT (Linux-specific) closes that gap: it caps how long ANY
unacknowledged data may sit in the send queue before the kernel gives up
on the connection outright, regardless of which mechanism (keepalive or
retransmission) would otherwise still be retrying."""
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
if hasattr(socket, "TCP_KEEPIDLE"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 5)
elif hasattr(socket, "TCP_KEEPALIVE"): # macOS
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 5)
if hasattr(socket, "TCP_KEEPINTVL"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3)
if hasattr(socket, "TCP_KEEPCNT"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
if hasattr(socket, "TCP_USER_TIMEOUT"):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 15000)
except OSError as e:
log.debug("TCP keepalive not fully supported on this platform: %s", e)
# ---------------------------------------------------------------------------
# KobraXClient
# ---------------------------------------------------------------------------
@@ -121,22 +161,42 @@ class KobraXClient:
self._buf = b""
self._pid = 1
self._lock = threading.Lock()
# Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der
# Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm
# ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd.
# Generation marker: incremented on every socket swap/close so the
# reader thread notices when _reconnect/_do_connect swapped the socket
# underneath it (Issue #53). Protects against recv on a stale fd.
self._sock_gen = 0
self._running = False
# Guards _reconnect() against concurrent invocation - both the reader
# thread (keepalive ping failure) and publish()/publish_web() (send
# failure) can trigger a reconnect independently. Without this, two
# threads could race into _do_connect() at once, each opening its own
# competing TLS handshake to a printer that likely only accepts one
# mTLS session at a time (Issue #105).
self._reconnect_lock = threading.Lock()
# Pending requests by msgid (for response ACK)
self._pending_msgid: dict[str, dict] = {}
# 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] = {}
# Dedup: last hash per topic suffix to suppress repeated identical messages
self._last_rx_hash: dict[str, str] = {}
# Debug switch (MQTT_RAW_LOG=1): logs every RX message unfiltered on
# INFO, including dedup'd duplicates and topics with no registered
# callback - for capturing printer behavior the bridge doesn't
# normally surface (e.g. reverse-engineering a rejected command).
self._raw_log = os.environ.get("MQTT_RAW_LOG", "").strip().lower() in ("1", "true", "yes")
# Fields that change every tick and should be stripped before dedup-hashing
_VOLATILE = {"timestamp", "msgid", "progress", "curr_layer",
"curr_nozzle_temp", "curr_hotbed_temp",
@@ -162,9 +222,9 @@ class KobraXClient:
if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
raise FileNotFoundError(
f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key "
f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). "
f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke "
f"die Dateien dorthin."
f"must sit next to the kx-bridge binary ({_SCRIPT_DIR}/). "
f"Download anycubic-certs.zip from the Gitea release and extract "
f"the files there."
)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
@@ -172,11 +232,12 @@ class KobraXClient:
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
ctx.load_cert_chain(CERT_FILE, KEY_FILE)
# Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK)
# läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht
# einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53).
# Build the socket as a local variable - the handshake (connect + CONNACK)
# runs WITHOUT holding the lock so a slow connect does not freeze
# senders. Only the finished socket is swapped in under the lock (#53).
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
raw = socket.create_connection(_ai[0][4], timeout=5)
_enable_tcp_keepalive(raw)
new_sock = ctx.wrap_socket(raw)
log.info("TLS connected cipher=%s", new_sock.cipher()[0])
@@ -196,7 +257,7 @@ class KobraXClient:
self._sock = new_sock
self._sock_gen += 1
self._buf = b""
self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln
self._subscribe(self._sub_topic()) # takes the lock itself - do not nest
log.debug("MQTT connected to %s:%s", self.host, self.port)
def connect(self):
@@ -206,10 +267,10 @@ class KobraXClient:
time.sleep(0.3)
def _ensure_reader(self):
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
würde dann zwar senden, aber auf Antworten ewig warten."""
"""Ensures the reader thread is alive. If the reader died after a
previous disconnect/reconnect sequence or an unhandled error,
received replies would never arrive - publish()
would still send but wait for replies forever."""
if not self._running:
return # gewollter disconnect
t = getattr(self, "_reader_thread", None)
@@ -231,41 +292,81 @@ class KobraXClient:
self._sock = None
self._sock_gen += 1
def _reconnect(self):
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
ausgeschaltet) zu vermeiden."""
log.warning("Verbindung verloren reconnect…")
# Close + Invalidierung unter Lock, damit kein Sender mitten im sendall
# auf den gerade geschlossenen Socket trifft (Issue #53).
def is_connected(self) -> bool:
"""Thread-safe check whether the MQTT socket is currently up. Used by
the bridge's poll loop to detect a dead session even when publish()
already swallowed the send failure and returned None instead of
raising (Issue #105) - a TCP-reachable printer alone doesn't mean the
MQTT/TLS session is still alive."""
with self._lock:
try:
if self._sock is not None:
self._sock.close()
except Exception:
pass
self._sock = None
self._sock_gen += 1
delays = [2, 4, 8, 15, 30, 60]
attempt = 0
while self._running:
delay = delays[min(attempt, len(delays) - 1)]
try:
self._do_connect()
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
return True
except Exception as e:
attempt += 1
lvl = log.warning if attempt <= 5 else log.debug
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
slept = 0.0
while slept < delay and self._running:
time.sleep(min(0.5, delay - slept))
slept += 0.5
return False # nur wenn disconnect() gerufen wurde
return self._sock is not None
def _reconnect(self, wait_if_in_progress: bool = True, persist: bool = True):
"""Reconnect the MQTT/TLS session. With persist=True (the default, used
by the reader-thread keepalive path) it keeps retrying forever until
the printer responds or disconnect() was called, backoff capped at 60s.
The first 5 attempts log as WARNING (acute connection issue), afterwards
only DEBUG to avoid log spam during long printer outages (e.g. switched off).
Guarded by _reconnect_lock (Issue #105): if another thread's reconnect
is already in flight, this call normally waits for it to finish instead
of starting a second, competing _do_connect() - the printer likely only
accepts one mTLS session at a time, so two parallel handshakes would
just interfere with each other and neither converges.
wait_if_in_progress=False + persist=False are used by the poll loop's
publish()/publish_web(): that thread MUST return promptly so the poll
loop can observe the dead session (via is_connected()) and flip
kobra_state to "offline". It must neither block on the lock waiting for
the reader thread's persistent reconnect (wait_if_in_progress=False),
nor run the multi-minute backoff loop itself (persist=False -> at most
one immediate attempt). Otherwise the poll loop hangs inside publish()
for the entire outage and the dashboard stays stuck on the last known
state - the exact bug seen when a printer was unplugged mid-connection."""
if not self._reconnect_lock.acquire(blocking=False):
if not wait_if_in_progress:
return self._sock is not None
self._reconnect_lock.acquire()
self._reconnect_lock.release()
return self._sock is not None
try:
log.warning("Connection lost - reconnecting...")
# Close + invalidation under the lock so no sender is mid-sendall
# auf den gerade geschlossenen Socket trifft (Issue #53).
with self._lock:
try:
if self._sock is not None:
self._sock.close()
except Exception:
pass
self._sock = None
self._sock_gen += 1
delays = [2, 4, 8, 15, 30, 60]
attempt = 0
while self._running:
delay = delays[min(attempt, len(delays) - 1)]
try:
self._do_connect()
log.info("Reconnect successful (after %d attempts)", attempt + 1)
return True
except Exception as e:
attempt += 1
if not persist:
# One-shot: don't block the caller (poll loop) in the
# backoff loop - leave persistent retrying to the
# reader thread's keepalive path.
log.debug("Reconnect (one-shot) failed: %s", e)
return False
lvl = log.warning if attempt <= 5 else log.debug
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
# Split sleep so disconnect() breaks the loop faster.
slept = 0.0
while slept < delay and self._running:
time.sleep(min(0.5, delay - slept))
slept += 0.5
return False # only when disconnect() was called
finally:
self._reconnect_lock.release()
def _subscribe(self, topic: str):
with self._lock:
@@ -290,15 +391,15 @@ class KobraXClient:
ping_ok = True
except Exception:
ping_ok = False
# _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock
# selbst, und threading.Lock ist nicht reentrant (sonst Deadlock).
# Call _reconnect() OUTSIDE the lock - it takes the lock
# itself, and threading.Lock is not reentrant (deadlock otherwise).
if not ping_ok:
if self._running and not self._reconnect():
break
last_ping = time.time()
# Aktuellen Socket + Generation unter Lock greifen, damit ein
# paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale
# fd pollen lässt (Issue #53).
# Grab the current socket + generation under the lock so a
# parallel _reconnect/_do_connect swap does not leave us polling
# a stale fd (Issue #53).
with self._lock:
sock = self._sock
gen = self._sock_gen
@@ -306,37 +407,37 @@ class KobraXClient:
time.sleep(0.05)
continue
# Idle-Wartezeit OHNE Lock select probt nur die Bereitschaft, so
# blockiert der Reader während Leerlauf nie das gemeinsame Lock.
# Idle wait WITHOUT the lock - select only probes readiness, so
# the reader never blocks the shared lock while idle.
try:
ready, _, _ = select.select([sock], [], [], 0.2)
except (OSError, ValueError):
# fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select)
# fd closed/invalid (reconnect or disconnect mid-select)
if not self._running:
break
time.sleep(0.05)
continue
if not ready:
continue # Leerlauf, kein Lock gehalten
continue # idle, no lock held
# Daten liegen an: Lock kurz greifen für das eine recv, serialisiert
# gegen alle sendall-Caller. recv blockiert nicht lange (select sagte
# ready, Socket-Timeout ist 0.2s).
# Data pending: briefly take the lock for the single recv, serialized
# against all sendall callers. recv does not block long (select said
# ready, socket timeout is 0.2s).
try:
with self._lock:
# Socket könnte zwischen select und hier ersetzt worden sein.
# The socket could have been swapped between select and here.
if self._sock_gen != gen or self._sock is not sock:
continue
data = sock.recv(65536)
if not data:
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
# Windows SSL can briefly return b"" without a real EOF
_empty_count += 1
if _empty_count >= 5:
raise ConnectionResetError("EOF")
continue
_empty_count = 0
self._buf += data
self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt
self._drain() # outside the lock - dispatch/event.set() stays prompt
except ssl.SSLWantReadError:
continue
except socket.timeout:
@@ -353,34 +454,46 @@ class KobraXClient:
def _drain(self):
buf = self._buf
idx = 0
while idx < len(buf):
ptype = buf[idx] & 0xF0
i = idx + 1
mul = 1
rem = 0
while i < len(buf):
b = buf[i]
rem += (b & 0x7F) * mul
mul *= 128
i += 1
if not (b & 0x80):
try:
while idx < len(buf):
ptype = buf[idx] & 0xF0
i = idx + 1
mul = 1
rem = 0
while i < len(buf):
b = buf[i]
rem += (b & 0x7F) * mul
mul *= 128
i += 1
if not (b & 0x80):
break
if i + rem > len(buf):
break
if i + rem > len(buf):
break
pkt = buf[i:i + rem]
idx = i + rem
pkt = buf[i:i + rem]
idx = i + rem
if ptype == 0x30:
topic, raw_payload = _parse_publish(pkt)
if topic is None:
continue
try:
payload = json.loads(raw_payload)
except Exception:
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
self._dispatch(topic, payload)
self._buf = buf[idx:]
if ptype == 0x30:
topic, raw_payload = _parse_publish(pkt)
if topic is None:
continue
try:
payload = json.loads(raw_payload)
except Exception:
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
try:
self._dispatch(topic, payload)
except Exception as e:
# A single malformed/unexpected message (e.g. valid JSON
# that isn't an object, like a bare number or list) must
# not be reprocessed forever: without this, an exception
# here would skip the buffer-advance below, leaving the
# same bad packet at the front of self._buf so every
# future _drain() call crashes on it again - each one
# forcing a reconnect via the reader loop's exception
# handler, an endless self-inflicted reconnect loop.
log.warning("dispatch error for %s: %s", topic, e)
finally:
self._buf = buf[idx:]
def _dedup_hash(self, suffix: str, payload: dict) -> str:
"""Hash payload ignoring volatile per-tick fields for dedup check."""
@@ -391,8 +504,14 @@ 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:
log.info("RX [raw] %s %s", topic, json.dumps(payload, ensure_ascii=False))
# Structured RX log with dedup suppression
h = self._dedup_hash(suffix, payload)
is_dup = self._last_rx_hash.get(suffix) == h
@@ -415,18 +534,29 @@ class KobraXClient:
log.info("RX %-25s state=%-12s data=%s",
suffix, state, json.dumps(payload.get("data"), ensure_ascii=False))
# Resolve by report topic suffix (e.g. "info/report")
if suffix in self._pending_report:
entry = self._pending_report[suffix]
entry["result"] = payload
entry["event"].set()
msgid = payload.get("msgid")
with self._pending_lock:
report_entry = self._pending_report.get(suffix)
msgid_entry = self._pending_msgid.get(msgid) if msgid else None
# Resolve by report topic suffix (e.g. "info/report"). If the payload
# carries a msgid that doesn't match what this waiter is actually
# expecting, it's a stale/late reply for a different, already-timed-out
# request that happens to share the same report_key - don't deliver it
# to the wrong caller.
if report_entry is not None:
entry_msgid = report_entry.get("msgid")
if not entry_msgid or not msgid or entry_msgid == msgid:
report_entry["result"] = payload
report_entry["event"].set()
else:
log.debug("dispatch: msgid mismatch for %s report (waiting=%s, got=%s) - ignoring stale reply",
suffix, entry_msgid, msgid)
# Resolve by msgid (for generic response ACK)
msgid = payload.get("msgid")
if msgid and msgid in self._pending_msgid:
entry = self._pending_msgid[msgid]
entry["result"] = payload
entry["event"].set()
if msgid_entry is not None:
msgid_entry["result"] = payload
msgid_entry["event"].set()
# User callbacks by topic suffix (last two path components)
if suffix in self.callbacks:
@@ -445,8 +575,8 @@ class KobraXClient:
# -- Publish + request/response ------------------------------------------
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
# If the reader thread is dead for historical reasons, revive it -
# otherwise replies would never arrive and event.wait() would time out.
self._ensure_reader()
msgid = str(uuid.uuid4())
payload = json.dumps({
@@ -462,16 +592,21 @@ 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) ist reines Rauschen (alle paar Sekunden) →
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
# auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar.
_tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO
log.log(_tx_level, "TX %-25s action=%-12s data=%s",
@@ -482,31 +617,38 @@ 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)
if not self._reconnect():
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".
if not self._reconnect(wait_if_in_progress=False, persist=False):
return None
# retry once after reconnect
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"]
@@ -531,11 +673,12 @@ class KobraXClient:
self._sock.sendall(_build_publish(topic, payload))
except Exception as e:
log.error("web send error: %s, reconnecting…", e)
# Reconnect triggern (analog zu publish()); ohne Retry weil
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
# treffen.
# Trigger a reconnect (like publish()); no retry because it is
# fire-and-forget - the next call will hit the fresh socket.
# Non-blocking for the same reason as publish() (see _reconnect
# docstring) - never hang this thread through a backoff loop.
try:
self._reconnect()
self._reconnect(wait_if_in_progress=False, persist=False)
except Exception:
pass
@@ -579,13 +722,13 @@ class KobraXClient:
# -- Part-Skip ("Exclude Object") ---------------------------------------
def query_skip_objects(self) -> dict | None:
"""Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste."""
"""Asks the printer for the current object/skip list."""
return self.publish("skip", "query_obj")
def skip_objects(self, names: list[str]) -> dict | None:
"""Überspringt die genannten Objekte auch mid-print möglich.
"""Skips the named objects - also possible mid-print.
Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen
Names correspond to the EXCLUDE_OBJECT_DEFINE NAME=... entries
im GCode-Header bzw. file_details.objects_skip_parts.
"""
return self.publish("skip", "start", {"objects_skip_parts": list(names)})
@@ -610,7 +753,9 @@ class KobraXClient:
raise RuntimeError("Could not get info/report for upload URL")
upload_url = info["data"]["urls"]["fileUploadurl"]
# parse token from URL query string
token = upload_url.split("?s=")[1] if "?s=" in upload_url else ""
if "?s=" not in upload_url:
raise RuntimeError(f"Upload: no session token ('?s=') in upload URL: {upload_url!r}")
token = upload_url.split("?s=")[1]
with open(filepath, "rb") as f:
file_data = f.read()
@@ -653,26 +798,32 @@ class KobraXClient:
f"Connection: close\r\n\r\n"
).encode()
# Connect-Timeout kurz (LAN). Während sendall() darf der Socket so
# lange brauchen wie nötig — bei großen Dateien (>100 MB) und
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
# generös (Drucker verarbeitet die Datei bevor er antwortet).
# Short connect timeout (LAN). During sendall() the socket may take
# as long as needed - with large files (>100 MB) and slower WiFi
# at the printer, pushing otherwise takes >30 s and would falsely
# trip the connect timeout. The read timeout afterwards is generous
# (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 während 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:
@@ -717,7 +868,7 @@ if __name__ == "__main__":
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
parser.add_argument("--monitor", action="store_true",
help="Dauerhaft mithören und alle Reports ausgeben")
help="Listen continuously and print all reports")
args = parser.parse_args()
client = KobraXClient(
@@ -741,7 +892,7 @@ if __name__ == "__main__":
client.callbacks["*"] = on_msg
client.connect()
print("[kobrax] Monitor-Modus aktiv (Ctrl-C zum Beenden)")
print("[kobrax] Monitor mode active (Ctrl-C to stop)")
try:
while True:
time.sleep(1)
@@ -755,7 +906,7 @@ if __name__ == "__main__":
info = client.query_info()
if info:
d = info.get("data", {})
print(f" Drucker: {d.get('printerName')} FW {d.get('version')}")
print(f" Printer: {d.get('printerName')} FW {d.get('version')}")
print(f" Status: {d.get('state')}")
t = d.get("temp", {})
print(f" Nozzle: {t.get('curr_nozzle_temp')}°C → {t.get('target_nozzle_temp')}°C")
@@ -764,6 +915,6 @@ if __name__ == "__main__":
print(f" Upload: {urls.get('fileUploadurl')}")
print(f" Kamera: {urls.get('rtspUrl')}")
else:
print(" Keine Antwort")
print(" No response")
client.disconnect()

File diff suppressed because it is too large Load Diff

74
kxgauge_client.py Normal file
View File

@@ -0,0 +1,74 @@
"""
kxgauge_client.py - thin synchronous HTTP client for KXGauge (ESP32 round-face
display, https://gitea.it-drui.de/viewit/kxgauge).
KXGauge exposes a simple GET-only HTTP API (no push, no websocket) - the
bridge has to actively poke it whenever printer state/temperature changes.
Designed to be called from daemon threads (MQTT reader thread callbacks),
mirrors spoolman_client.py's shape: plain `requests`, no event-loop dependency.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
import logging
log = logging.getLogger("kobrax.kxgauge")
# Tolerance in °C before a new heat value is worth another GET - avoids
# spamming the device on every MQTT tick when the temperature is basically
# holding steady.
_HEAT_TOLERANCE = 2.0
class KXGaugeClient:
"""Thin synchronous HTTP client for a KXGauge display.
All calls swallow their own exceptions and just log a warning - a
disconnected/misconfigured display must never interrupt MQTT callback
processing or the poll loop.
"""
def __init__(self, base_url: str, heat_peak: float = 250.0):
self.base_url = base_url.rstrip("/")
self.heat_peak = heat_peak
self._last_emotion: str | None = None
self._last_heat_celsius: float | None = None
self._peak_sent = False
def _get(self, path: str) -> bool:
try:
import requests
r = requests.get(f"{self.base_url}{path}", timeout=3)
r.raise_for_status()
return True
except Exception as e:
log.warning(f"KXGauge request failed ({path}): {e}")
return False
def health_check(self) -> bool:
try:
import requests
r = requests.get(f"{self.base_url}/status", timeout=3)
r.raise_for_status()
return True
except Exception:
return False
def set_emotion(self, name: str) -> None:
name = (name or "").lower().strip()
if not name or name == self._last_emotion:
return
if self._get(f"/emotion/{name}"):
self._last_emotion = name
def set_heat_celsius(self, value: float) -> None:
if not self._peak_sent:
if self._get(f"/heat/peak/celsius/{self.heat_peak}"):
self._peak_sent = True
if (self._last_heat_celsius is not None
and abs(value - self._last_heat_celsius) < _HEAT_TOLERANCE):
return
if self._get(f"/heat/celsius/{value:.1f}"):
self._last_heat_celsius = value

View File

@@ -1,20 +1,23 @@
"""OrcaSlicer Filament-Profil Parser.
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
Shared between the generator (tools/gen_orca_filament_list.py) and the
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
Reads Orca filament JSON files (system or user profiles) and returns
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-Profile speichern manche Felder als ['wert']. Liefert erstes
Element als String."""
"""Orca profiles store some fields as ['value']. Returns the first
element as a string."""
if isinstance(value, list):
return str(value[0]) if value else default
if isinstance(value, str):
@@ -37,39 +40,49 @@ def parse_profile(data: dict, by_name: dict | None = None,
path_vendor: str | None = None,
source_path: str = "",
system_index: list | None = None) -> dict | None:
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
"""Parses a single Orca filament profile into the bridge schema.
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
`by_name` is optionally a {name: [profile, ...]} index for inherits resolution
from the raw source tree (generator). For single-file imports (user file
from the OrcaSlicer user dir) we pass `system_index` instead -
the finished system profile list from orca_filaments.json. This lets
us derive filament_id/vendor/type/color via the `inherits` chain from
the system parent even when the user profile does not set these
fields itself (typically: user override profiles only contain tweaks).
Liefert {id, name, vendor, type, color} oder None wenn das Profil
keine filament_id hat (z.B. abstrakte @base-Templates).
Returns {id, name, vendor, type, color} or None when the profile
has no filament_id (e.g. abstract @base templates).
"""
if not isinstance(data, dict):
return None
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
# User profiles from the OrcaSlicer user dir often set NO "type" field -
# it comes from the system parent. We accept that when either "type"
# is explicitly "filament" OR an "inherits" points to another profile.
if data.get("type") not in (None, "filament") and not data.get("inherits"):
return None
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
# type=filament aber kein parent + keine ID wertloses Stub
# type=filament but no parent + no ID -> worthless stub
return None
inst = data.get("instantiation", "true")
if isinstance(inst, str) and inst.lower() == "false":
return None
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
# Build the system name index for the fallback lookup when system_index is set.
sys_by_name: dict[str, dict] = {}
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]
@@ -92,7 +105,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
return None
def _resolve_via_system_index(key: str):
"""Inherits-Kette über system_index (clean_name-Match)."""
"""Inherits chain via system_index (clean_name match)."""
parent_raw = data.get("inherits")
if not parent_raw or not sys_by_name:
return None
@@ -100,7 +113,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
sys_p = sys_by_name.get(parent_clean)
if not sys_p:
return None
# System-JSON benutzt schon das normalisierte Schema
# The system JSON already uses the normalized schema
mapping = {
"filament_id": "id",
"filament_vendor": "vendor",
@@ -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"), "")
@@ -136,10 +149,10 @@ def parse_profile(data: dict, by_name: dict | None = None,
def parse_profile_bytes(blob: bytes, source_name: str = "",
system_index: list | None = None) -> dict | None:
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
`system_index` ist optional die fertige Liste aus orca_filaments.json
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
Schema vom System-Parent erben."""
"""Reads a single profile from JSON bytes. For the file upload path.
`system_index` is optionally the finished list from orca_filaments.json -
used for the inherits resolution of user profiles that do not carry the full
schema from the system parent."""
try:
data = json.loads(blob.decode("utf-8", errors="replace"))
except Exception:

45
spoolman_client.py Normal file
View File

@@ -0,0 +1,45 @@
"""
spoolman_client.py - thin synchronous HTTP client for Spoolman filament tracking.
Extracted from kobrax_moonraker_bridge.py as part of splitting that module up;
re-exported from there so existing imports keep working.
────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)
Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""
class SpoolmanClient:
"""Thin synchronous HTTP client for Spoolman filament tracking.
Designed to be called from daemon threads (poll loop, _on_print callbacks).
Uses requests (already in requirements) so no event-loop dependency.
"""
def __init__(self, server_url: str, sync_rate: int = 0):
self.server_url = server_url.rstrip("/")
self.sync_rate = sync_rate
def _req(self, method: str, path: str, **kwargs):
import requests
r = requests.request(method, f"{self.server_url}{path}", timeout=5, **kwargs)
r.raise_for_status()
return r.json()
def health_check(self) -> bool:
try:
self._req("GET", "/api/v1/health")
return True
except Exception:
return False
def list_spools(self) -> list:
return self._req("GET", "/api/v1/spool")
def use_filament(self, spool_id: int, use_length_mm: float) -> None:
"""Report consumed filament length in mm. Spoolman converts to weight
using the spool's filament profile density."""
self._req("PUT", f"/api/v1/spool/{spool_id}/use",
json={"use_length": round(use_length_mm, 2)})

View File

@@ -1,10 +1,12 @@
#!/usr/bin/env bash
# start.sh KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
# start.sh KX-Bridge starten (zieht das fertige Image aus der Registry)
set -euo pipefail
cd "$(dirname "$0")"
IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge"
# .env anlegen falls nicht vorhanden
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
@@ -30,38 +32,38 @@ if ! docker info > /dev/null 2>&1; then
exit 1
fi
# Prüfen ob Build nötig ist
NEEDS_BUILD=0
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
echo "[start] Image nicht vorhanden baue kx-bridge:latest ..."
NEEDS_BUILD=1
# Release-Kanal abfragen
CHANNEL=""
if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then
CHANNEL="$1"
else
# Image-Erstellungszeit in Unix-Sekunden
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
s=s[:26].rstrip('Z').replace('T',' '); \
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
for f in Dockerfile \
bridge/kobrax_moonraker_bridge.py \
bridge/kobrax_client.py \
bridge/env_loader.py \
bridge/requirements.txt \
bridge/anycubic_slicer.crt \
bridge/anycubic_slicer.key; do
if [[ -f "$f" ]]; then
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
if [[ $FILE_TS -gt $IMAGE_TS ]]; then
echo "[start] '$f' ist neuer als das Image baue neu ..."
NEEDS_BUILD=1
break
fi
fi
done
echo ""
echo " Welchen Release-Kanal möchtest du starten?"
echo " 1) stable (empfohlen)"
echo " 2) nightly (getestete Vorabversion)"
echo -n " Auswahl [1]: "
read -r CHOICE
case "$CHOICE" in
2) CHANNEL="nightly" ;;
*) CHANNEL="stable" ;;
esac
fi
if [[ $NEEDS_BUILD -eq 1 ]]; then
docker build -t kx-bridge:latest .
if [[ "$CHANNEL" == "nightly" ]]; then
IMAGE_TAG="nightly"
else
IMAGE_TAG="latest"
fi
IMAGE="$IMAGE_BASE:$IMAGE_TAG"
echo "[start] Kanal: $CHANNEL → Image: $IMAGE"
echo "[start] Ziehe aktuelles Image ..."
docker pull "$IMAGE"
# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile)
if [[ -f docker-compose.yml ]]; then
sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml
rm -f docker-compose.yml.bak
fi
# Container starten
@@ -70,7 +72,7 @@ docker-compose down 2>/dev/null || true
docker-compose up -d
echo ""
echo " ✓ KX-Bridge läuft"
echo " ✓ KX-Bridge läuft ($CHANNEL)"
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
echo " Logs : docker-compose logs -f"
echo " Stop : docker-compose down"

View File

@@ -2,12 +2,13 @@
Shared fixtures für KX-Bridge Tests.
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
"""
import sys, types, argparse, pytest, pytest_asyncio
import sys, types, argparse, tempfile, pytest, pytest_asyncio
from unittest.mock import MagicMock
from aiohttp.test_utils import TestClient, TestServer
# ── Pfad ──────────────────────────────────────────────────────────────────────
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
env_mod = types.ModuleType("env_loader")
@@ -41,6 +42,7 @@ def make_args(**overrides):
device_id = "",
host = "127.0.0.1",
port = 7125,
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
)
for k, v in overrides.items():
setattr(args, k, v)

View File

@@ -0,0 +1,236 @@
"""Auto-matching for custom ACE-RFID filament tags (Issue #101).
Anycubic's ACE RFID system concatenates vendor + material + a truncated
serial into one `type` string for custom (third-party) tags, e.g.
"GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for
"Basic"). Previously the bridge treated this whole string as an unknown
material and fell back to a neutral "Generic <type>" profile, even though
the user had already imported a matching OrcaSlicer profile via the ZIP
import feature (Issue #41) - requiring a manual per-slot reassignment every
time that spool was loaded.
_parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this
automatically against the merged system+user filament library.
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
USER_PROFILES = [
{"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""},
{"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
]
def _bridge(profiles=USER_PROFILES):
c = MagicMock(); c.callbacks = {}; c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxrfid-"),
)
b = KobraXBridge(c, args=args)
b._orca_filaments_cache = profiles
return b
def test_combined_rfid_type_parses_known_vendor_and_family():
b = _bridge()
vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas")
assert vendor == "Geeetech"
assert family == "PLA"
def test_plain_type_string_is_unaffected():
"""Regression guard: a normal type="PLA" report (no vendor prefix) must
not be mistaken for a combined RFID string."""
b = _bridge()
vendor, family = b._parse_combined_rfid_type("PLA")
assert vendor == ""
assert family == ""
def test_unknown_vendor_prefix_returns_no_match():
b = _bridge()
vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas")
assert vendor == ""
assert family == ""
def test_match_profile_by_vendor_family_finds_imported_profile():
b = _bridge()
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
assert profile.get("name") == "Geeetech PLA Basic"
def test_match_profile_by_vendor_family_no_match_returns_empty():
b = _bridge()
profile = b._match_profile_by_vendor_family("Geeetech", "PETG")
assert profile == {}
def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing():
profiles = USER_PROFILES + [
{"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True},
]
b = _bridge(profiles)
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk")
def test_build_lane_data_auto_resolves_combined_rfid_slot():
"""End-to-end: a slot reporting the combined RFID string should surface
the imported Geeetech profile in lane_data instead of the Generic
fallback."""
b = _bridge()
b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path
b._filament_mode = "ace_hub"
b._ams_slots = [
{"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]},
]
lane = b._build_lane_data()
tray = lane["ams"][0]["tray"][0]
assert tray["vendor_name"] == "Geeetech"
assert tray["name"] == "Geeetech PLA Basic"
def test_build_lane_data_plain_type_still_uses_generic_fallback():
"""Regression guard: everyday type="PLA" slots must keep using the
existing Generic-library fallback, unaffected by the new matching path."""
b = _bridge()
b._filament_profiles = {} # no manual per-slot override - isolate the fallback path
b._filament_mode = "ace_hub"
b._ams_slots = [
{"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]},
]
lane = b._build_lane_data()
tray = lane["ams"][0]["tray"][0]
assert tray["name"] == "Generic PLA"
assert tray["vendor_name"] == "Generic"
# ─── Centralized matching via _effective_slot_profile() ────────────────────
#
# The bug reported in Issue #101 by @Blaim (nightly45 not working, despite
# _parse_combined_rfid_type()/_match_profile_by_vendor_family() existing):
# those two helpers were ONLY ever invoked inside _build_lane_data(), which
# is only reached when OrcaSlicer polls the Moonraker lane_data endpoint -
# never as part of the real MQTT receive path (_on_multicolor_box ->
# self._ams_slots -> _push_status_update -> dashboard / /kx/filament/slots).
# So the dashboard and the Happy-Hare gate data never saw a match, matching
# exactly what the user's screenshots showed. Fixed by moving the matching
# logic into _effective_slot_profile() itself, which all three consumers
# already call.
def test_effective_slot_profile_auto_resolves_raw_rfid_string_without_override():
"""The core Issue #101 regression: _effective_slot_profile() itself (not
just _build_lane_data()) must resolve a combined RFID string when there is
no manual per-slot override in config.ini."""
b = _bridge()
b._filament_profiles = {}
profile = b._effective_slot_profile(0, "GEEETECH PLA Bas")
assert profile.get("name") == "Geeetech PLA Basic"
assert profile.get("vendor") == "Geeetech"
def test_effective_slot_profile_manual_override_still_wins():
b = _bridge()
b._filament_profiles = {0: {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic"}}
profile = b._effective_slot_profile(0, "GEEETECH PLA Bas")
assert profile.get("name") == "Generic PLA"
def test_effective_slot_profile_plain_type_no_override_returns_empty():
"""Regression guard: a plain type="PLA" slot with no override must still
fall through to {} (the caller's own generic-name fallback), not be
treated as an RFID string."""
b = _bridge()
b._filament_profiles = {}
assert b._effective_slot_profile(0, "PLA") == {}
def _multicolor_box_report(raw_type: str, color=(238, 190, 152)) -> dict:
"""A realistic multiColorBox/report payload for one ACE box (id=0) with
a toolhead (id=-1), matching the real Kobra X topology captured live
during Issue #100/#101 investigation - drives _detect_filament_mode()
to "ace_hub", same as on real hardware."""
return {
"state": "success",
"data": {
"head_tools_model": 1,
"multi_color_box": [
{
"id": -1, "loaded_slot": -1,
"slots": [{"index": 0, "status": 0, "type": "", "color": [0, 0, 0]}],
},
{
"id": 0, "loaded_slot": -1,
"slots": [
{"index": 0, "status": 0, "type": "", "color": [0, 0, 0]},
{"index": 1, "status": 0, "type": "", "color": [0, 0, 0]},
{
"index": 2, "status": 5, "type": raw_type,
"color": list(color), "sku": "",
},
{"index": 3, "status": 0, "type": "", "color": [0, 0, 0]},
],
},
],
},
}
def test_on_multicolor_box_end_to_end_resolves_rfid_slot_for_dashboard():
"""End-to-end regression test for Issue #101: feed a raw MQTT
multiColorBox/report payload through the real receive path
(_on_multicolor_box), then check that the dashboard-facing
/kx/filament/slots data (handle_kx_filament_slots) - which is what
populates the dashboard's window._slotProfileMap in the browser -
actually reflects the matched profile, not the raw "GEEETECH PLA Bas"
string. This is the exact path that was broken and untested before."""
b = _bridge()
b._filament_profiles = {}
b._on_multicolor_box(_multicolor_box_report("GEEETECH PLA Bas"))
assert b._filament_mode == "ace_hub"
# global_index 6 = box_id 0 * 4 + local slot 2 in ace_hub mode's ACE block
slot = next(s for s in b._ams_slots if s.get("type") == "GEEETECH PLA Bas")
global_idx = slot["global_index"]
profile = b._effective_slot_profile(global_idx, slot["type"])
assert profile.get("name") == "Geeetech PLA Basic"
assert profile.get("vendor") == "Geeetech"
def test_match_profile_by_vendor_family_disambiguates_via_variant_tokens():
"""Issue #101 follow-up (@Blaim): two profiles of the same vendor+family
("Geeetech PLA Basic" vs. "Geeetech PLA Matte") must resolve to the one
matching the RFID string's truncated variant token ("bas" -> Basic),
not just "whichever loads first"."""
profiles = USER_PROFILES + [
{"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
]
b = _bridge(profiles)
basic = b._match_profile_by_vendor_family("Geeetech", "PLA", ["bas"])
assert basic.get("name") == "Geeetech PLA Basic"
matte = b._match_profile_by_vendor_family("Geeetech", "PLA", ["mat"])
assert matte.get("name") == "Geeetech PLA Matte"
def test_match_profile_by_vendor_family_no_variant_tokens_falls_back_to_first():
profiles = USER_PROFILES + [
{"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
]
b = _bridge(profiles)
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
assert profile.get("name") == "Geeetech PLA Basic"
def test_rfid_variant_tokens_extracts_tokens_after_vendor_and_family():
assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA Bas") == ["bas"]
assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA") == []
assert KobraXBridge._rfid_variant_tokens("PLA") == []

View File

@@ -0,0 +1,85 @@
"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path.
Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it
EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping`
inserts a positional placeholder at each gap whose `ams_index` points at the
gap's own (physically EMPTY) tray. The printer rejects a mapping entry that
references an empty tray, even for a tool the GCode never calls.
Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray.
Positional alignment (entry N = TN, from a16062f) must be preserved.
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3).
AMS_SLOTS = [
{"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]},
{"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]},
{"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY
{"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]},
]
def _bridge(slots=AMS_SLOTS):
c = MagicMock(); c.callbacks = {}; c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxauto-"),
)
b = KobraXBridge(c, args=args)
b._filament_mode = "toolhead"
b._ams_slots = [dict(s) for s in slots]
return b
def _loaded_ams_indices(b):
return {b._slot_to_print_ams_index(int(s["global_index"]))
for s in b._ams_slots if s["status"] == 5}
def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded():
"""_start_print filters loaded to the used slot only: loaded=[(3, slot3)].
Positions 0..2 are placeholders and must NOT reference empty tray idx 2."""
b = _bridge()
loaded = [(3, b._ams_slots[3])]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept
loaded_ams = _loaded_ams_indices(b)
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set():
"""All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at
position 2 must not reference the empty tray idx 2."""
b = _bridge()
loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3]
loaded_ams = _loaded_ams_indices(b)
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
# Real loaded slots keep their own ams_index.
assert mapping[0]["ams_index"] == 0
assert mapping[1]["ams_index"] == 1
assert mapping[3]["ams_index"] == 3
def test_all_full_is_unchanged():
"""All slots loaded -> no placeholders, identity mapping (the working case)."""
slots = [dict(s, status=5) for s in AMS_SLOTS]
b = _bridge(slots)
loaded = [(i, b._ams_slots[i]) for i in range(4)]
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3]

111
tests/test_buried_report.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Tests für buried/report — das druckerseitige Analytics-Event, das einmal pro
Druckstart feuert (verifiziert live gegen einen echten Kobra X, sowohl für
Anycubic Slicer Next als auch für OrcaSlicer/die Bridge selbst). Liefert
gcode_size/estimate_duration/total_layers, die server/files/metadata für
Dateien außerhalb des eigenen GCodeStore sonst nicht hat (Issue #102).
"""
import pytest
BURIED_PAYLOAD = {
"type": "buried",
"action": "PrintStart",
"code": 200,
"state": "done",
"data": {
"task_name": "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode",
"gcode_size": 644437,
"estimate_duration": 1264,
"total_layers": 8,
"storage_total": 6481,
"storage_used": 898,
"slicer": "OrcaSlicer",
},
}
def test_on_buried_populates_cache(client):
_, bridge = client
bridge._on_buried(BURIED_PAYLOAD)
assert bridge._buried_cache == {
"task_name": "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode",
"gcode_size": 644437,
"estimate_duration": 1264,
"total_layers": 8,
}
def test_on_buried_populates_storage_state(client):
_, bridge = client
bridge._on_buried(BURIED_PAYLOAD)
assert bridge._state["storage_total_mb"] == 6481
assert bridge._state["storage_used_mb"] == 898
def test_on_buried_ignores_payload_without_task_name(client):
_, bridge = client
bridge._buried_cache = None
bridge._on_buried({"type": "buried", "data": {"gcode_size": 123}})
assert bridge._buried_cache is None
def test_build_file_metadata_uses_buried_fallback_for_unknown_file(client):
"""A file not in the GCodeStore and not the currently-tracked job should
still get real size/estimated_time/layer_count from the buried cache."""
_, bridge = client
bridge._on_buried(BURIED_PAYLOAD)
meta = bridge._build_file_metadata("Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode")
assert meta["size"] == 644437
assert meta["estimated_time"] == 1264
assert meta["layer_count"] == 8
def test_build_file_metadata_ignores_buried_cache_for_different_file(client):
"""The buried cache must only apply when task_name matches the queried
filename - otherwise it would leak the last print's data into an
unrelated query, the exact bug Issue #102 already fixed for live state."""
_, bridge = client
bridge._on_buried(BURIED_PAYLOAD)
meta = bridge._build_file_metadata("some_other_file.gcode")
assert meta["size"] == 1 # unchanged fallback, not leaked from buried cache
assert meta["estimated_time"] is None
assert meta["layer_count"] is None
def test_build_file_metadata_prefers_gcodestore_over_buried(client):
"""GCodeStore data (from the bridge's own upload) must win over the
buried cache when both are available for the same filename."""
_, bridge = client
bridge._on_buried(BURIED_PAYLOAD)
with bridge._store._lock:
bridge._store._conn.execute(
"INSERT INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) "
"VALUES (?,?,?,?,?,?,?)",
("f1", "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode", "/tmp/f1", 999999, "2026-01-01T00:00:00Z", 42, 5000),
)
bridge._store._conn.commit()
meta = bridge._build_file_metadata("Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode")
assert meta["size"] == 999999
assert meta["estimated_time"] == 5000
assert meta["layer_count"] == 42
@pytest.mark.asyncio
async def test_api_state_reports_storage_after_buried_report(client):
c, bridge = client
bridge._on_buried(BURIED_PAYLOAD)
resp = await c.get("/api/state")
assert resp.status == 200
data = await resp.json()
assert data["storage_total_mb"] == 6481
assert data["storage_used_mb"] == 898
@pytest.mark.asyncio
async def test_api_state_storage_defaults_to_zero(client):
c, _ = client
resp = await c.get("/api/state")
data = await resp.json()
assert data["storage_total_mb"] == 0
assert data["storage_used_mb"] == 0

View File

@@ -0,0 +1,51 @@
"""Camera stream hangs forever after printer reboot (Issue #99).
The printer rotates its stream token on reboot, changing the camera URL.
CameraCache.set_url() used to be a bare assignment - the running ffmpeg loops
never noticed since they only re-read self._url at the top of their outer
loop, which they never reach while permanently blocked reading stdout from
the now-silent, stale-token connection. set_url() must detect the change and
tear the loops down so the next ensure_running() respawns them against the
new URL.
"""
from kobrax_moonraker_bridge import CameraCache
def test_set_url_first_time_does_not_reset():
"""No prior URL - nothing stale to tear down."""
c = CameraCache()
calls = []
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert c._url == "http://printer/live/tokenA"
assert not calls
def test_set_url_same_value_does_not_reset():
c = CameraCache()
calls = []
c.set_url("http://printer/live/tokenA")
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert not calls
def test_set_url_changed_triggers_reset():
"""The actual bug scenario: token rotates after a printer reboot."""
c = CameraCache()
calls = []
c.set_url("http://printer/live/tokenA")
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenB")
assert c._url == "http://printer/live/tokenB"
assert calls == [True]
def test_set_url_empty_to_value_does_not_reset():
"""Startup case: no URL known yet, first status push sets it - nothing
running to tear down."""
c = CameraCache()
calls = []
c.reset = lambda: calls.append(True)
c.set_url("http://printer/live/tokenA")
assert not calls

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,157 @@
"""Optional auto-delete of a printed file from the printer's own storage
after a successful print (Settings -> Print -> "Delete file from printer
after successful print").
Only applies to files that are also backed by the bridge's own GCode store
(otherwise the file would be gone with no copy left anywhere) and only on a
clean "finished" state - not on stoped/canceled prints, and never when the
setting is off (the default).
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import GCodeStore, KobraXBridge
def _bridge(delete_after_print=1):
c = MagicMock()
c.callbacks = {}
c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxdelafterprint-"),
delete_printer_file_after_print=delete_after_print,
)
store = GCodeStore(args.data_dir)
b = KobraXBridge(c, args=args, store=store)
return b, c
def _seed_file(bridge, filename="test.gcode"):
file_id = "abc123"
bridge._store.save_file(file_id, filename, b"; gcode content")
return file_id
def _print_report(state, filename=None):
payload = {"state": state, "data": {}}
if filename is not None:
payload["data"]["filename"] = filename
return payload
def test_finished_print_deletes_printer_file_when_enabled_and_in_store():
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
assert b._current_job_id
assert b._current_job_filename == "test.gcode"
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert len(delete_calls) == 1
payload = delete_calls[0].args[2]
assert payload == {"root": "local", "files": [{"path": "/", "filename": "test.gcode"}]}
def test_finished_print_no_delete_when_setting_disabled():
b, c = _bridge(delete_after_print=0)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_finished_print_no_delete_when_file_not_in_bridge_store():
"""Files started directly from the printer/Anycubic Slicer aren't in the
bridge's own GCode store - must never be deleted, since that would leave
no copy anywhere."""
b, c = _bridge(delete_after_print=1)
# No _seed_file() call - the file is not in the store.
b._on_print(_print_report("printing", "not_in_store.gcode"))
assert not b._current_job_id # no store match -> no job tracked either
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_canceled_print_does_not_delete_file():
"""Only a clean "finished" triggers the delete - a stopped/canceled
print keeps its file, since the user may want to retry it."""
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("canceled"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
assert b._current_job_filename == ""
def test_stoped_print_does_not_delete_file():
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("stoped"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_current_job_filename_reset_after_finished():
"""Regression guard: _current_job_filename must not leak into the next
print's finished-handling if that next print isn't itself tracked."""
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("finished"))
assert b._current_job_filename == ""
# A second "finished" with no new job in between must not re-trigger a delete.
c.publish.reset_mock()
b._on_print(_print_report("finished"))
delete_calls = [
call for call in c.publish.call_args_list
if call.args[:2] == ("file", "deleteBatch")
]
assert delete_calls == []
def test_delete_publish_failure_does_not_raise():
"""A broken MQTT send during the delete request must not propagate out
of _on_print() - it runs on the MQTT reader thread, and an unhandled
exception there would break processing of subsequent messages."""
b, c = _bridge(delete_after_print=1)
_seed_file(b, "test.gcode")
c.publish.side_effect = RuntimeError("send failed")
b._on_print(_print_report("printing", "test.gcode"))
b._on_print(_print_report("finished")) # must not raise

View File

@@ -0,0 +1,67 @@
"""Per-printer filament-profile isolation (config_loader).
Regression test for the multi-printer bug (issue #74): the slot->profile mapping
and ``visible_vendors`` lived in a single global ``[filament_profiles]`` section,
so configuring one printer overwrote the other and after a restart both loaded
the same map. Each printer now uses its own ``[filament_profiles_<id>]`` section,
with a read-fallback to the legacy global section for backward compatibility.
"""
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root
import config_loader # noqa: E402
BASE_INI = (
"[printer_1]\nname = K1\n\n"
"[printer_2]\nname = K2\n\n"
"[filament_profiles]\n"
"visible_vendors = Anycubic, SUNLU\n"
"slot_0_vendor = Anycubic\nslot_0_name = Anycubic PLA+\nslot_0_id = GFPLA+\n"
)
def _use_ini(monkeypatch, tmp_path, text=BASE_INI):
path = tmp_path / "config.ini"
path.write_text(text, encoding="utf-8")
monkeypatch.setattr(config_loader, "_find_config_file", lambda: path)
return path
def test_legacy_global_still_works(tmp_path, monkeypatch):
"""No printer_id -> original global section (single-printer back-compat)."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+"
assert config_loader.list_visible_vendors() == ["Anycubic", "SUNLU"]
def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch):
"""Before any per-printer save, both printers see the global mapping."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+"
assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+"
def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch):
"""Core regression: configuring printer 1 must not change printer 2."""
_use_ini(monkeypatch, tmp_path)
config_loader.save_filament_profiles(
{0: {"vendor": "KINGROON", "name": "KINGROON PLA Basic", "id": "Pc0b8a01"}}, "1")
assert config_loader.list_filament_profiles("1")[0]["name"] == "KINGROON PLA Basic"
assert config_loader.list_filament_profiles("2")[0]["name"] == "Anycubic PLA+"
# legacy global section preserved untouched
assert config_loader.list_filament_profiles()[0]["name"] == "Anycubic PLA+"
def test_visible_vendors_isolated_per_printer(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path)
config_loader.save_visible_vendors(["KINGROON"], "1")
assert config_loader.list_visible_vendors("1") == ["KINGROON"]
assert config_loader.list_visible_vendors("2") == ["Anycubic", "SUNLU"]
def test_save_visible_vendors_keeps_slot_fallback(tmp_path, monkeypatch):
"""Creating a per-printer section only for vendors must not orphan slots."""
_use_ini(monkeypatch, tmp_path)
config_loader.save_visible_vendors(["KINGROON"], "1")
assert config_loader.list_filament_profiles("1")[0]["name"] == "Anycubic PLA+"

271
tests/test_kxgauge.py Normal file
View File

@@ -0,0 +1,271 @@
"""KXGauge display integration (https://gitea.it-drui.de/viewit/kxgauge).
KXGauge is a small ESP32 round-face display with a GET-only HTTP API and no
push/websocket - the bridge has to actively poke it on printer state/temp
changes. Covers: the KXGaugeClient dedupe logic, settings GET/POST roundtrip
for the new fields, the mapping config loader, and the /api/kxgauge/test
connectivity check endpoint.
"""
import argparse
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from aiohttp.test_utils import TestClient, TestServer
from kobrax_moonraker_bridge import KobraXBridge, build_app
from kxgauge_client import KXGaugeClient
# ── KXGaugeClient (sync HTTP client, dedupe logic) ──────────────────────────
def test_set_emotion_sends_get_and_dedupes():
kg = KXGaugeClient("http://192.168.1.99")
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
kg.set_emotion("happy")
kg.set_emotion("happy") # same emotion again - must not re-send
assert mock_get.call_count == 1
assert mock_get.call_args[0][0] == "http://192.168.1.99/emotion/happy"
def test_set_emotion_sends_again_after_change():
kg = KXGaugeClient("http://192.168.1.99")
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
kg.set_emotion("happy")
kg.set_emotion("scared")
assert mock_get.call_count == 2
assert mock_get.call_args[0][0] == "http://192.168.1.99/emotion/scared"
def test_set_heat_celsius_sends_peak_once_then_dedupes_within_tolerance():
kg = KXGaugeClient("http://192.168.1.99", heat_peak=230)
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
kg.set_heat_celsius(200.0)
kg.set_heat_celsius(200.5) # within tolerance - no new /heat/celsius call
urls = [c.args[0] for c in mock_get.call_args_list]
assert "http://192.168.1.99/heat/peak/celsius/230" in urls
assert urls.count("http://192.168.1.99/heat/celsius/200.0") == 1
assert not any("/heat/celsius/200.5" in u for u in urls)
def test_set_heat_celsius_sends_again_beyond_tolerance():
kg = KXGaugeClient("http://192.168.1.99")
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
kg.set_heat_celsius(200.0)
kg.set_heat_celsius(210.0)
heat_calls = [c.args[0] for c in mock_get.call_args_list if "/heat/celsius/" in c.args[0]]
assert len(heat_calls) == 2
def test_client_swallows_connection_errors():
kg = KXGaugeClient("http://192.168.1.99")
with patch("requests.get", side_effect=OSError("unreachable")):
kg.set_emotion("happy") # must not raise
assert kg._last_emotion is None # failed send - state not updated
def test_health_check_true_on_200():
kg = KXGaugeClient("http://192.168.1.99")
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(raise_for_status=lambda: None)
assert kg.health_check() is True
def test_health_check_false_on_error():
kg = KXGaugeClient("http://192.168.1.99")
with patch("requests.get", side_effect=OSError("unreachable")):
assert kg.health_check() is False
# ── Bridge integration: settings GET/POST, test endpoint ───────────────────
def _make_bridge(pid="1", **arg_overrides):
c = MagicMock()
c.callbacks = {}
c.connected = False
args = argparse.Namespace(
printer_ip="192.168.1.50", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxgauge-"),
kxgauge_url="", kxgauge_enabled=0, kxgauge_heat_peak=250,
)
for k, v in arg_overrides.items():
setattr(args, k, v)
all_bridges = {}
bridge = KobraXBridge(c, args=args, printer_id=pid, all_bridges=all_bridges)
all_bridges[pid] = bridge
return bridge
@pytest_asyncio.fixture
async def kxgauge_client():
bridge = _make_bridge(kxgauge_url="http://192.168.1.77", kxgauge_enabled=1, kxgauge_heat_peak=230)
# _find_config_path() otherwise resolves to the real repo config/config.ini
# (env_loader.find_config_path() walks from the script location, ignoring
# args.data_dir) - a settings POST in a test must never touch that file.
import pathlib
sandbox_cfg = pathlib.Path(tempfile.mkdtemp(prefix="kxgauge-cfg-")) / "config.ini"
bridge._find_config_path = lambda: sandbox_cfg
bridge._restart_bridge = lambda: None # a settings POST schedules a restart - don't kill the test process
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
yield c, bridge
def _fake_get_response(status=200, json_body=None):
resp = MagicMock()
resp.status = status
resp.json = AsyncMock(return_value=json_body or {})
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=resp)
ctx.__aexit__ = AsyncMock(return_value=False)
return ctx
@pytest.mark.asyncio
async def test_bridge_creates_kxgauge_client_when_enabled():
bridge = _make_bridge(kxgauge_url="http://192.168.1.77", kxgauge_enabled=1)
assert bridge._kxgauge is not None
assert bridge._kxgauge.base_url == "http://192.168.1.77"
@pytest.mark.asyncio
async def test_bridge_no_kxgauge_client_when_disabled():
bridge = _make_bridge(kxgauge_url="http://192.168.1.77", kxgauge_enabled=0)
assert bridge._kxgauge is None
@pytest.mark.asyncio
async def test_bridge_no_kxgauge_client_when_no_url():
bridge = _make_bridge(kxgauge_enabled=1, kxgauge_url="")
assert bridge._kxgauge is None
@pytest.mark.asyncio
async def test_settings_roundtrip_persists_kxgauge_fields(kxgauge_client):
c, bridge = kxgauge_client
resp = await c.get("/api/settings")
data = await resp.json()
assert data["kxgauge_url"] == "http://192.168.1.77"
assert data["kxgauge_enabled"] == 1
assert data["kxgauge_heat_peak"] == 230
assert isinstance(data["kxgauge_mapping"], dict)
assert data["kxgauge_mapping"]["printing"] == "happy"
@pytest.mark.asyncio
async def test_kxgauge_mapping_default_when_no_config_section():
bridge = _make_bridge()
assert bridge._kxgauge_mapping["free"] == "neutral"
assert bridge._kxgauge_mapping["error"] == "scared"
@pytest.mark.asyncio
async def test_kxgauge_test_endpoint_success(kxgauge_client):
c, bridge = kxgauge_client
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, {"emotion": "HAPPY"})) as mock_get:
resp = await c.post("/api/kxgauge/test", json={})
data = await resp.json()
assert resp.status == 200
assert data["result"] == "ok"
assert data["emotion"] == "HAPPY"
assert mock_get.call_args[0][0] == "http://192.168.1.77/status"
@pytest.mark.asyncio
async def test_kxgauge_test_endpoint_uses_url_from_body_over_configured():
bridge = _make_bridge() # no configured kxgauge_url
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, {"emotion": "NEUTRAL"})) as mock_get:
resp = await c.post("/api/kxgauge/test", json={"url": "http://192.168.1.88"})
data = await resp.json()
assert resp.status == 200
assert mock_get.call_args[0][0] == "http://192.168.1.88/status"
@pytest.mark.asyncio
async def test_kxgauge_test_endpoint_no_url_configured_error():
bridge = _make_bridge()
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
resp = await c.post("/api/kxgauge/test", json={})
assert resp.status == 400
@pytest.mark.asyncio
async def test_kxgauge_test_endpoint_unreachable_returns_502(kxgauge_client):
c, bridge = kxgauge_client
with patch("aiohttp.ClientSession.get", side_effect=OSError("connection refused")):
resp = await c.post("/api/kxgauge/test", json={})
assert resp.status == 502
@pytest.mark.asyncio
async def test_settings_post_saves_custom_mapping(kxgauge_client):
c, bridge = kxgauge_client
resp = await c.post("/api/settings", json={
"printer_ip": "192.168.1.50", "mqtt_port": 9883,
"kxgauge_url": "http://192.168.1.77", "kxgauge_enabled": 1,
"kxgauge_heat_peak": 240,
"kxgauge_mapping": {"printing": "love", "error": "furious"},
})
assert resp.status == 200
assert bridge._kxgauge_mapping["printing"] == "love"
assert bridge._kxgauge_mapping["error"] == "furious"
@pytest.mark.asyncio
async def test_settings_post_rejects_invalid_emotion(kxgauge_client):
c, bridge = kxgauge_client
before = dict(bridge._kxgauge_mapping)
resp = await c.post("/api/settings", json={
"printer_ip": "192.168.1.50", "mqtt_port": 9883,
"kxgauge_mapping": {"printing": "not_a_real_emotion"},
})
assert resp.status == 200
# invalid value must be rejected, mapping keeps its previous value
assert bridge._kxgauge_mapping["printing"] == before["printing"]
# ── MQTT callback hooks fire the mapped emotion / heat value ───────────────
@pytest.mark.asyncio
async def test_on_temp_pushes_heat_to_kxgauge(kxgauge_client):
c, bridge = kxgauge_client
bridge._kxgauge.set_heat_celsius = MagicMock()
bridge._on_temp({"data": {"curr_nozzle_temp": 205, "target_nozzle_temp": 210,
"curr_hotbed_temp": 60, "target_hotbed_temp": 60}})
bridge._kxgauge.set_heat_celsius.assert_called_once_with(205.0)
@pytest.mark.asyncio
async def test_on_print_pushes_mapped_emotion_to_kxgauge(kxgauge_client):
c, bridge = kxgauge_client
bridge._kxgauge.set_emotion = MagicMock()
bridge._on_print({"state": "printing", "data": {}})
bridge._kxgauge.set_emotion.assert_called_once_with("happy")
@pytest.mark.asyncio
async def test_on_print_unmapped_state_does_not_call_kxgauge(kxgauge_client):
c, bridge = kxgauge_client
bridge._kxgauge_mapping = {} # no entries at all
bridge._kxgauge.set_emotion = MagicMock()
bridge._on_print({"state": "printing", "data": {}})
bridge._kxgauge.set_emotion.assert_not_called()
@pytest.mark.asyncio
async def test_no_kxgauge_configured_callbacks_are_noop():
bridge = _make_bridge() # kxgauge disabled
assert bridge._kxgauge is None
# must not raise even though _kxgauge is None
bridge._on_temp({"data": {"curr_nozzle_temp": 200, "target_nozzle_temp": 200,
"curr_hotbed_temp": 60, "target_hotbed_temp": 60}})
bridge._on_print({"state": "printing", "data": {}})

View File

@@ -0,0 +1,151 @@
"""server/files/metadata state-leak + terminal-state reset gaps (Issue #102).
Reported by @fmontagna via moonraker-obico:
1. Querying metadata for a file OTHER than the currently/last tracked job
leaked that job's live layer count / estimated time into the response,
because _build_file_metadata() read from self._state first and only fell
back to the file's own GCodeStore row when the state value was falsy.
2. curr_layer/total_layers (and, for a successful "finished" print, every
other per-job field) were never reset at print end - they stayed at the
last job's values until the next print happened to overwrite them.
3. The printer reports its own "progress" during pre-print phases
(preheating/auto_leveling/checking/...), which used to pass straight
through to display_status.progress/virtual_sdcard.progress and then jump
non-monotonically once real printing started and progress reset.
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
def _bridge():
c = MagicMock(); c.callbacks = {}; c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxmeta-"),
)
return KobraXBridge(c, args=args)
def _insert_store_row(b, filename, layer_count=None, est_time=None, size_bytes=0):
with b._store._lock:
b._store._conn.execute(
"INSERT OR REPLACE INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) "
"VALUES (?,?,?,?,?,?,?)",
(filename, filename, "/tmp/" + filename, size_bytes, "2026-01-01T00:00:00Z", layer_count, est_time),
)
b._store._conn.commit()
# --- Fix 1: metadata state-leak -------------------------------------------
def test_metadata_for_untracked_file_does_not_leak_running_job_state():
b = _bridge()
# A job is "running": live state has its own layer/time values.
b._state["filename"] = "running.gcode"
b._state["total_layers"] = 999
b._state["slicer_time"] = 12345
b._state["layer_height"] = 0.3
# Querying a DIFFERENT, unrelated file must use ITS OWN store row, not
# the running job's live state.
_insert_store_row(b, "other.gcode", layer_count=42, est_time=600, size_bytes=1000)
meta = b._build_file_metadata("other.gcode")
assert meta["layer_count"] == 42
assert meta["estimated_time"] == 600
assert meta["size"] == 1000
def test_metadata_for_tracked_file_still_uses_live_state():
"""The currently-tracked file's OWN metadata query should still prefer
live state (fresher than what was known at upload time)."""
b = _bridge()
b._state["filename"] = "running.gcode"
b._state["total_layers"] = 55
b._state["slicer_time"] = 999
_insert_store_row(b, "running.gcode", layer_count=1, est_time=1)
meta = b._build_file_metadata("running.gcode")
assert meta["layer_count"] == 55
assert meta["estimated_time"] == 999
def test_metadata_for_nonexistent_file_falls_back_cleanly():
b = _bridge()
b._state["filename"] = "running.gcode"
b._state["total_layers"] = 999
meta = b._build_file_metadata("DOES_NOT_EXIST.gcode")
assert meta["layer_count"] is None
assert meta["size"] == 1 # documented fallback, unrelated to this fix
# --- Fix 2: terminal-state reset -------------------------------------------
def _print_payload(state, **extra):
"""print/report envelope: `state` is top-level, everything else is under
`data` (see _on_print: kobra_state = payload.get("state", "")). """
d = {"filename": "job.gcode"}
d.update(extra)
return {"state": state, "data": d}
def test_finished_resets_layer_fields_like_stoped_canceled():
b = _bridge()
b._state["curr_layer"] = 10
b._state["total_layers"] = 20
b._state["progress"] = 0.5
b._state["filename"] = "job.gcode"
b._on_print(_print_payload("finished"))
assert b._state["curr_layer"] == 0
assert b._state["total_layers"] == 0
assert b._state["progress"] == 0.0
assert b._state["filename"] == ""
def test_canceled_resets_layer_fields():
b = _bridge()
b._state["curr_layer"] = 7
b._state["total_layers"] = 20
b._on_print(_print_payload("canceled"))
assert b._state["curr_layer"] == 0
assert b._state["total_layers"] == 0
def test_on_info_resets_layer_fields_on_terminal_state():
b = _bridge()
b._state["curr_layer"] = 7
b._state["total_layers"] = 20
b._on_info({"data": {"project": {"state": "finished"}}})
assert b._state["curr_layer"] == 0
assert b._state["total_layers"] == 0
# --- Fix 3: progress clamping during pre-print phases ----------------------
def test_progress_not_updated_during_auto_leveling():
b = _bridge()
b._state["progress"] = 0.0
b._on_print(_print_payload("auto_leveling", progress=87))
assert b._state["progress"] == 0.0
def test_progress_not_updated_during_preheating():
b = _bridge()
b._state["progress"] = 0.0
b._on_print(_print_payload("preheating", progress=42))
assert b._state["progress"] == 0.0
def test_progress_updates_normally_once_printing():
b = _bridge()
b._on_print(_print_payload("printing", progress=33))
assert b._state["progress"] == 0.33
def test_on_info_progress_clamped_during_checking():
b = _bridge()
b._state["progress"] = 0.0
b._on_info({"data": {"project": {"state": "checking", "progress": 55}}})
assert b._state["progress"] == 0.0

View File

@@ -0,0 +1,152 @@
"""
Tests für den MQTT-Reconnect-Mechanismus (Issue #105) — der Client hing nach
einem Drucker-Reconnect fest, weil zwei unabhängige Fehlerpfade (der Reader-
Thread-Keepalive und publish()'s eigener Reconnect-Trigger) unkoordiniert
parallel liefen, UND weil der Poll-Loop einen None-Rückgabewert von publish()
(statt einer Exception) nie als "Verbindung tot" erkannte.
"""
import threading
import time
import pytest
from kobrax_client import KobraXClient
def _client(**overrides):
kwargs = dict(
host="192.168.1.100", username="u", password="p",
mode_id="20030", device_id="abc123", port=9883,
client_id="test",
)
kwargs.update(overrides)
return KobraXClient(**kwargs)
def test_is_connected_false_when_no_socket():
c = _client()
assert c.is_connected() is False
def test_is_connected_true_when_socket_present():
c = _client()
c._sock = object() # any truthy stand-in for a real socket
assert c.is_connected() is True
def test_reconnect_concurrent_calls_only_run_do_connect_once():
"""Two threads calling _reconnect() at the same time must not both run
_do_connect() - only one handshake should happen; the second caller waits
for the first instead of racing it (Issue #105)."""
c = _client()
c._running = True
do_connect_calls = []
call_lock = threading.Lock()
release_event = threading.Event()
def fake_do_connect():
with call_lock:
do_connect_calls.append(1)
# Simulate a slow handshake so the second _reconnect() call has time
# to observe the lock as already held.
release_event.wait(timeout=2.0)
c._sock = object()
c._do_connect = fake_do_connect
results = []
def run():
results.append(c._reconnect())
t1 = threading.Thread(target=run)
t2 = threading.Thread(target=run)
t1.start()
time.sleep(0.05) # let t1 acquire the lock and enter _do_connect first
t2.start()
time.sleep(0.1)
release_event.set() # let the in-flight handshake finish
t1.join(timeout=3)
t2.join(timeout=3)
assert len(do_connect_calls) == 1
assert results == [True, True]
def test_reconnect_second_waiter_returns_after_first_completes():
c = _client()
c._running = True
def fake_do_connect():
time.sleep(0.1)
c._sock = object()
c._do_connect = fake_do_connect
t1 = threading.Thread(target=c._reconnect)
t1.start()
time.sleep(0.02)
# Second call while the first is still mid-handshake.
result = c._reconnect()
t1.join(timeout=3)
assert result is True
assert c._sock is not None
def test_reconnect_non_blocking_returns_immediately_while_reconnect_in_progress():
"""The poll-loop path (publish/publish_web) must NOT block while the reader
thread's persistent reconnect is running its multi-minute backoff loop -
it has to return so the poll loop can flip kobra_state to "offline".
A printer unplugged mid-connection otherwise left the dashboard stuck on
the last known state indefinitely (Issue #103 follow-up)."""
c = _client()
c._running = True
started = threading.Event()
release = threading.Event()
def slow_persistent_do_connect():
started.set()
# Simulate the printer still being gone: never succeeds until released.
release.wait(timeout=5.0)
raise OSError("still unreachable")
c._do_connect = slow_persistent_do_connect
# First reconnect (reader-thread style): persistent, holds the lock, stuck
# in backoff.
t1 = threading.Thread(target=lambda: c._reconnect(persist=True), daemon=True)
t1.start()
assert started.wait(timeout=2.0)
# Poll-loop style call must return basically instantly, not block on t1.
t0 = time.time()
result = c._reconnect(wait_if_in_progress=False, persist=False)
elapsed = time.time() - t0
assert elapsed < 0.5, f"non-blocking reconnect blocked for {elapsed:.2f}s"
assert result is False # socket is down while the other reconnect churns
release.set() # let the daemon thread unwind
def test_reconnect_one_shot_does_not_loop_on_failure():
"""persist=False must attempt the handshake at most once and return,
instead of entering the backoff loop (which would block the caller)."""
c = _client()
c._running = True
attempts = []
def failing_do_connect():
attempts.append(1)
raise OSError("unreachable")
c._do_connect = failing_do_connect
t0 = time.time()
result = c._reconnect(persist=False)
elapsed = time.time() - t0
assert result is False
assert len(attempts) == 1 # exactly one attempt, no backoff retries
assert elapsed < 0.5

View File

@@ -0,0 +1,117 @@
"""Multi-ACE aggregation in ace_direct mode (Issue #95, Kobra S1).
A Kobra S1 with two daisy-chained ACE Pro units reports
multi_color_box = [{id:0, slots:[4]}, {id:1, slots:[4]}] with NO toolhead
entry (id:-1). The old ace_direct branch kept only ace_boxes[0], silently
dropping the second unit — the dashboard and the OrcaSlicer sync only ever
saw 4 of the 8 slots. Payloads below are trimmed from the real log attached
to the issue.
"""
from kobrax_moonraker_bridge import KobraXBridge
def _slot(index, type_="PLA", color=(1, 2, 3), status=5):
return {
"index": index, "sku": "", "type": type_, "color": list(color),
"edit_status": 0, "status": status,
"color_group": [list(color) + [255]], "icon_type": 0,
"consumables_percent": 50,
}
def _ace_box(box_id, loaded_slot=-1, n_slots=4):
return {
"id": box_id, "status": 1, "model_id": 0, "auto_feed": 1,
"loaded_slot": loaded_slot,
"feed_status": {"code": 200, "type": -1, "current_status": -1, "slot_index": -1},
"temp": 30, "humidity": 0,
"drying_status": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"slots": [_slot(i) for i in range(n_slots)],
}
def _toolhead_box(n_slots=4):
box = _ace_box(-1, n_slots=n_slots)
return box
# ── mode detection ───────────────────────────────────────────────────────────
def test_two_ace_units_no_toolhead_is_ace_direct():
boxes = [_ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_direct"
# ── ace_direct aggregation ───────────────────────────────────────────────────
def test_single_ace_unit_yields_4_slots():
"""Kobra X regression: one unit, global indices 0-3 exactly as before."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0)], "ace_direct")
assert len(slots) == 4
assert [s["global_index"] for s in slots] == [0, 1, 2, 3]
assert all(s["box_id"] == 0 for s in slots)
assert loaded == -1
def test_two_ace_units_yield_8_slots():
"""Issue #95: the second unit's slots must appear as global 4-7."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
assert len(slots) == 8
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_two_ace_units_report_order_does_not_matter():
"""Boxes sorted by id — global numbering stays stable if the firmware
reports unit 1 before unit 0."""
slots, _ = KobraXBridge._aggregate_slots([_ace_box(1), _ace_box(0)], "ace_direct")
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_loaded_slot_on_second_unit_maps_to_global():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0), _ace_box(1, loaded_slot=2)], "ace_direct")
assert loaded == 6 # 1*4 + 2
def test_loaded_slot_on_first_unit_unchanged():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0, loaded_slot=3), _ace_box(1)], "ace_direct")
assert loaded == 3
# ── ace_hub regression (unchanged behavior) ──────────────────────────────────
def test_ace_hub_numbering_unchanged():
boxes = [_toolhead_box(), _ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_hub"
slots, _ = KobraXBridge._aggregate_slots(boxes, "ace_hub")
# 3 toolhead + 4 + 4 ACE
assert len(slots) == 11
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert [s["box_id"] for s in slots][:3] == [-1, -1, -1]
# ── _box_local_to_global / _global_to_box_slot round-trip ────────────────────
def _bridge_with_mode(mode, slots):
b = object.__new__(KobraXBridge)
b._filament_mode = mode
b._ams_slots = slots
return b
def test_box_local_to_global_ace_direct_second_unit():
b = _bridge_with_mode("ace_direct", [])
assert b._box_local_to_global(0, 2, []) == 2
assert b._box_local_to_global(1, 2, []) == 6
def test_global_to_box_slot_round_trip_two_units():
slots, _ = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
b = _bridge_with_mode("ace_direct", slots)
for g in range(8):
box_id, local = b._global_to_box_slot(g)
assert (box_id, local) == (g // 4, g % 4)
assert b._box_local_to_global(box_id, local, []) == g

View File

@@ -0,0 +1,81 @@
"""AttributeError crash on multiColorBox/report failure (Issue #100).
Real KX2-Pro bug: manually assigning a filament profile to an ACE slot
(custom-RFID / third-party filament) gets rejected by the printer. Instead of
echoing the normal success shape, the printer replies with `state: "failed"`
and `data` as a 2-element LIST (`["multi_color_box", [...]]`) instead of the
usual dict (`{"multi_color_box": [...]}`). `_on_multicolor_box` called
`data.get(...)` unconditionally, crashing with
`AttributeError: 'list' object has no attribute 'get'` and silently dropping
the report (including the slot-state update it would otherwise have done).
"""
import argparse
import tempfile
from unittest.mock import MagicMock
from kobrax_moonraker_bridge import KobraXBridge
# Exact failure payload from the Issue #100 log.
FAILED_PAYLOAD = {
"state": "failed",
"data": ["multi_color_box", [{"filaments": {"id": 2}, "id": 0}]],
}
SUCCESS_PAYLOAD = {
"state": "success",
"data": {
"head_tools_model": 1,
"multi_color_box": [
{"id": -1, "slots": []},
{
"id": 0,
"slots": [
{"index": 0, "type": "PLA", "color": [0, 156, 189], "status": 5},
],
},
],
},
}
def _bridge():
c = MagicMock(); c.callbacks = {}; c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxmcb-"),
)
return KobraXBridge(c, args=args)
def test_failed_state_does_not_crash():
b = _bridge()
b._on_multicolor_box(FAILED_PAYLOAD) # must not raise
assert b._state["last_ams_set_error"] is True
def test_success_after_failure_clears_error_flag():
b = _bridge()
b._on_multicolor_box(FAILED_PAYLOAD)
assert b._state["last_ams_set_error"] is True
b._on_multicolor_box(SUCCESS_PAYLOAD)
assert b._state["last_ams_set_error"] is False
def test_non_dict_data_without_failed_state_does_not_crash():
"""Defensive guard: any future non-dict `data` shape must not crash,
even if the printer doesn't set state="failed" for it."""
b = _bridge()
b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]})
def test_failed_report_is_logged_with_the_triggering_request(caplog):
"""The failure payload alone carries no slot/type/color info - the log
must correlate it with the setInfo request that triggered it, otherwise
the failure reason can't be diagnosed from bridge logs alone."""
import logging
b = _bridge()
b._last_ams_set_request = {"global": 6, "box": 0, "local_slot": 3, "type": "PLA", "color": [33, 39, 33]}
with caplog.at_level(logging.WARNING):
b._on_multicolor_box(FAILED_PAYLOAD)
assert any("global" in r.message and "6" in r.message for r in caplog.records)

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

@@ -0,0 +1,96 @@
"""
Tests für _poll_loop's Umgang mit einer toten MQTT-Session (Issue #105).
publish()/query_info() swallow send failures internally and return None
instead of raising - the poll loop must treat that (combined with
is_connected() == False) as "connection lost" and switch to the offline
branch, instead of silently retrying forever every poll_interval.
"""
import argparse
import tempfile
import threading
import time
from unittest.mock import MagicMock
import pytest
from kobrax_moonraker_bridge import KobraXBridge
def _bridge():
c = MagicMock()
c.callbacks = {}
c.connected = False
args = argparse.Namespace(
printer_ip="192.168.1.100", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxpoll-"), poll_interval=0.05,
)
return KobraXBridge(c, args=args)
def test_poll_loop_switches_to_offline_when_query_returns_none_and_disconnected():
b = _bridge()
b._state["print_state"] = "standby"
b._state["kobra_state"] = "free"
b.client.query_info.return_value = None
b.client.is_connected.return_value = False
b._printer_reachable = MagicMock(return_value=True) # TCP still fine
stop_event = threading.Event()
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
t.start()
time.sleep(0.2)
stop_event.set()
t.join(timeout=2)
assert b._state["kobra_state"] == "offline"
b.client.disconnect.assert_called()
def test_poll_loop_stays_online_when_query_returns_none_but_still_connected():
"""A single missed poll tick (info momentarily falsy) must not flip the
bridge offline if the MQTT session itself is still alive."""
b = _bridge()
b._state["print_state"] = "standby"
b._state["kobra_state"] = "free"
b.client.query_info.return_value = None
b.client.is_connected.return_value = True # session still up
b.client.query_multicolor_box.return_value = None
b._printer_reachable = MagicMock(return_value=True)
stop_event = threading.Event()
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
t.start()
time.sleep(0.2)
stop_event.set()
t.join(timeout=2)
assert b._state["kobra_state"] != "offline"
def test_poll_loop_recovers_via_offline_branch_once_reachable_again():
"""Once flipped offline, the existing offline branch should re-connect
as soon as the printer becomes reachable again (pre-existing behavior,
unaffected by this fix)."""
b = _bridge()
b._state["print_state"] = "standby"
b._state["kobra_state"] = "free"
b.client.query_info.return_value = None
b.client.is_connected.return_value = False
b._printer_reachable = MagicMock(return_value=True)
stop_event = threading.Event()
t = threading.Thread(target=b._poll_loop, args=(stop_event,), daemon=True)
t.start()
time.sleep(0.15) # let it flip offline
assert b._state["kobra_state"] == "offline"
# Printer "comes back": client.connect() succeeds, subsequent query_info
# starts returning real data again.
b.client.connect.side_effect = None
b.client.query_info.return_value = {"data": {"state": "free"}}
time.sleep(0.2)
stop_event.set()
t.join(timeout=2)
b.client.connect.assert_called()

View File

@@ -0,0 +1,233 @@
"""
Tests für /kx/printer-files (list) und /kx/printer-files/delete —
der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt
(via MQTT file/listLocal + file/deleteBatch, live gegen den echten
Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md).
Important: publish()'s own return value for these actions is just a
generic immediate ACK skeleton (code=0, empty fields) - the real answer
arrives asynchronously via the file/report callback (_on_file), same as
the existing fileDetails fire-and-forget pattern. So publish() itself
returns None/skeleton here, and the "real" response is delivered by
firing bridge._on_file(...) from a background thread, simulating what
the MQTT reader thread would do when the printer's file/report arrives.
"""
import threading
import time
import pytest
LISTLOCAL_SUCCESS = {
"action": "listLocal",
"code": 200,
"state": "success",
"data": {
"list_mode": 0,
"records": [
{"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000},
{"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000},
{"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000},
],
},
}
LISTLOCAL_FAILED = {
"action": "listLocal",
"code": 10112,
"state": "failed",
"data": None,
}
DELETEBATCH_SUCCESS = {
"action": "deleteBatch",
"code": 200,
"state": "success",
"data": None,
"msg": "done",
}
DELETEBATCH_FAILED = {
"action": "deleteBatch",
"code": 10112,
"state": "failed",
"data": None,
}
FILEDETAILS_SUCCESS = {
"action": "fileDetails",
"code": 200,
"state": "done",
"data": {
"file_details": {
"thumbnail": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB",
"png_image": "",
"svg_image": "",
"objects_skip_parts": [],
},
"filename": "a.gcode",
"root": "local",
},
}
FILEDETAILS_NO_THUMBNAIL = {
"action": "fileDetails",
"code": 200,
"state": "done",
"data": {
"file_details": {"thumbnail": "", "png_image": "", "svg_image": "", "objects_skip_parts": []},
"filename": "a.gcode",
"root": "local",
},
}
FILEDETAILS_FAILED = {
"action": "fileDetails",
"code": 10112,
"state": "failed",
"data": None,
}
def _deliver_async(bridge, payload, delay=0.05):
"""Simulates the MQTT reader thread delivering a file/report a moment
after the fire-and-forget publish() call returns."""
def _fire():
time.sleep(delay)
bridge._on_file(payload)
threading.Thread(target=_fire, daemon=True).start()
@pytest.mark.asyncio
async def test_printer_files_lists_files_and_excludes_dirs(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
resp = await c.get("/kx/printer-files")
assert resp.status == 200
data = await resp.json()
filenames = [f["filename"] for f in data["result"]]
assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded
@pytest.mark.asyncio
async def test_printer_files_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
await c.get("/kx/printer-files")
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "listLocal"
assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"}
@pytest.mark.asyncio
async def test_printer_files_returns_502_on_printer_failure(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1]
resp = await c.get("/kx/printer-files")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_files_returns_502_on_timeout(client):
c, bridge = client
bridge.client.publish.return_value = None # no file/report ever arrives
resp = await c.get("/kx/printer-files")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_delete_success(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
assert resp.status == 200
data = await resp.json()
assert data["result"] == "ok"
@pytest.mark.asyncio
async def test_printer_file_delete_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]})
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "deleteBatch"
assert args[2] == {
"root": "local",
"files": [
{"path": "/", "filename": "a.gcode"},
{"path": "/", "filename": "b.gcode"},
],
}
@pytest.mark.asyncio
async def test_printer_file_delete_empty_filenames_returns_400(client):
c, _ = client
resp = await c.post("/kx/printer-files/delete", json={"filenames": []})
assert resp.status == 400
@pytest.mark.asyncio
async def test_printer_file_delete_returns_502_on_printer_rejection(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1]
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_thumbnail_success(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 200
data = await resp.json()
assert data["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@pytest.mark.asyncio
async def test_printer_file_thumbnail_uses_correct_mqtt_payload(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
await c.get("/kx/printer-files/a.gcode/thumbnail")
args, kwargs = bridge.client.publish.call_args
assert args[0] == "file"
assert args[1] == "fileDetails"
assert args[2] == {"root": "local", "filename": "a.gcode"}
@pytest.mark.asyncio
async def test_printer_file_thumbnail_empty_when_no_thumbnail_embedded(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_NO_THUMBNAIL), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 200
data = await resp.json()
assert data["result"]["thumbnail"] == ""
@pytest.mark.asyncio
async def test_printer_file_thumbnail_returns_502_on_printer_failure(client):
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_FAILED), None)[1]
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp.status == 502
@pytest.mark.asyncio
async def test_printer_file_thumbnail_is_cached_after_first_fetch(client):
"""Second request for the same filename must not call publish() again."""
c, bridge = client
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
resp1 = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp1.status == 200
call_count_after_first = bridge.client.publish.call_count
resp2 = await c.get("/kx/printer-files/a.gcode/thumbnail")
assert resp2.status == 200
data2 = await resp2.json()
assert data2["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
assert bridge.client.publish.call_count == call_count_after_first # no new MQTT call

175
tests/test_printer_power.py Normal file
View File

@@ -0,0 +1,175 @@
"""External smart-plug power control (Issue #103).
The printer itself has no MQTT command to power off or enter standby - only
heaters/motors/etc. can be controlled remotely. For users running the
printer through a Tasmota-style smart plug, the bridge exposes plain
HTTP GET on/off/status URLs (configured per printer) as its own dashboard
button, instead of routing through Moonraker's device_power API.
"""
import argparse
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from aiohttp.test_utils import TestClient, TestServer
from kobrax_moonraker_bridge import KobraXBridge, build_app
def _make_bridge(pid="1", **arg_overrides):
c = MagicMock()
c.callbacks = {}
c.connected = False
args = argparse.Namespace(
printer_ip="192.168.1.50", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxpower-"),
power_on_url="", power_off_url="", power_status_url="",
)
for k, v in arg_overrides.items():
setattr(args, k, v)
all_bridges = {}
bridge = KobraXBridge(c, args=args, printer_id=pid, all_bridges=all_bridges)
all_bridges[pid] = bridge
return bridge
@pytest_asyncio.fixture
async def power_client():
bridge = _make_bridge(
power_on_url="http://192.168.1.99/cm?cmnd=Power%20on",
power_off_url="http://192.168.1.99/cm?cmnd=Power%20off",
power_status_url="http://192.168.1.99/cm?cmnd=Power",
)
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
yield c, bridge
def _fake_get_response(status=200, text=""):
resp = MagicMock()
resp.status = status
resp.text = AsyncMock(return_value=text)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=resp)
ctx.__aexit__ = AsyncMock(return_value=False)
return ctx
@pytest.mark.asyncio
async def test_printers_list_reports_has_power_control(power_client):
c, bridge = power_client
resp = await c.get("/kx/printers")
data = await resp.json()
assert data["result"][0]["has_power_control"] is True
@pytest.mark.asyncio
async def test_printers_list_no_power_control_when_unconfigured():
bridge = _make_bridge()
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
resp = await c.get("/kx/printers")
data = await resp.json()
assert data["result"][0]["has_power_control"] is False
@pytest.mark.asyncio
async def test_power_on_hits_configured_url(power_client):
c, bridge = power_client
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200)) as mock_get:
resp = await c.post("/kx/printers/1/power", json={"action": "on"})
data = await resp.json()
assert resp.status == 200
assert data["result"] == "ok"
mock_get.assert_called_once()
assert mock_get.call_args[0][0] == "http://192.168.1.99/cm?cmnd=Power%20on"
@pytest.mark.asyncio
async def test_power_off_hits_configured_url(power_client):
c, bridge = power_client
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200)) as mock_get:
resp = await c.post("/kx/printers/1/power", json={"action": "off"})
data = await resp.json()
assert resp.status == 200
assert data["result"] == "ok"
assert mock_get.call_args[0][0] == "http://192.168.1.99/cm?cmnd=Power%20off"
@pytest.mark.asyncio
async def test_power_invalid_action_rejected(power_client):
c, bridge = power_client
resp = await c.post("/kx/printers/1/power", json={"action": "toggle"})
assert resp.status == 400
@pytest.mark.asyncio
async def test_power_unknown_printer_id_404(power_client):
c, bridge = power_client
resp = await c.post("/kx/printers/99/power", json={"action": "on"})
assert resp.status == 404
@pytest.mark.asyncio
async def test_power_missing_url_configured_error():
bridge = _make_bridge() # no power_on_url set
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
resp = await c.post("/kx/printers/1/power", json={"action": "on"})
assert resp.status == 400
@pytest.mark.asyncio
async def test_power_switch_unreachable_returns_502(power_client):
c, bridge = power_client
with patch("aiohttp.ClientSession.get", side_effect=OSError("connection refused")):
resp = await c.post("/kx/printers/1/power", json={"action": "on"})
assert resp.status == 502
@pytest.mark.asyncio
async def test_power_status_parses_tasmota_json(power_client):
c, bridge = power_client
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, '{"POWER":"ON"}')):
resp = await c.get("/kx/printers/1/power-status")
data = await resp.json()
assert data["state"] == "on"
@pytest.mark.asyncio
async def test_power_status_parses_tasmota_json_off(power_client):
c, bridge = power_client
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, '{"POWER":"OFF"}')):
resp = await c.get("/kx/printers/1/power-status")
data = await resp.json()
assert data["state"] == "off"
@pytest.mark.asyncio
async def test_power_status_falls_back_to_plain_text(power_client):
c, bridge = power_client
with patch("aiohttp.ClientSession.get", return_value=_fake_get_response(200, "STATE: ON")):
resp = await c.get("/kx/printers/1/power-status")
data = await resp.json()
assert data["state"] == "on"
@pytest.mark.asyncio
async def test_power_status_missing_url_configured_error():
bridge = _make_bridge() # no power_status_url set
app = build_app(bridge)
async with TestClient(TestServer(app)) as c:
resp = await c.get("/kx/printers/1/power-status")
assert resp.status == 400
@pytest.mark.asyncio
async def test_settings_roundtrip_persists_power_urls(power_client):
c, bridge = power_client
resp = await c.get("/api/settings")
data = await resp.json()
assert data["power_on_url"] == "http://192.168.1.99/cm?cmnd=Power%20on"
assert data["power_off_url"] == "http://192.168.1.99/cm?cmnd=Power%20off"
assert data["power_status_url"] == "http://192.168.1.99/cm?cmnd=Power"

View File

@@ -40,14 +40,23 @@ async def test_settings_get_returns_configured_values(client_configured):
@pytest.mark.asyncio
async def test_settings_post_writes_env(client):
"""POST /api/settings schreibt Werte in .env-Datei."""
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)."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("")
bridge._find_env_path = lambda: env_path
config_path = pathlib.Path(tmpdir) / "config.ini"
bridge._find_config_path = lambda: config_path
bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process
resp = await c.post("/api/settings", json={
"printer_ip": "10.0.0.5",
@@ -59,24 +68,28 @@ async def test_settings_post_writes_env(client):
})
assert resp.status == 200
content = env_path.read_text()
assert "PRINTER_IP=10.0.0.5" in content
assert "MQTT_USERNAME=userABCD" in content
assert "DEVICE_ID=deadbeef01234567" in content
content = config_path.read_text()
assert "printer_ip = 10.0.0.5" in content
assert "username = userABCD" in content
assert "device_id = deadbeef01234567" in content
@pytest.mark.asyncio
async def test_settings_post_preserves_existing_keys(client):
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
"""POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server)."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
bridge._find_env_path = lambda: env_path
config_path = pathlib.Path(tmpdir) / "config.ini"
config_path.write_text(
"[spoolman]\nserver = http://192.168.1.50:7912\n\n"
"[connection]\nprinter_ip = old\n"
)
bridge._find_config_path = lambda: config_path
bridge._restart_bridge = lambda: None
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
content = env_path.read_text()
assert "GITEA_TOKEN=mytoken" in content
assert "PRINTER_IP=10.0.0.99" in content
content = config_path.read_text()
assert "server = http://192.168.1.50:7912" in content
assert "printer_ip = 10.0.0.99" in content

View File

@@ -0,0 +1,157 @@
"""Stale slot-profile guard: suppress a saved per-slot filament profile when the
AMS now reports a *different material family* than the profile was assigned for.
Real-world bug (KX1): slot 1 held a PETG spool and got the profile
"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the
colour (live) but the saved profile stuck on PETG, so the panel + the slicer
hint kept showing/using PETG. Restarting did not help — the override lives in
config.ini.
Fix = non-destructive suppression (Option A): resolve the effective profile as
"the saved override only if its material *family* matches the current AMS
material; otherwise none (fall back to the generic default)". The override is
never deleted, so putting the original material back reactivates it.
Comparison must be by *family*, never strict string equality — PLA / PLA+ /
PLA SILK / PLA MATTE are the same family and must NOT invalidate each other
(regression guard for the earlier over-strict material compare).
"""
import argparse
import json
import tempfile
from unittest.mock import MagicMock
# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader.
from kobrax_moonraker_bridge import KobraXBridge
# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type).
LIBRARY = [
{"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"},
{"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"},
{"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"},
]
PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"}
SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"}
UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"}
def _bridge():
c = MagicMock()
c.callbacks = {}
c.connected = False
args = argparse.Namespace(
printer_ip="", mqtt_port=9883, username="", password="",
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
data_dir=tempfile.mkdtemp(prefix="kxguard-"),
)
b = KobraXBridge(c, args=args)
b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is
return b
# ── _material_family ──────────────────────────────────────────────────────────
def test_material_family_collapses_pla_variants():
fam = KobraXBridge._material_family
assert fam("PLA") == "PLA"
assert fam("PLA+") == "PLA"
assert fam("PLA SILK") == "PLA"
assert fam("PLA MATTE") == "PLA"
assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction
def test_material_family_collapses_petg_variants():
fam = KobraXBridge._material_family
assert fam("PETG") == "PETG"
assert fam("PETG+") == "PETG"
def test_material_family_distinguishes_pla_from_petg():
fam = KobraXBridge._material_family
assert fam("PLA") != fam("PETG")
assert fam("PLA SILK") != fam("PETG")
def test_material_family_empty_for_empty_input():
assert KobraXBridge._material_family("") == ""
assert KobraXBridge._material_family(None) == ""
# ── _effective_slot_profile ───────────────────────────────────────────────────
def test_suppressed_when_family_changes_petg_profile_pla_loaded():
"""The exact KX1 bug: PETG profile, AMS now reports PLA → suppress."""
b = _bridge()
b._filament_profiles = {0: dict(PETG_PROFILE)}
assert b._effective_slot_profile(0, "PLA") == {}
def test_kept_when_family_matches_petg_profile_petg_loaded():
b = _bridge()
b._filament_profiles = {0: dict(PETG_PROFILE)}
assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE
def test_kept_for_pla_variant_no_false_positive():
"""PLA+ profile with a PLA SILK spool loaded is the same family → keep."""
b = _bridge()
b._filament_profiles = {1: dict(SILK_PROFILE)}
assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE
def test_kept_when_profile_material_unknown_failsafe():
"""If the profile is not in the library we cannot know its family → never
suppress on uncertainty (fail-safe keeps the user's choice)."""
b = _bridge()
b._filament_profiles = {2: dict(UNKNOWN_PROFILE)}
assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE
def test_empty_when_no_override():
b = _bridge()
b._filament_profiles = {}
assert b._effective_slot_profile(3, "PLA") == {}
# ── Integration: display endpoint (the visible panel) ─────────────────────────
async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded():
"""/kx/filament/slots must not show the stale PETG identity once PLA loads."""
b = _bridge()
b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
assert row["material"] == "PLA" # AMS truth, always
assert row["filament_name"] == "" # stale PETG identity gone
assert row["filament_vendor"] == ""
async def test_display_endpoint_keeps_profile_when_family_matches():
b = _bridge()
b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
assert row["filament_name"] == "KINGROON PETG Basic"
assert row["filament_vendor"] == "KINGROON"
# ── Integration: print path (lane_data sent to OrcaSlicer) ────────────────────
async def test_lane_data_does_not_leak_stale_petg_identity():
b = _bridge()
b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
tray = b._build_lane_data()["ams"][0]["tray"][0]
assert tray["tray_type"] == "PLA"
assert "PETG" not in tray["name"].upper()
assert tray["vendor_name"] != "KINGROON"
async def test_lane_data_keeps_profile_when_family_matches():
b = _bridge()
b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}]
b._filament_profiles = {0: dict(PETG_PROFILE)}
tray = b._build_lane_data()["ams"][0]["tray"][0]
assert tray["name"] == "KINGROON PETG Basic"
assert tray["vendor_name"] == "KINGROON"

View File

@@ -0,0 +1,100 @@
"""Per-printer Spoolman slot-map isolation + persistence (config_loader).
Regression test for two bugs in the Spoolman slot→spool persistence:
1. The bridge referenced ``config_loader`` while the module alias is
``env_loader`` → ``NameError`` swallowed by a bare ``except``, so the map
was never loaded nor saved (persistence looked implemented but was dead).
2. The map lived in a single global ``[spoolman] slot_spools`` key, so two
printers/two AMS units overwrote each other (same class as issue #74/#75).
Each printer now uses its own ``[spoolman_<id>]`` section, with a read-fallback
to the legacy global key for backward compatibility. The global ``[spoolman]``
section keeps ``server`` / ``sync_rate``.
"""
import sys
import pathlib
import configparser
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) # repo root
import config_loader # noqa: E402
BASE_INI = (
"[printer_1]\nname = K1\n\n"
"[printer_2]\nname = K2\n\n"
"[spoolman]\n"
"server = http://192.168.3.200:7912\n"
"sync_rate = 0\n"
"slot_spools = 0:1,1:2\n"
)
def _use_ini(monkeypatch, tmp_path, text=BASE_INI):
path = tmp_path / "config.ini"
path.write_text(text, encoding="utf-8")
monkeypatch.setattr(config_loader, "_find_config_file", lambda: path)
return path
def test_legacy_global_read(tmp_path, monkeypatch):
"""No printer_id -> original global [spoolman] slot_spools (back-compat)."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_spool_map() == {0: 1, 1: 2}
def test_read_falls_back_to_global_until_first_save(tmp_path, monkeypatch):
"""Before any per-printer save, both printers see the global mapping."""
_use_ini(monkeypatch, tmp_path)
assert config_loader.list_spool_map("1") == {0: 1, 1: 2}
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
def test_saving_one_printer_does_not_touch_the_other(tmp_path, monkeypatch):
"""Core regression: mapping printer 1 must not change printer 2."""
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42, 1: 17}, "1")
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
# printer 2 has no own section yet -> still the global fallback
assert config_loader.list_spool_map("2") == {0: 1, 1: 2}
# legacy global key preserved untouched
assert config_loader.list_spool_map() == {0: 1, 1: 2}
def test_both_printers_isolated_after_each_saves(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42, 1: 17}, "1")
config_loader.save_spool_map({0: 5, 1: 6}, "2")
assert config_loader.list_spool_map("1") == {0: 42, 1: 17}
assert config_loader.list_spool_map("2") == {0: 5, 1: 6}
def test_save_preserves_server_and_sync_rate(tmp_path, monkeypatch):
"""Writing a per-printer map must not clobber [spoolman] server/sync_rate."""
path = _use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42}, "1")
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
assert cfg.get("spoolman", "server") == "http://192.168.3.200:7912"
assert cfg.get("spoolman", "sync_rate") == "0"
assert cfg.get("spoolman_1", "slot_spools") == "0:42"
def test_persistence_round_trips(tmp_path, monkeypatch):
"""Save then read back (simulates a bridge restart) — the map survives."""
_use_ini(monkeypatch, tmp_path, text="[spoolman]\nserver = http://x:7912\n")
config_loader.save_spool_map({0: 7, 2: 9}, "1")
assert config_loader.list_spool_map("1") == {0: 7, 2: 9}
def test_empty_map_clears_the_key(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path)
config_loader.save_spool_map({0: 42}, "1")
config_loader.save_spool_map({}, "1") # clear
# per-printer key gone -> falls back to the legacy global map
assert config_loader.list_spool_map("1") == {0: 1, 1: 2}
def test_parse_ignores_malformed_and_nonpositive(tmp_path, monkeypatch):
_use_ini(monkeypatch, tmp_path,
text="[spoolman]\nslot_spools = 0:1, x:y, 2:0, 3:-4, 4:5, junk\n")
assert config_loader.list_spool_map() == {0: 1, 4: 5}

View File

@@ -0,0 +1,80 @@
"""TCP keepalive + TCP_USER_TIMEOUT on the MQTT socket (Issue #103 follow-up).
Without these, a printer that disappears without a clean TCP close
(unplugged, not gracefully shut down) leaves the socket looking alive to
is_connected() for as long as the OS's default dead-connection timeout -
often 15+ minutes on Linux - since sendall() on a half-open connection is
buffered by the kernel and doesn't fail immediately. This left the
dashboard's printer-state indicator stuck showing the last known state
(e.g. green "ready") long after the printer was actually unreachable,
reported when testing the smart-plug power-switch feature by physically
unplugging the printer.
Verified live (real printer, physically unplugged) that SO_KEEPALIVE alone
is not sufficient: keepalive probes only fire on an idle connection, but if
the printer disappears while a send is still unacknowledged - the normal
case, since the poll loop is sending every few seconds - the kernel's
regular TCP retransmission timer takes over instead (tcp_retries2, 13-30+
minutes on Linux), which keepalive settings don't affect. TCP_USER_TIMEOUT
closes that gap by capping how long ANY unacknowledged data may sit in the
send queue, regardless of which retry mechanism would otherwise still be
running.
"""
import socket
from unittest.mock import MagicMock
import pytest
from kobrax_client import _enable_tcp_keepalive
def test_enable_tcp_keepalive_sets_so_keepalive():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
_enable_tcp_keepalive(s)
assert s.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE) == 1
finally:
s.close()
@pytest.mark.skipif(not hasattr(socket, "TCP_KEEPIDLE"), reason="Linux-specific option")
def test_enable_tcp_keepalive_sets_short_idle_and_interval():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
_enable_tcp_keepalive(s)
idle = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE)
intvl = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL)
cnt = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT)
# Short enough that a dead connection is detected within a couple of
# poll cycles (default poll_interval is 3s), not the OS default of
# minutes.
assert idle <= 10
assert intvl <= 5
assert cnt <= 5
finally:
s.close()
@pytest.mark.skipif(not hasattr(socket, "TCP_USER_TIMEOUT"), reason="Linux-specific option")
def test_enable_tcp_keepalive_sets_user_timeout():
"""The critical fix, verified live against a real printer: without this,
a dead connection with unacknowledged data in flight is only detected
after the OS's normal TCP retransmission timeout (13-30+ minutes on
Linux), not the keepalive interval."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
_enable_tcp_keepalive(s)
user_timeout_ms = s.getsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT)
# Short enough that a dead connection with in-flight data is detected
# within a couple of poll cycles, not tens of minutes.
assert 0 < user_timeout_ms <= 20000
finally:
s.close()
def test_enable_tcp_keepalive_does_not_raise_on_unsupported_platform():
"""A platform without TCP_KEEPIDLE/INTVL/CNT (e.g. some Windows builds)
must not crash the connection attempt - keepalive is best-effort."""
s = MagicMock()
s.setsockopt.side_effect = OSError("unsupported")
_enable_tcp_keepalive(s) # must not raise

103
tests/test_update_check.py Normal file
View File

@@ -0,0 +1,103 @@
"""Update-check regression for Issue #104.
STABLE_RELEASE_API used limit=1, so it only ever saw the single newest
release on Gitea regardless of type. Since nightly/dev prereleases publish
far more often than stable releases, that newest release is almost always a
prerelease - the stable_releases filter (not prerelease) then found nothing
and /api/update/check returned "no stable releases found" even though older
stable releases exist.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@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"
# Patch the API so that if the handler wrongly tried to fetch releases,
# the test would notice (mock returns something, but the handler must not
# reach it).
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
resp.json = AsyncMock(return_value=payload)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=resp)
ctx.__aexit__ = AsyncMock(return_value=False)
return ctx
@pytest.mark.asyncio
async def test_stable_update_check_finds_release_behind_newer_prereleases(client):
c, bridge = client
bridge._read_version = lambda: "0.9.27"
releases = (
[{"tag_name": f"nightly-0.9.30-nightly{i}", "prerelease": True} for i in range(1, 7)]
+ [{"tag_name": "v0.9.29", "prerelease": False, "body": "changelog"}]
)
with patch("aiohttp.ClientSession.get", return_value=_fake_releases_response(releases)):
resp = await c.get("/api/update/check")
data = await resp.json()
assert resp.status == 200
assert data["latest"] == "0.9.29"
assert data["update_available"] is True
@pytest.mark.asyncio
async def test_stable_update_check_requests_enough_releases_to_skip_prereleases(client):
"""The API URL itself must ask for more than the single newest release -
a limit=1 request can never find a stable release behind a run of
prereleases no matter how the response is parsed."""
c, bridge = client
bridge._read_version = lambda: "0.9.27"
import re
assert not re.search(r"limit=1(?!\d)", bridge.STABLE_RELEASE_API), (
"STABLE_RELEASE_API must request more than 1 release, otherwise a "
"recent nightly/dev prerelease being the newest release hides all "
"stable releases behind it (Issue #104)"
)

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,14 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KX-Bridge</title>
<link rel="stylesheet" href="/kx/ui/style.css">
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">
<script src="/kx/ui/lib/pickr.min.js"></script>
<script src="/kx/ui/lib/gridstack-all.min.js"></script>
<body>
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
<div id="pause-msg-banner" style="display:none;background:#b8860b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:997;"></div>
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
<span>📄 <span id="file-ready-name"></span></span>
<button id="file-ready-btn" onclick="startReadyFile()"
@@ -46,15 +51,26 @@
<span class="modal-title" id="slot-edit-title"></span>
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1"></button>
</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
<div style="flex:1">
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
<input type="color" id="slot-edit-color"
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
<div style="display:flex;align-items:flex-start;gap:16px;margin-bottom:12px">
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0;margin-top:4px"></div>
<div style="flex:1;min-width:0">
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-color"></div>
<!-- Pickr anchor — JS mounts the picker here -->
<div id="slot-pickr-anchor"></div>
<!-- hidden input keeps the hex value for saveSlotEdit() -->
<input type="hidden" id="slot-edit-color">
</div>
</div>
<!-- Recent color swatches (max 16, localStorage) -->
<div id="slot-color-swatches" style="display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px"></div>
<!-- Copy from slot -->
<div id="slot-copy-row" style="display:none;margin-bottom:16px">
<select id="slot-copy-select"
style="width:100%;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;box-sizing:border-box"
onchange="slotCopyColor(this)">
<option value="" id="lbl-slot-copy-from">Copy color from slot…</option>
</select>
</div>
<div style="margin-bottom:20px">
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
@@ -124,9 +140,19 @@
<main>
<!-- ═══ DASHBOARD ═══ -->
<div class="panel active" id="panel-dashboard">
<div class="grid">
<div id="dash-toolbar" style="display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-bottom:10px">
<select id="dash-preset" onchange="applyDashPreset(this.value)" style="display:none;padding:4px 8px;font-size:12px;background:var(--raised);color:var(--txt);border:1px solid var(--border);border-radius:6px">
<option value="standard" id="dash-preset-standard">Standard</option>
<option value="wide89" id="dash-preset-wide89">Desktop breit</option>
</select>
<button id="dash-preset-delete-btn" onclick="deleteCurrentDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--err);border:1px solid var(--border);border-radius:6px;cursor:pointer">🗑</button>
<button id="dash-preset-save-btn" onclick="saveCurrentAsDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">💾 <span id="dash-lbl-save-preset">Als Preset speichern</span></button>
<button id="dash-reset-btn" onclick="resetDashLayout()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer"><span id="dash-lbl-reset">Zurücksetzen</span></button>
<button id="dash-edit-btn" onclick="toggleDashEdit()" style="padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">🖉 <span id="dash-lbl-edit">Dashboard anpassen</span></button>
</div>
<div class="grid-stack" id="dash-grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="card-camera" data-card="camera" gs-w="12" gs-h="7">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px">
@@ -141,7 +167,7 @@
<div class="cam-wrap" id="cam-wrap">
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
<div class="cam-spinner" id="cam-spinner"></div>
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
<img id="cam-img" draggable="false" style="display:none;width:100%;height:auto" alt="Kamera">
<div class="cam-overlay" id="cam-overlay" style="display:none">
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
</div>
@@ -151,7 +177,7 @@
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="card-progress" data-card="progress" gs-w="12" gs-h="6">
<div class="card-title"><span></span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
@@ -197,7 +223,7 @@
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="card-temps" data-card="temps" gs-w="6" gs-h="7">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
@@ -240,7 +266,7 @@
</div>
<!-- Achsensteuerung -->
<div class="card">
<div class="card" id="card-motion" data-card="motion" gs-w="6" gs-h="7">
<div class="card-title"><span></span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
<!-- XY -->
@@ -288,7 +314,7 @@
</div>
<!-- Print Speed -->
<div class="card">
<div class="card" id="card-speed" data-card="speed" gs-w="6" gs-h="3">
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
<div style="display:flex;gap:8px;margin-top:4px">
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
@@ -310,7 +336,7 @@
</div>
<!-- Lüfter -->
<div class="card">
<div class="card" id="card-fan" data-card="fan" gs-w="6" gs-h="3">
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
<div class="slider-row">
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
@@ -325,18 +351,19 @@
</div>
</div>
<div id="d-ace-dry-wrap" style="display:none">
<div id="d-ace-dry-grid" style="display:contents"></div>
</div>
<!-- AMS -->
<div class="card" style="grid-column:1/-1" id="d-ams-card">
<div class="card" id="d-ams-card" data-card="ams" gs-w="12" gs-h="4">
<div class="card-title"><span></span> <span id="d-card-ams">Filament</span></div>
<div class="ams-slots" id="ams-slots">
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
</div>
</div>
</div>
<!-- ACE-Trocknung: außerhalb des GridStack-Grids, per Drucker-State eingeblendet -->
<div id="d-ace-dry-wrap" class="grid" style="display:none;margin-top:16px">
<div id="d-ace-dry-grid" style="display:contents"></div>
</div>
<div id="dash-hidden-bar"></div>
</div>
<!-- ═══ CONSOLE ═══ -->
@@ -361,36 +388,81 @@
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
<div class="browser-subtabs" style="display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--border)">
<button class="browser-tab active" id="btab-uploaded" onclick="showBrowserTab('uploaded')"
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
<span id="btab-lbl-uploaded">Hochgeladen</span></button>
<button class="browser-tab" id="btab-printer" onclick="showBrowserTab('printer')"
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
<span id="btab-lbl-printer">Auf dem Drucker</span></button>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
<div id="browser-group-uploaded" class="browser-group active">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
<span id="store-lbl-select-all">Alle auswählen</span>
</label>
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="storeExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
<div id="browser-group-printer" class="browser-group">
<div id="printer-store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="printer-store-error" style="display:none;color:var(--err);text-align:center;padding:20px 0;font-size:13px">
</div>
<div id="printer-store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="printer-store-select-all" onchange="printerFileToggleSelectAll(this.checked)">
<span id="printer-store-lbl-select-all">Alle auswählen</span>
</label>
<span id="printer-store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="printer-store-delete-selected-btn" disabled onclick="printerFileDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="printer-store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="printerFileExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="printer-store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="printer-store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>
@@ -476,6 +548,22 @@
<input type="text" id="s-mode-id" placeholder="20030">
</div>
</div>
<div class="card">
<div class="card-title"><span>🔌</span> <span id="modal-sec-power">Power Switch</span></div>
<div class="modal-field">
<label id="lbl-power-on-url">Power-On URL</label>
<input type="text" id="s-power-on-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20on">
</div>
<div class="modal-field">
<label id="lbl-power-off-url">Power-Off URL</label>
<input type="text" id="s-power-off-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20off">
</div>
<div class="modal-field">
<label id="lbl-power-status-url">Status URL</label>
<input type="text" id="s-power-status-url" placeholder="http://192.168.x.x/cm?cmnd=Power">
<small id="lbl-power-hint" style="color:var(--txt2)"></small>
</div>
</div>
</div>
<!-- Drucker -->
@@ -496,6 +584,10 @@
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance compensation before print</label>
</div>
<div class="modal-field">
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
<select id="s-file-ready-mode">
@@ -511,6 +603,11 @@
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-delete-printer-file-after-print" style="width:auto;margin:0">
<label id="lbl-delete-printer-file-after-print" style="margin:0;cursor:pointer" for="s-delete-printer-file-after-print">Delete file from printer after successful print</label>
</div>
<small id="lbl-delete-printer-file-after-print-hint" style="color:var(--txt2)"></small>
</div>
</div>
@@ -537,6 +634,10 @@
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-verbose-http-log" style="width:auto;margin:0">
<label id="lbl-verbose-http-log" style="margin:0;cursor:pointer" for="s-verbose-http-log">Log every HTTP request (verbose)</label>
</div>
</div>
</div>
@@ -589,13 +690,36 @@
<input type="text" id="s-spoolman-url" placeholder="http://spoolman:7912" style="width:200px">
</div>
<div class="set-row">
<label id="lbl-spoolman-sync-rate">Sync-Rate (s, 0=aus)</label>
<label id="lbl-spoolman-sync-rate">Sync-Rate (s, 0=Druckende)</label>
<input type="number" id="s-spoolman-sync-rate" min="0" max="3600" value="30" style="width:80px">
</div>
<div id="spoolman-status-row" style="margin-top:6px;font-size:12px;color:var(--txt2)">
<span id="spoolman-status-dot"></span> <span id="spoolman-status-lbl"></span>
</div>
</div>
<!-- KXGauge -->
<div class="card" style="margin-top:10px">
<div class="card-title"><span>🙂</span> <span id="modal-sec-kxgauge">KXGauge</span></div>
<div class="set-row">
<label id="lbl-kxgauge-enabled">Aktiviert</label>
<input type="checkbox" id="s-kxgauge-enabled">
</div>
<div class="set-row">
<label id="lbl-kxgauge-url">Geräte-URL</label>
<input type="text" id="s-kxgauge-url" placeholder="http://192.168.x.x" style="width:200px">
</div>
<div class="set-row">
<label id="lbl-kxgauge-heat-peak">Ziel-Temperatur (°C)</label>
<input type="number" id="s-kxgauge-heat-peak" min="0" max="400" value="250" style="width:80px">
</div>
<div class="set-row">
<button type="button" id="btn-kxgauge-test" onclick="testKxgaugeConnection()"><span id="lbl-kxgauge-test">Verbindung testen</span></button>
</div>
<div id="kxgauge-status-row" style="margin-top:6px;font-size:12px;color:var(--txt2)">
<span id="kxgauge-status-dot"></span> <span id="kxgauge-status-lbl"></span>
</div>
<div id="kxgauge-mapping-list" style="margin-top:10px"></div>
</div>
<!-- Obico -->
<div class="card" style="margin-top:10px">
<div class="card-title"><span>🕵</span> <span id="modal-sec-obico">Obico</span></div>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%}

File diff suppressed because one or more lines are too long

3
web/themes/default/lib/pickr.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -15,10 +15,46 @@
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--accent);text-decoration:none}
/* select/option-Farben explizit setzen — OrcaSlicers Device-Tab-Webview erbt
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29). */
select{background:var(--raised)!important;color:var(--txt)!important}
sie sonst nicht und rendert weiße Schrift auf weißem Grund (Issue #29).
Einheitliches Styling für alle Dropdowns im gesamten UI. */
select{
background:var(--raised)!important;
color:var(--txt)!important;
border:1px solid var(--border)!important;
border-radius:8px!important;
padding:6px 10px!important;
font-size:13px!important;
appearance:none!important;
-webkit-appearance:none!important;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888' d='M6 8L0 0h12z'/%3E%3C/svg%3E")!important;
background-repeat:no-repeat!important;
background-position:right 10px center!important;
padding-right:28px!important;
cursor:pointer!important;
outline:none!important;
box-sizing:border-box!important;
}
select:focus{border-color:var(--accent)!important;box-shadow:0 0 0 2px rgba(0,200,255,0.18)!important}
select option{background:var(--card)!important;color:var(--txt)!important}
/* Einheitliches Styling für Text/Number-Inputs */
input[type=text],input[type=number],input[type=url],input[type=password],input[type=email],input[type=search]{
background:var(--raised);
color:var(--txt);
border:1px solid var(--border);
border-radius:8px;
padding:6px 10px;
font-size:13px;
outline:none;
box-sizing:border-box;
}
input[type=text]:focus,input[type=number]:focus,input[type=url]:focus,
input[type=password]:focus,input[type=email]:focus,input[type=search]:focus{
border-color:var(--accent);
box-shadow:0 0 0 2px rgba(0,200,255,0.18);
}
input::placeholder{color:var(--txt2);opacity:1}
/* ── HEADER ── */
header{background:var(--card);border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
@@ -59,18 +95,63 @@ main{flex:1;overflow-y:auto;padding:20px}
/* ── CARD ── */
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
padding:18px;transition:box-shadow .15s,transform .15s}
padding:18px;transition:box-shadow .15s,transform .15s;container-type:inline-size}
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
margin-bottom:14px;display:flex;align-items:center;gap:8px}
.card-title span{font-size:14px}
/* ── DASHBOARD FREE GRID (GridStack) ── */
/* .grid-stack-item-content is already position:absolute + fills the cell
(GridStack's own CSS). The card inside just needs to fill that box — it
must NOT be position:absolute itself, or it can sit above/intercept
GridStack's resize-handle hit area and drag listeners. */
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
the cell smaller than the content needs. */
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow-y:auto;overflow-x:hidden;box-sizing:border-box}
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
as the user starts dragging, throwing off GridStack's position math. Kill
the hover transform for dashboard cards specifically. */
.grid-stack-item-content>.card:hover{transform:none}
/* Edit mode: dashed outline + grab cursor on each item */
#dash-grid.editing .grid-stack-item-content>.card{cursor:grab;
outline:1px dashed var(--accent);outline-offset:-1px;user-select:none}
/* GridStack placeholder styled to theme */
.grid-stack>.grid-stack-placeholder>.placeholder-content{
background:rgba(120,150,255,.12);border:1px dashed var(--accent);border-radius:12px}
/* Resize handles only visible in edit mode */
#dash-grid:not(.editing) .ui-resizable-handle{display:none!important}
/* Per-card controls (hide button) */
.dash-card-controls{display:none;position:absolute;top:8px;right:8px;gap:4px;z-index:6}
#dash-grid.editing .dash-card-controls{display:flex}
.dash-card-ctrl-btn{width:24px;height:24px;border-radius:6px;border:1px solid var(--border);
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:12px;
display:flex;align-items:center;justify-content:center;line-height:1}
.dash-card-ctrl-btn:hover{color:var(--accent);border-color:var(--accent)}
#dash-hidden-bar{display:none;flex-wrap:wrap;gap:8px;margin-top:12px;padding:10px;
border:1px dashed var(--border);border-radius:10px}
#dash-hidden-bar.show{display:flex}
.dash-hidden-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px;
background:var(--raised);border:1px solid var(--border);border-radius:20px;color:var(--txt2)}
.dash-hidden-chip button{background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0}
@media(max-width:768px){
#dash-toolbar{display:none}
}
/* ── HERO ── */
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
@media(max-width:900px){.hero{grid-template-columns:1fr}}
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain;
-webkit-user-drag:none;user-select:none}
/* Inside the dashboard grid the camera card can be resized taller than the
default 320px cap — flex column: title row keeps its height, cam view
flexes to fill whatever cell height the user chose. */
.grid-stack-item-content>#card-camera{display:flex;flex-direction:column}
.grid-stack-item-content .cam-wrap{flex:1;min-height:0;max-height:none}
.grid-stack-item-content .cam-wrap img,.grid-stack-item-content .cam-wrap video{max-height:100%;height:100%}
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
@keyframes spin{to{transform:rotate(360deg)}}
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
@@ -83,6 +164,11 @@ main{flex:1;overflow-y:auto;padding:20px}
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
/* Same pattern as #card-camera above: without this, shrinking the tile's
height just clips the lower content (time-grid/filename/buttons) outside
the visible area instead of making it scrollable (Issue #97). */
.grid-stack-item-content>#card-progress{display:flex;flex-direction:column;min-height:0}
.grid-stack-item-content>#card-progress>*{flex-shrink:0}
.hero-info{display:flex;flex-direction:column;gap:12px}
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}
@@ -125,6 +211,12 @@ main{flex:1;overflow-y:auto;padding:20px}
/* ── TEMPS ── */
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@container (max-width:340px){
.temp-card-inner{grid-template-columns:1fr}
.temp-val{font-size:24px}
.temp-edit{flex-wrap:wrap}
.temp-input{width:100%}
}
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
.temp-row{display:flex;align-items:baseline;gap:6px}
@@ -230,6 +322,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
.set-cat .nav-text{display:inline}
}
/* ── BROWSER SUB-TABS (uploaded vs. on-printer files) ── */
.browser-tab.active{color:var(--accent);border-bottom-color:var(--accent)!important}
.browser-tab:hover{color:var(--txt)}
.browser-group{display:none}
.browser-group.active{display:block}
/* ── FILE BROWSER UPLOAD ZONE ── */
#store-upload-zone{
display:flex;flex-direction:column;align-items:center;justify-content:center;
@@ -265,6 +363,8 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
.modal-field input{background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
.modal-field input:focus{outline:none;border-color:var(--accent)}
.set-row{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.set-row label{font-size:12px;color:var(--txt2)}
.poll-btns{display:flex;gap:8px}
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "z.B. Kobra X Wohnzimmer",
"apd_success": "Drucker hinzugefügt, Bridge startet neu…",
"apd_title": "Drucker hinzufügen",
"browser_tab_printer": "Auf dem Drucker",
"browser_tab_uploaded": "Hochgeladen",
"btn_cam_start": "▶ Kamera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Kamera",
@@ -68,6 +70,18 @@
"card_speed": "Druckgeschwindigkeit",
"card_temps": "Temperaturen",
"confirm_cancel": "Druck wirklich abbrechen?",
"dash_done": "Fertig",
"dash_edit": "Dashboard anpassen",
"dash_hidden_cards": "Ausgeblendete Karten",
"dash_hide": "Ausblenden",
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
"dash_preset_name_prompt": "Preset-Name:",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Desktop breit",
"dash_reset": "Zurücksetzen",
"dash_save_preset": "Als Preset speichern",
"dash_show": "Einblenden",
"dash_toggle_width": "Breite umschalten",
"fd_cancel": "Abbrechen",
"fd_no_matching_material": "Kein passendes Material",
"fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?",
@@ -121,9 +135,10 @@
"lbl_feed": "Einziehen",
"lbl_layers": "Layer",
"lbl_light": "💡 Licht",
"lbl_pause_reason": "Druck pausiert:",
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=aus)",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
"lbl_spoolman_url": "Server-URL",
"lbl_unload": "Ausziehen",
"lbl_zpos": "Z (mm)",
@@ -158,6 +173,7 @@
"log_topic_label": "Thema:",
"log_topic_print": "Druck",
"log_topic_status": "Status",
"modal_sec_kxgauge": "KXGauge",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
@@ -199,12 +215,20 @@
"panel_temps_chart": "Verlauf (letzte 60 Messungen)",
"panel_temps_nozzle": "Düse",
"print_auto_leveling": "Auto-Leveling für diesen Druck",
"printer_store_delete_confirm": "Datei vom Drucker löschen?",
"printer_store_delete_selected_confirm": "{n} ausgewählte Dateien vom Drucker löschen?",
"printer_store_empty": "Keine Dateien auf dem Drucker.",
"printer_store_unreachable": "Drucker nicht erreichbar oder Abfrage fehlgeschlagen.",
"printers_active": "● aktiv",
"printers_current": "Aktueller Drucker",
"printers_empty_hint": "Noch kein Drucker eingerichtet.",
"printers_loading": "Lade…",
"printers_none": "Keine Drucker konfiguriert.",
"printers_remove": "Drucker entfernen",
"printers_power": "Drucker-Stromversorgung schalten",
"printers_power_on": "Strom: An",
"printers_power_off": "Strom: Aus",
"printers_power_off_confirm": "Drucker-Strom ausschalten? Stelle sicher, dass kein Druck läuft.",
"printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
"printers_switch": "Wechseln →",
"progress_action_clear": "Leeren",
@@ -235,9 +259,18 @@
"settings_file_ready_dialog": "Druckdialog",
"settings_file_ready_mode": "Nach Upload: Druckstart-Verhalten",
"settings_integrations": "Integrationen",
"settings_kxgauge_enabled": "Aktiviert",
"settings_kxgauge_heat_peak": "Ziel-Temperatur (°C)",
"settings_kxgauge_test": "Verbindung testen",
"settings_kxgauge_url": "Geräte-URL",
"settings_language": "Sprache",
"settings_mode_id": "Mode-ID",
"settings_mode_id_placeholder": "20030",
"settings_power": "Steckdose (Ein/Aus)",
"settings_power_on_url": "Einschalt-URL",
"settings_power_off_url": "Ausschalt-URL",
"settings_power_status_url": "Status-URL",
"settings_power_hint": "Optional: einfache HTTP-GET-URLs für eine Steckdose (z.B. Tasmota), die den Drucker per Netzstrom schaltet. Leer lassen blendet den Power-Button aus.",
"settings_mqtt_port": "MQTT-Port",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Profile importieren",
@@ -256,13 +289,17 @@
"settings_title": "Einstellungen",
"settings_username": "MQTT-Benutzername",
"settings_vendor_filter_placeholder": "Hersteller suchen…",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"settings_version": "Version",
"settings_vibration_compensation": "Resonanzkompensation",
"settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
"settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)",
"settings_visible_vendors_save": "Auswahl speichern",
"settings_visible_vendors_save_label": "Auswahl speichern",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"settings_delete_printer_file_after_print": "Datei nach erfolgreichem Druck vom Drucker löschen",
"settings_delete_printer_file_after_print_hint": "Gilt nur für Drucke, die über diese Bridge gestartet wurden (selbst hochgeladene Dateien) - direkt am Drucker oder über Anycubic Slicer gestartete Drucke werden nie gelöscht, da davon sonst keine Kopie mehr existiert.",
"sf_all": "Alle",
"sf_err": "✗ Fehler",
"sf_new": "Neu",
@@ -278,6 +315,7 @@
"skip_sending": "Sende …",
"skip_success": "Objekte werden übersprungen.",
"skip_title": "✂ Objekte überspringen",
"slot_copy_from": "Farbe von Slot kopieren…",
"slot_edit_color": "Farbe",
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Einziehen",
@@ -296,15 +334,20 @@
"ss_dur": "⏱ Druckzeit",
"ss_name": "AZ Name",
"store_delete_confirm": "Datei löschen?",
"store_delete_selected": "Auswahl löschen",
"store_delete_selected_confirm": "{n} ausgewählte Dateien löschen?",
"store_download": "⬇ Download",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_estimate": "Schätzung",
"store_exit_select": "Abbrechen",
"store_never": "noch nicht gedruckt",
"store_no_results": "Keine Dateien gefunden.",
"store_print": "▶ Drucken",
"store_print_confirm": "Datei drucken?",
"store_refresh": "↻ Aktualisieren",
"store_search_placeholder": "🔍 Suche…",
"store_select_all": "Alle auswählen",
"store_selected_count": "{n} ausgewählt",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "durchsuchen",
@@ -325,4 +368,4 @@
"update_error": "Fehler",
"update_none": "Bereits aktuell",
"update_restarting": "Starte neu..."
}
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "e.g. Kobra X Living Room",
"apd_success": "Printer added, bridge restarting…",
"apd_title": "Add printer",
"browser_tab_printer": "On Printer",
"browser_tab_uploaded": "Uploaded",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Start",
"btn_cam_stop": "◼ Camera",
@@ -68,6 +70,18 @@
"card_speed": "Print Speed",
"card_temps": "Temperatures",
"confirm_cancel": "Really cancel the print?",
"dash_done": "Done",
"dash_edit": "Customize dashboard",
"dash_hidden_cards": "Hidden cards",
"dash_hide": "Hide",
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
"dash_preset_name_prompt": "Preset name:",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Wide desktop",
"dash_reset": "Reset",
"dash_save_preset": "Save as preset",
"dash_show": "Show",
"dash_toggle_width": "Toggle width",
"fd_cancel": "Cancel",
"fd_no_matching_material": "No matching material",
"fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?",
@@ -121,9 +135,10 @@
"lbl_feed": "Load",
"lbl_layers": "Layer",
"lbl_light": "💡 Light",
"lbl_pause_reason": "Print paused:",
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=off)",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
"lbl_spoolman_url": "Server URL",
"lbl_unload": "Unload",
"lbl_zpos": "Z (mm)",
@@ -158,6 +173,7 @@
"log_topic_label": "Topic:",
"log_topic_print": "Print",
"log_topic_status": "Status",
"modal_sec_kxgauge": "KXGauge",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
@@ -199,12 +215,20 @@
"panel_temps_chart": "History (last 60 readings)",
"panel_temps_nozzle": "Nozzle",
"print_auto_leveling": "Auto-Leveling",
"printer_store_delete_confirm": "Delete file from the printer?",
"printer_store_delete_selected_confirm": "Delete {n} selected files from the printer?",
"printer_store_empty": "No files on the printer.",
"printer_store_unreachable": "Printer unreachable or query failed.",
"printers_active": "● active",
"printers_current": "Current printer",
"printers_empty_hint": "No printer set up yet.",
"printers_loading": "Loading…",
"printers_none": "No printers configured.",
"printers_remove": "Remove printer",
"printers_power": "Toggle printer power",
"printers_power_on": "Power: On",
"printers_power_off": "Power: Off",
"printers_power_off_confirm": "Turn printer power off? Make sure no print is running.",
"printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
"printers_switch": "Switch →",
"progress_action_clear": "Clear",
@@ -235,9 +259,18 @@
"settings_file_ready_dialog": "Print dialog",
"settings_file_ready_mode": "After upload: Start print behavior",
"settings_integrations": "Integrations",
"settings_kxgauge_enabled": "Enabled",
"settings_kxgauge_heat_peak": "Target temperature (°C)",
"settings_kxgauge_test": "Test connection",
"settings_kxgauge_url": "Device URL",
"settings_language": "Language",
"settings_mode_id": "Mode ID",
"settings_mode_id_placeholder": "20030",
"settings_power": "Power Switch",
"settings_power_on_url": "Power-On URL",
"settings_power_off_url": "Power-Off URL",
"settings_power_status_url": "Status URL",
"settings_power_hint": "Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer's mains power. Leave empty to hide the power button.",
"settings_mqtt_port": "MQTT Port",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Import profiles",
@@ -256,13 +289,17 @@
"settings_title": "Settings",
"settings_username": "MQTT Username",
"settings_vendor_filter_placeholder": "Search vendors…",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"settings_version": "Version",
"settings_vibration_compensation": "Resonance Compensation",
"settings_visible_vendors": "Visible vendors (profile dropdown)",
"settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.",
"settings_visible_vendors_label": "Visible vendors (profile dropdown)",
"settings_visible_vendors_save": "Save selection",
"settings_visible_vendors_save_label": "Save selection",
"settings_web_upload_warning": "Show warning when printing web uploads",
"settings_delete_printer_file_after_print": "Delete file from printer after successful print",
"settings_delete_printer_file_after_print_hint": "Only applies to prints started through this bridge (files it uploaded itself) - prints started directly from the printer or Anycubic Slicer are never deleted, since no copy of those exists anywhere else.",
"sf_all": "All",
"sf_err": "✗ Failed",
"sf_new": "New",
@@ -278,6 +315,7 @@
"skip_sending": "Sending …",
"skip_success": "Objects will be skipped.",
"skip_title": "✂ Skip objects",
"slot_copy_from": "Copy color from slot…",
"slot_edit_color": "Color",
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Load",
@@ -296,15 +334,20 @@
"ss_dur": "⏱ Print time",
"ss_name": "AZ Name",
"store_delete_confirm": "Delete file?",
"store_delete_selected": "Delete Selected",
"store_delete_selected_confirm": "Delete {n} selected files?",
"store_download": "⬇ Download",
"store_empty": "No files uploaded yet.",
"store_estimate": "Estimate",
"store_exit_select": "Cancel",
"store_never": "never printed",
"store_no_results": "No files found.",
"store_print": "▶ Print",
"store_print_confirm": "Print file?",
"store_refresh": "↻ Refresh",
"store_search_placeholder": "🔍 Search…",
"store_select_all": "Select All",
"store_selected_count": "{n} selected",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "browse",
@@ -325,4 +368,4 @@
"update_error": "Error",
"update_none": "Already up to date",
"update_restarting": "Restarting..."
}
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "p. ej. Kobra X Sala",
"apd_success": "Impresora añadida, reiniciando bridge…",
"apd_title": "Agregar impresora",
"browser_tab_printer": "En la impresora",
"browser_tab_uploaded": "Subidos",
"btn_cam_start": "▶ Cámara",
"btn_cam_start2": "▶ Iniciar",
"btn_cam_stop": "◼ Cámara",
@@ -68,6 +70,18 @@
"card_speed": "Velocidad de impresión",
"card_temps": "Temperaturas",
"confirm_cancel": "¿Realmente cancelar la impresión?",
"dash_done": "Listo",
"dash_edit": "Personalizar panel",
"dash_hidden_cards": "Tarjetas ocultas",
"dash_hide": "Ocultar",
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
"dash_preset_name_prompt": "Nombre del preset:",
"dash_preset_standard": "Estándar",
"dash_preset_wide89": "Escritorio ancho",
"dash_reset": "Restablecer",
"dash_save_preset": "Guardar como preset",
"dash_show": "Mostrar",
"dash_toggle_width": "Cambiar ancho",
"fd_cancel": "Cancelar",
"fd_no_matching_material": "No hay material compatible",
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
@@ -121,9 +135,10 @@
"lbl_feed": "Cargar",
"lbl_layers": "Capa",
"lbl_light": "💡 Luz",
"lbl_pause_reason": "Impresión pausada:",
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=desact.)",
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
"lbl_spoolman_url": "URL del servidor",
"lbl_unload": "Descargar",
"lbl_zpos": "Z (mm)",
@@ -158,6 +173,7 @@
"log_topic_label": "Tema:",
"log_topic_print": "Impresión",
"log_topic_status": "Estado",
"modal_sec_kxgauge": "KXGauge",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
@@ -199,12 +215,20 @@
"panel_temps_chart": "Historial (últimas 60 lecturas)",
"panel_temps_nozzle": "Boquilla",
"print_auto_leveling": "Autonivelado para esta impresión",
"printer_store_delete_confirm": "¿Eliminar archivo de la impresora?",
"printer_store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados de la impresora?",
"printer_store_empty": "No hay archivos en la impresora.",
"printer_store_unreachable": "Impresora inaccesible o consulta fallida.",
"printers_active": "● activa",
"printers_current": "Impresora actual",
"printers_empty_hint": "Aún no hay impresora configurada.",
"printers_loading": "Cargando…",
"printers_none": "No hay impresoras configuradas.",
"printers_remove": "Eliminar impresora",
"printers_power": "Alternar alimentación de la impresora",
"printers_power_on": "Alimentación: Encendida",
"printers_power_off": "Alimentación: Apagada",
"printers_power_off_confirm": "¿Apagar la alimentación de la impresora? Asegúrate de que no haya ninguna impresión en curso.",
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
"printers_switch": "Cambiar →",
"progress_action_clear": "Vaciar",
@@ -235,9 +259,18 @@
"settings_file_ready_dialog": "Diálogo de impresión",
"settings_file_ready_mode": "Después de carga: Comportamiento de inicio de impresión",
"settings_integrations": "Integraciones",
"settings_kxgauge_enabled": "Activado",
"settings_kxgauge_heat_peak": "Temperatura objetivo (°C)",
"settings_kxgauge_test": "Probar conexión",
"settings_kxgauge_url": "URL del dispositivo",
"settings_language": "Idioma",
"settings_mode_id": "ID de modo",
"settings_mode_id_placeholder": "20030",
"settings_power": "Enchufe inteligente",
"settings_power_on_url": "URL de encendido",
"settings_power_off_url": "URL de apagado",
"settings_power_status_url": "URL de estado",
"settings_power_hint": "Opcional: URLs HTTP GET para un enchufe inteligente (p.ej. Tasmota) que controla la alimentación de la impresora. Déjalo vacío para ocultar el botón de encendido.",
"settings_mqtt_port": "MQTT Port",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Importar perfiles",
@@ -256,13 +289,17 @@
"settings_title": "Configuración",
"settings_username": "Usuario MQTT",
"settings_vendor_filter_placeholder": "Buscar fabricantes…",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"settings_version": "Versión",
"settings_vibration_compensation": "Compensación de resonancia",
"settings_visible_vendors": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
"settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)",
"settings_visible_vendors_save": "Guardar selección",
"settings_visible_vendors_save_label": "Guardar selección",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"settings_delete_printer_file_after_print": "Eliminar archivo de la impresora tras una impresión exitosa",
"settings_delete_printer_file_after_print_hint": "Solo aplica a impresiones iniciadas a través de este bridge (archivos que él mismo subió) - las impresiones iniciadas directamente desde la impresora o Anycubic Slicer nunca se eliminan, ya que no existe ninguna copia en otro lugar.",
"sf_all": "Todos",
"sf_err": "✗ Fallido",
"sf_new": "Nuevo",
@@ -278,6 +315,7 @@
"skip_sending": "Enviando …",
"skip_success": "Se omitirán los objetos.",
"skip_title": "✂ Omitir objetos",
"slot_copy_from": "Copiar color del slot…",
"slot_edit_color": "Color",
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Cargar",
@@ -296,15 +334,20 @@
"ss_dur": "⏱ Tiempo de impresión",
"ss_name": "AZ Nombre",
"store_delete_confirm": "¿Eliminar archivo?",
"store_delete_selected": "Eliminar seleccionados",
"store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados?",
"store_download": "⬇ Descargar",
"store_empty": "Aún no hay archivos subidos.",
"store_estimate": "Estimación",
"store_exit_select": "Cancelar",
"store_never": "nunca impreso",
"store_no_results": "No se encontraron archivos.",
"store_print": "▶ Imprimir",
"store_print_confirm": "¿Imprimir archivo?",
"store_refresh": "↻ Actualizar",
"store_search_placeholder": "🔍 Buscar…",
"store_select_all": "Seleccionar todo",
"store_selected_count": "{n} seleccionados",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "buscar",
@@ -325,4 +368,4 @@
"update_error": "Error",
"update_none": "Ya actualizado",
"update_restarting": "Reiniciando..."
}
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "ex. Kobra X Salon",
"apd_success": "Imprimante ajoutée, redémarrage du bridge…",
"apd_title": "Ajouter une imprimante",
"browser_tab_printer": "Sur l'imprimante",
"browser_tab_uploaded": "Téléversés",
"btn_cam_start": "▶ Caméra",
"btn_cam_start2": "▶ Démarrer",
"btn_cam_stop": "◼ Caméra",
@@ -121,9 +123,10 @@
"lbl_feed": "Charger",
"lbl_layers": "Couche",
"lbl_light": "💡 Lumière",
"lbl_pause_reason": "Impression en pause :",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=désact.)",
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
"lbl_spoolman_url": "URL du serveur",
"lbl_unload": "Décharger",
"lbl_zpos": "Z (mm)",
@@ -158,6 +161,7 @@
"log_topic_label": "Sujet :",
"log_topic_print": "Impression",
"log_topic_status": "Statut",
"modal_sec_kxgauge": "KXGauge",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
@@ -199,12 +203,20 @@
"panel_temps_chart": "Historique (60 dernières valeurs)",
"panel_temps_nozzle": "Buse",
"print_auto_leveling": "Mise à niveau auto pour cette impression",
"printer_store_delete_confirm": "Supprimer le fichier de l'imprimante ?",
"printer_store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés de l'imprimante ?",
"printer_store_empty": "Aucun fichier sur l'imprimante.",
"printer_store_unreachable": "Imprimante injoignable ou requête échouée.",
"printers_active": "● actif",
"printers_current": "Imprimante actuelle",
"printers_empty_hint": "Aucune imprimante configurée.",
"printers_loading": "Chargement…",
"printers_none": "Aucune imprimante configurée.",
"printers_remove": "Supprimer l'imprimante",
"printers_power": "Basculer l'alimentation de l'imprimante",
"printers_power_on": "Alimentation : Allumée",
"printers_power_off": "Alimentation : Éteinte",
"printers_power_off_confirm": "Éteindre l'alimentation de l'imprimante ? Assurez-vous qu'aucune impression n'est en cours.",
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
"printers_switch": "Changer →",
"progress_action_clear": "Vider",
@@ -235,9 +247,18 @@
"settings_file_ready_dialog": "Dialogue d'impression",
"settings_file_ready_mode": "Après téléchargement : Comportement de démarrage d'impression",
"settings_integrations": "Intégrations",
"settings_kxgauge_enabled": "Activé",
"settings_kxgauge_heat_peak": "Température cible (°C)",
"settings_kxgauge_test": "Tester la connexion",
"settings_kxgauge_url": "URL de l'appareil",
"settings_language": "Langue",
"settings_mode_id": "ID du mode",
"settings_mode_id_placeholder": "20030",
"settings_power": "Prise électrique",
"settings_power_on_url": "URL d'allumage",
"settings_power_off_url": "URL d'extinction",
"settings_power_status_url": "URL de statut",
"settings_power_hint": "Optionnel : URL HTTP GET pour une prise connectée (ex. Tasmota) contrôlant l'alimentation secteur de l'imprimante. Laisser vide masque le bouton d'alimentation.",
"settings_mqtt_port": "Port MQTT",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Importer des profils",
@@ -263,6 +284,8 @@
"settings_visible_vendors_save": "Enregistrer la sélection",
"settings_visible_vendors_save_label": "Enregistrer la sélection",
"settings_web_upload_warning": "Afficher un avertissement lors de l'impression de fichiers web",
"settings_delete_printer_file_after_print": "Supprimer le fichier de l'imprimante après une impression réussie",
"settings_delete_printer_file_after_print_hint": "S'applique uniquement aux impressions lancées via ce bridge (fichiers qu'il a lui-même téléversés) - les impressions lancées directement depuis l'imprimante ou Anycubic Slicer ne sont jamais supprimées, car aucune copie n'existe ailleurs.",
"sf_all": "Tout",
"sf_err": "✗ Échoués",
"sf_new": "Nouveau",
@@ -278,6 +301,7 @@
"skip_sending": "Envoi …",
"skip_success": "Les objets seront ignorés.",
"skip_title": "✂ Ignorer des objets",
"slot_copy_from": "Copier la couleur du slot…",
"slot_edit_color": "Couleur",
"slot_edit_custom": "ex. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Charger",
@@ -296,15 +320,20 @@
"ss_dur": "⏱ Durée d'impression",
"ss_name": "AZ Nom",
"store_delete_confirm": "Supprimer le fichier ?",
"store_delete_selected": "Supprimer la sélection",
"store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés ?",
"store_download": "⬇ Télécharger",
"store_empty": "Aucun fichier uploadé.",
"store_estimate": "Estimation",
"store_exit_select": "Annuler",
"store_never": "jamais imprimé",
"store_no_results": "Aucun fichier trouvé.",
"store_print": "▶ Imprimer",
"store_print_confirm": "Imprimer le fichier ?",
"store_refresh": "↻ Actualiser",
"store_search_placeholder": "🔍 Rechercher…",
"store_select_all": "Tout sélectionner",
"store_selected_count": "{n} sélectionné(s)",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "parcourir",
@@ -325,4 +354,4 @@
"update_error": "Erreur",
"update_none": "Déjà à jour",
"update_restarting": "Redémarrage…"
}
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "es. Kobra X Soggiorno",
"apd_success": "Stampante aggiunta, riavvio del bridge in corso…",
"apd_title": "Aggiungi stampante",
"browser_tab_printer": "Sulla stampante",
"browser_tab_uploaded": "Caricati",
"btn_cam_start": "▶ Camera",
"btn_cam_start2": "▶ Avvia",
"btn_cam_stop": "◼ Camera",
@@ -121,9 +123,10 @@
"lbl_feed": "Carica",
"lbl_layers": "Layer",
"lbl_light": "💡 Luce",
"lbl_pause_reason": "Stampa in pausa:",
"lbl_remaining": "Rimanente:",
"lbl_slicer_time": "Stima slicer:",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=disatt.)",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
"lbl_spoolman_url": "URL server",
"lbl_unload": "Rimuovi",
"lbl_zpos": "Z (mm)",
@@ -158,6 +161,7 @@
"log_topic_label": "Argomento:",
"log_topic_print": "Stampa",
"log_topic_status": "Stato",
"modal_sec_kxgauge": "KXGauge",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
@@ -199,12 +203,20 @@
"panel_temps_chart": "Cronologia (ultime 60 letture)",
"panel_temps_nozzle": "Ugello",
"print_auto_leveling": "Livellamento automatico",
"printer_store_delete_confirm": "Eliminare il file dalla stampante?",
"printer_store_delete_selected_confirm": "Eliminare {n} file selezionati dalla stampante?",
"printer_store_empty": "Nessun file sulla stampante.",
"printer_store_unreachable": "Stampante non raggiungibile o richiesta fallita.",
"printers_active": "● attiva",
"printers_current": "Stampante corrente",
"printers_empty_hint": "Nessuna stampante ancora configurata.",
"printers_loading": "Caricamento in corso…",
"printers_none": "Nessuna stampante configurata.",
"printers_remove": "Rimuovi stampante",
"printers_power": "Attiva/disattiva alimentazione stampante",
"printers_power_on": "Alimentazione: Accesa",
"printers_power_off": "Alimentazione: Spenta",
"printers_power_off_confirm": "Spegnere l'alimentazione della stampante? Assicurati che non sia in corso alcuna stampa.",
"printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
"printers_switch": "Cambia →",
"progress_action_clear": "Cancella",
@@ -235,9 +247,18 @@
"settings_file_ready_dialog": "Finestra di dialogo stampa",
"settings_file_ready_mode": "Dopo il caricamento: Comportamento di avvio stampa",
"settings_integrations": "Integrazioni",
"settings_kxgauge_enabled": "Abilitato",
"settings_kxgauge_heat_peak": "Temperatura obiettivo (°C)",
"settings_kxgauge_test": "Verifica connessione",
"settings_kxgauge_url": "URL dispositivo",
"settings_language": "Lingua",
"settings_mode_id": "ID modalità",
"settings_mode_id_placeholder": "20030",
"settings_power": "Presa elettrica",
"settings_power_on_url": "URL accensione",
"settings_power_off_url": "URL spegnimento",
"settings_power_status_url": "URL stato",
"settings_power_hint": "Opzionale: URL HTTP GET per una presa smart (es. Tasmota) che controlla l'alimentazione della stampante. Lasciare vuoto per nascondere il pulsante di accensione.",
"settings_mqtt_port": "Porta MQTT",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "Importa profili",
@@ -263,6 +284,8 @@
"settings_visible_vendors_save": "Salva selezione",
"settings_visible_vendors_save_label": "Salva selezione",
"settings_web_upload_warning": "Mostra un avviso quando si stampano caricamenti web",
"settings_delete_printer_file_after_print": "Elimina il file dalla stampante dopo una stampa riuscita",
"settings_delete_printer_file_after_print_hint": "Si applica solo alle stampe avviate tramite questo bridge (file caricati da esso) - le stampe avviate direttamente dalla stampante o da Anycubic Slicer non vengono mai eliminate, poiché non ne esiste alcuna copia altrove.",
"sf_all": "Tutti",
"sf_err": "✗ Fallito",
"sf_new": "Nuovo",
@@ -278,6 +301,7 @@
"skip_sending": "Invio in corso …",
"skip_success": "Gli oggetti verranno saltati.",
"skip_title": "✂ Salta oggetti",
"slot_copy_from": "Copia colore dallo slot…",
"slot_edit_color": "Colore",
"slot_edit_custom": "es. PLA, PETG, ABS…",
"slot_edit_load": "⬇ Carica",
@@ -296,15 +320,20 @@
"ss_dur": "⏱ Tempo di stampa",
"ss_name": "Nome AZ",
"store_delete_confirm": "Eliminare il file?",
"store_delete_selected": "Elimina selezionati",
"store_delete_selected_confirm": "Eliminare {n} file selezionati?",
"store_download": "⬇ Scarica",
"store_empty": "Nessun file caricato.",
"store_estimate": "Stima",
"store_exit_select": "Annulla",
"store_never": "mai stampato",
"store_no_results": "Nessun file trovato.",
"store_print": "▶ Stampa",
"store_print_confirm": "Stampare il file?",
"store_refresh": "↻ Aggiorna",
"store_search_placeholder": "🔍 Cerca…",
"store_select_all": "Seleziona tutto",
"store_selected_count": "{n} selezionati",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "sfoglia",
@@ -325,4 +354,4 @@
"update_error": "Errore",
"update_none": "Già aggiornato",
"update_restarting": "Riavvio in corso..."
}
}

View File

@@ -42,6 +42,8 @@
"apd_placeholder_name": "例如 Kobra X 客厅",
"apd_success": "打印机已添加Bridge 正在重启…",
"apd_title": "添加打印机",
"browser_tab_printer": "打印机上",
"browser_tab_uploaded": "已上传",
"btn_cam_start": "▶ 相机",
"btn_cam_start2": "▶ 启动",
"btn_cam_stop": "◼ 相机",
@@ -68,6 +70,18 @@
"card_speed": "打印速度",
"card_temps": "温度",
"confirm_cancel": "确定要取消打印吗?",
"dash_done": "完成",
"dash_edit": "自定义仪表盘",
"dash_hidden_cards": "隐藏的卡片",
"dash_hide": "隐藏",
"dash_preset_delete_confirm": "删除预设 \"{name}\"",
"dash_preset_name_prompt": "预设名称:",
"dash_preset_standard": "标准",
"dash_preset_wide89": "宽屏桌面",
"dash_reset": "重置",
"dash_save_preset": "另存为预设",
"dash_show": "显示",
"dash_toggle_width": "切换宽度",
"fd_cancel": "取消",
"fd_no_matching_material": "无匹配材料",
"fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?",
@@ -121,9 +135,10 @@
"lbl_feed": "进料",
"lbl_layers": "层",
"lbl_light": "💡 灯光",
"lbl_pause_reason": "打印已暂停:",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_spoolman_sync_rate": "同步频率0=关闭",
"lbl_spoolman_sync_rate": "同步频率0=打印结束",
"lbl_spoolman_url": "服务器地址",
"lbl_unload": "退料",
"lbl_zpos": "Z (mm)",
@@ -158,6 +173,7 @@
"log_topic_label": "主题:",
"log_topic_print": "打印",
"log_topic_status": "状态",
"modal_sec_kxgauge": "KXGauge",
"modal_sec_obico": "Obico",
"modal_sec_spoolman": "Spoolman",
"nav_ams": "AMS",
@@ -199,12 +215,20 @@
"panel_temps_chart": "历史 (最近 60 次读数)",
"panel_temps_nozzle": "喷嘴",
"print_auto_leveling": "本次打印自动调平",
"printer_store_delete_confirm": "从打印机删除文件?",
"printer_store_delete_selected_confirm": "从打印机删除选中的 {n} 个文件?",
"printer_store_empty": "打印机上没有文件。",
"printer_store_unreachable": "无法连接打印机或查询失败。",
"printers_active": "● 活动",
"printers_current": "当前打印机",
"printers_empty_hint": "尚未设置打印机。",
"printers_loading": "加载中…",
"printers_none": "未配置打印机。",
"printers_remove": "移除打印机",
"printers_power": "切换打印机电源",
"printers_power_on": "电源:开",
"printers_power_off": "电源:关",
"printers_power_off_confirm": "关闭打印机电源?请确认当前没有正在进行的打印任务。",
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
"printers_switch": "切换 →",
"progress_action_clear": "清除",
@@ -235,9 +259,18 @@
"settings_file_ready_dialog": "打印对话框",
"settings_file_ready_mode": "上传后:开始打印行为",
"settings_integrations": "集成",
"settings_kxgauge_enabled": "启用",
"settings_kxgauge_heat_peak": "目标温度 (°C)",
"settings_kxgauge_test": "测试连接",
"settings_kxgauge_url": "设备地址",
"settings_language": "语言",
"settings_mode_id": "模式 ID",
"settings_mode_id_placeholder": "20030",
"settings_power": "电源插座",
"settings_power_on_url": "开机 URL",
"settings_power_off_url": "关机 URL",
"settings_power_status_url": "状态 URL",
"settings_power_hint": "可选:智能插座(如 Tasmota的 HTTP GET 控制 URL用于控制打印机的电源。留空则隐藏电源按钮。",
"settings_mqtt_port": "MQTT 端口",
"settings_mqtt_username_placeholder": "userXXXXXXXX",
"settings_orca_profiles_import": "导入配置文件",
@@ -256,13 +289,17 @@
"settings_title": "设置",
"settings_username": "MQTT 用户名",
"settings_vendor_filter_placeholder": "搜索厂商…",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"settings_version": "版本",
"settings_vibration_compensation": "打印前共振补偿",
"settings_visible_vendors": "可见厂商(配置下拉框)",
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
"settings_visible_vendors_label": "可见厂商(配置下拉框)",
"settings_visible_vendors_save": "保存选择",
"settings_visible_vendors_save_label": "保存选择",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"settings_delete_printer_file_after_print": "打印成功后从打印机删除文件",
"settings_delete_printer_file_after_print_hint": "仅适用于通过此网桥启动的打印(即由网桥自己上传的文件)——直接从打印机或 Anycubic Slicer 启动的打印任务永远不会被删除,因为它们没有其他备份。",
"sf_all": "全部",
"sf_err": "✗ 失败",
"sf_new": "新",
@@ -278,6 +315,7 @@
"skip_sending": "发送中 …",
"skip_success": "对象将被跳过。",
"skip_title": "✂ 跳过对象",
"slot_copy_from": "从插槽复制颜色…",
"slot_edit_color": "颜色",
"slot_edit_custom": "例如 PLA, PETG, ABS…",
"slot_edit_load": "⬇ 进料",
@@ -296,15 +334,20 @@
"ss_dur": "⏱ 打印时间",
"ss_name": "AZ 名称",
"store_delete_confirm": "删除文件?",
"store_delete_selected": "删除所选",
"store_delete_selected_confirm": "删除已选择的 {n} 个文件?",
"store_download": "⬇ 下载",
"store_empty": "尚未上传文件。",
"store_estimate": "估算",
"store_exit_select": "取消",
"store_never": "从未打印",
"store_no_results": "未找到文件。",
"store_print": "▶ 打印",
"store_print_confirm": "打印文件?",
"store_refresh": "↻ 刷新",
"store_search_placeholder": "🔍 搜索…",
"store_select_all": "全选",
"store_selected_count": "已选择 {n} 个",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",
"store_upload_label_browse": "浏览",
@@ -325,4 +368,4 @@
"update_error": "错误",
"update_none": "已是最新版本",
"update_restarting": "重启中..."
}
}