Compare commits

...

18 Commits

Author SHA1 Message Date
5dd668e8ca docs: restore NIGHTLY_CHANGELOG entries after rebase (code-review fixes batch)
All checks were successful
Nightly Build / build (push) Successful in 8m51s
2026-08-05 09:37:43 +02:00
0c520ffa00 feat(update): treat the testing channel as docker-only in the update check
A testing-<sha> build has no Gitea releases (the testing workflow builds only
a Docker image), so the in-app update check must not fall through to the
stable path - which would wrongly offer a stable "update". handle_api_update_check
now short-circuits for a "testing" version with docker_only:true and nothing
to update (no Gitea round-trip at all), and handle_api_update_apply refuses
self-update on the testing channel the same way it already does for nightly.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

New tests in tests/test_client_robustness.py cover all of the above.
2026-08-04 21:22:54 +02:00
gitea-actions
a360b463f9 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.30-nightly10 release
All checks were successful
Nightly Build / build (push) Successful in 8m36s
2026-08-04 12:51:58 +00:00
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
33 changed files with 3007 additions and 243 deletions

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.

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,2 +1,3 @@
## Changes in this build
- 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,7 +23,7 @@ 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>
@@ -41,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 |
@@ -68,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
@@ -110,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)
---

View File

@@ -7,8 +7,11 @@ import os
import sys
import pathlib
import configparser
import logging
from typing import Optional
log = logging.getLogger("kobrax.config")
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
CONFIG_SECTION_CONNECTION = "connection"
@@ -59,11 +62,15 @@ CONFIG_ENV_MAPPING = {
"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"),
@@ -76,7 +83,7 @@ CONFIG_ENV_MAPPING = {
def _load_config_file(path: pathlib.Path):
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
@@ -112,8 +119,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"),
@@ -132,10 +138,20 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
cfg[CONFIG_SECTION_BRIDGE] = {
"poll_interval": "3",
}
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n")
f.write("# Automatically migrated from .env\n\n")
cfg.write(f)
# This runs at module import time (see the "Laden" section below) - an
# uncaught mkdir/write failure (e.g. a read-only filesystem) would crash
# the whole bridge at startup with a raw traceback. Log a clear diagnostic
# before re-raising, so the actual cause (permissions, disk full) is
# visible instead of a bare stack trace pointing into configparser.
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n")
f.write("# Automatically migrated from .env\n\n")
cfg.write(f)
except OSError as e:
log.error("Failed to write migrated config.ini to %s: %s", config_path, e)
raise
def find_config_path() -> pathlib.Path:
@@ -175,7 +191,7 @@ def list_printers() -> list[dict]:
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
@@ -237,7 +253,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
path = _find_config_file()
if not path:
return {}
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
section = _filament_section(printer_id)
if not cfg.has_section(section):
@@ -278,7 +294,7 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str]
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
section = _filament_section(printer_id)
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
@@ -320,7 +336,7 @@ def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
path = _find_config_file()
if not path:
return []
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
section = _filament_section(printer_id)
if not cfg.has_option(section, "visible_vendors"):
@@ -342,7 +358,7 @@ def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
section = _filament_section(printer_id)
if not cfg.has_section(section):
@@ -402,7 +418,7 @@ def list_spool_map(printer_id: Optional[str] = None) -> dict[int, int]:
path = _find_config_file()
if not path:
return {}
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(path, encoding="utf-8")
section = _spoolman_map_section(printer_id)
if cfg.has_option(section, "slot_spools"):
@@ -422,7 +438,7 @@ def save_spool_map(slot_spools: dict[int, int], printer_id: Optional[str] = None
path = _find_config_file()
if not path:
return False
cfg = configparser.ConfigParser()
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}
@@ -441,21 +457,41 @@ def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
def _safe_int(value: str, default: int) -> int:
"""Falls back to `default` instead of raising on a non-numeric value.
All of these run at module import time - an uncaught ValueError here
(e.g. from a hand-edited config.ini with a typo like `mqtt_port = 98833x`)
would crash the entire bridge before it even starts, with a raw traceback
instead of a clear diagnostic. list_printers() already guards this same
class of input the same way; this applies it to the module-level
shortcuts too."""
try:
return int(value)
except (TypeError, ValueError):
log.warning("config: expected a number, got %r - using default %r", value, default)
return default
# Frequently used shortcuts
PRINTER_IP = get("PRINTER_IP", "")
MQTT_PORT = int(get("MQTT_PORT", "9883"))
MQTT_PORT = _safe_int(get("MQTT_PORT", "9883"), 9883)
USERNAME = get("MQTT_USERNAME", "")
PASSWORD = get("MQTT_PASSWORD", "")
MODE_ID = get("MODE_ID", "")
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"))
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"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
AUTO_LEVELING = _safe_int(get("AUTO_LEVELING", "1"), 1)
VIBRATION_COMPENSATION = _safe_int(get("VIBRATION_COMPENSATION", "0"), 0)
CAMERA_ON_PRINT = _safe_int(get("CAMERA_ON_PRINT", "0"), 0)
WEB_UPLOAD_WARNING = _safe_int(get("WEB_UPLOAD_WARNING", "1"), 1)
DELETE_PRINTER_FILE_AFTER_PRINT = _safe_int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0"), 0)
PRINT_START_DIALOG = _safe_int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")), 1)
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
SPOOLMAN_SYNC_RATE = _safe_int(get("SPOOLMAN_SYNC_RATE", "0"), 0)
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0"))
POLL_INTERVAL = _safe_int(get("POLL_INTERVAL", "3"), 3)
VERBOSE_HTTP_LOG = _safe_int(get("VERBOSE_HTTP_LOG", "0"), 0)

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

@@ -46,10 +46,14 @@ 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"))
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", "")

View File

@@ -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
# ---------------------------------------------------------------------------
@@ -126,17 +166,37 @@ class KobraXClient:
# 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",
@@ -177,6 +237,7 @@ class KobraXClient:
# 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])
@@ -231,41 +292,81 @@ class KobraXClient:
self._sock = None
self._sock_gen += 1
def _reconnect(self):
"""Persistent reconnect: keeps retrying forever until the printer is
responds or disconnect() was called. Backoff caps at 60 s. The
first 5 attempts log as WARNING (acute connection issue), afterwards
only DEBUG to avoid log spam during long printer outages (e.g. switched
ausgeschaltet) zu vermeiden."""
log.warning("Connection lost - reconnecting...")
# Close + invalidation under the lock so no sender is mid-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 successful (after %d attempts)", 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)
# 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
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:
@@ -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:
@@ -462,13 +592,18 @@ class KobraXClient:
# Also register by report topic as fallback for responses without msgid.
report_key = f"{msg_type}/report"
event = threading.Event()
entry = {"event": event, "result": None}
self._pending_msgid[msgid] = entry
# Only register report-key waiter if nobody else is waiting on it
# entry carries its own msgid so _dispatch()'s report-suffix path can
# confirm a reply actually belongs to THIS request before delivering
# it - without that, a late reply for an already-timed-out request A
# could be handed to a newer request B waiting on the same report_key.
entry = {"event": event, "result": None, "msgid": msgid}
report_registered = False
if report_key not in self._pending_report:
self._pending_report[report_key] = entry
report_registered = True
with self._pending_lock:
self._pending_msgid[msgid] = entry
# Only register report-key waiter if nobody else is waiting on it
if report_key not in self._pending_report:
self._pending_report[report_key] = entry
report_registered = True
topic = self._pub_topic(msg_type)
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
@@ -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"]
@@ -532,10 +674,11 @@ class KobraXClient:
except Exception as e:
log.error("web send error: %s, reconnecting…", e)
# Trigger a reconnect (like publish()); no retry because it is
# fire-and-forget - the next call will hit the fresh socket
# treffen.
# 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
@@ -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()
@@ -660,19 +805,25 @@ class KobraXClient:
# (the printer processes the file before replying).
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
sock = socket.create_connection(_ai[0][4], timeout=10)
sock.settimeout(None) # blocking during send
sock.sendall(headers + body)
sock.settimeout(180)
response = b""
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
response += chunk
except socket.timeout:
pass
sock.close()
sock.settimeout(None) # blocking during send
sock.sendall(headers + body)
sock.settimeout(180)
response = b""
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
response += chunk
except socket.timeout:
pass
finally:
# Without this, a sendall()/recv() failure other than
# socket.timeout (e.g. ConnectionResetError/BrokenPipeError if
# the printer drops the connection mid-upload) skipped
# sock.close() entirely, leaking the fd on every failed attempt.
sock.close()
# parse HTTP response body
if b"\r\n\r\n" in response:

View File

@@ -713,7 +713,7 @@ class CameraCache:
await asyncio.sleep(2.0)
continue
try:
self._proc_jpeg = await asyncio.create_subprocess_exec(
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-vf", "fps=2",
@@ -722,6 +722,7 @@ class CameraCache:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._proc_jpeg = proc
except Exception as e:
log.warning(f"CameraCache: ffmpeg-jpeg start failed: {e}")
await asyncio.sleep(3.0)
@@ -731,7 +732,7 @@ class CameraCache:
rc = None
try:
while True:
chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK)
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
buf += chunk
@@ -751,26 +752,31 @@ class CameraCache:
except Exception as e:
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
finally:
# Kill + wait - otherwise the child process lingers as a zombie and
# asyncio reports "Unknown child pid ..." on the next reaper tick.
if self._proc_jpeg is not None:
# NOTE: cleanup operates on the local `proc` reference, not on
# self._proc_jpeg - see _run_mjpeg_loop's identical comment.
# If this task got cancelled (e.g. by reset()), a new task may
# already have started and assigned its own process to
# self._proc_jpeg by the time we reach here; killing that
# shared attribute instead of our own local proc would kill
# the WRONG (newer) process and leak this one as an orphan.
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode
if rc:
try:
self._proc_jpeg.kill()
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
try:
await self._proc_jpeg.wait()
except Exception:
pass
rc = self._proc_jpeg.returncode
if rc:
try:
err = await self._proc_jpeg.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
self._proc_jpeg = None
if self._proc_jpeg is proc:
self._proc_jpeg = None
if rc:
self._fail_count_jpeg += 1
delay = min(2.0 * (2 ** self._fail_count_jpeg), 300.0)
@@ -788,7 +794,7 @@ class CameraCache:
await asyncio.sleep(2.0)
continue
try:
self._proc_h264 = await asyncio.create_subprocess_exec(
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-c:v", "copy", "-an",
@@ -796,6 +802,7 @@ class CameraCache:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._proc_h264 = proc
except Exception as e:
log.warning(f"CameraCache: ffmpeg-h264 start failed: {e}")
await asyncio.sleep(3.0)
@@ -804,7 +811,7 @@ class CameraCache:
rc = None
try:
while True:
chunk = await self._proc_h264.stdout.read(self.TS_CHUNK)
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
# Fanout: non-blocking per subscriber; slow clients
@@ -822,24 +829,31 @@ class CameraCache:
except Exception as e:
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
finally:
if self._proc_h264 is not None:
# NOTE: cleanup operates on the local `proc` reference, not on
# self._proc_h264 - see _run_mjpeg_loop's identical comment.
# If this task got cancelled (e.g. by reset()), a new task may
# already have started and assigned its own process to
# self._proc_h264 by the time we reach here; killing that
# shared attribute instead of our own local proc would kill
# the WRONG (newer) process and leak this one as an orphan.
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode
if rc:
try:
self._proc_h264.kill()
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
try:
await self._proc_h264.wait()
except Exception:
pass
rc = self._proc_h264.returncode
if rc:
try:
err = await self._proc_h264.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
self._proc_h264 = None
if self._proc_h264 is proc:
self._proc_h264 = None
if rc:
self._fail_count_h264 += 1
delay = min(2.0 * (2 ** self._fail_count_h264), 300.0)
@@ -1042,6 +1056,8 @@ class KobraXBridge:
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
"error_code": 0,
"pause_msg": "",
"storage_total_mb": 0,
"storage_used_mb": 0,
}
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot
@@ -1062,9 +1078,20 @@ class KobraXBridge:
# base64 PNG string, "" if the file has no embedded thumbnail).
# In-memory only - not persisted, cleared on restart.
self._printer_thumbnail_cache: dict[str, str] = {}
# Last buried/report payload (printer's own analytics event, fired once
# per print start regardless of slicer - see reference_buried_report_trigger
# memory). Carries gcode_size/estimate_duration/total_layers that are
# otherwise unavailable for files not uploaded through the bridge itself
# (Issue #102). Single entry only - just the most recent print.
self._buried_cache: dict | None = None
self._store = store if store is not None else GCodeStore(args.data_dir)
self._serve_dir_path: str = self._store._gcode_dir
self._current_job_id: str = ""
# Filename of the file backing _current_job_id, kept alongside it so
# the "finished" handler can still delete it from the printer's own
# storage (Issue: delete-after-print) after self._state["filename"]
# has already been cleared as part of the terminal-state reset below.
self._current_job_filename: str = ""
self._camera_autostarted: bool = False
self._camera_user_stopped: bool = False # user manually stopped the camera during a print
self.camera_cache: CameraCache = CameraCache()
@@ -1113,6 +1140,7 @@ class KobraXBridge:
client.callbacks["print/report"] = self._on_print
client.callbacks["info/report"] = self._on_info
client.callbacks["file/report"] = self._on_file
client.callbacks["buried/report"] = self._on_buried
client.callbacks["multiColorBox/report"] = self._on_multicolor_box
client.callbacks["light/report"] = self._on_light
client.callbacks["skip/report"] = self._on_skip
@@ -1303,7 +1331,7 @@ class KobraXBridge:
cfg_path = self._find_config_path()
if not cfg_path.is_file():
return defaults
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
cfg.read(cfg_path, encoding="utf-8")
sec = "ace_dry_presets"
if not cfg.has_section(sec):
@@ -1387,6 +1415,7 @@ class KobraXBridge:
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 = {}
@@ -1399,11 +1428,21 @@ class KobraXBridge:
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
@@ -1570,6 +1609,25 @@ class KobraXBridge:
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
@@ -1594,6 +1652,30 @@ class KobraXBridge:
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/
@@ -2210,12 +2292,32 @@ class KobraXBridge:
return "", ""
return vendor, family
def _match_profile_by_vendor_family(self, vendor: str, family: str) -> dict:
@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."""
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()
@@ -2223,12 +2325,28 @@ class KobraXBridge:
]
if not matches:
return {}
if len(matches) > 1:
log.debug(
f"_match_profile_by_vendor_family: {len(matches)} profiles match "
f"vendor={vendor!r} family={family!r}, using first: {matches[0].get('name')}"
)
return matches[0]
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
@@ -2245,21 +2363,44 @@ class KobraXBridge:
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.
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) we return {} → the slot falls back to
the 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)."""
(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 not profile.get("name"):
return {}
prof_fam = self._material_family(self._profile_material(profile))
ams_fam = self._material_family(ams_material)
if prof_fam and ams_fam and prof_fam != ams_fam:
return {}
return profile
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.
@@ -2305,30 +2446,19 @@ class KobraXBridge:
# The vendor is sent along (tray_sub_brands + filament_vendor),
# so a patched OrcaSlicer can match by brand + type +
# color (analogous to SnapmakerPrinterAgent).
# Two-layer resolution for the filament hint sent to OrcaSlicer:
# 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. Generic fallback (_TRAY_INFO_IDX) per material type - no
# 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
# Stale-profile guard: only apply the override while its material
# family still matches the loaded filament (PETG profile + PLA
# loaded -> dropped).
user_profile = self._effective_slot_profile(slot_index, material)
if not user_profile.get("name"):
# Third layer: auto-resolve a combined ACE-RFID "VENDOR TYPE
# SERIAL" string (e.g. "GEEETECH PLA Bas", from third-party
# RFID tools) against the user's already-imported profile
# library, instead of falling through to the neutral Generic
# fallback (Issue #101). Not persisted to config.ini - this
# re-derives on every _build_lane_data() call, so a
# differently-tagged spool loaded later isn't stuck with a
# stale match.
vendor_guess, family_guess = self._parse_combined_rfid_type(slot.get("type", ""))
if vendor_guess:
auto_profile = self._match_profile_by_vendor_family(vendor_guess, family_guess)
if auto_profile.get("name"):
user_profile = auto_profile
material = family_guess
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")
@@ -3376,9 +3506,78 @@ class KobraXBridge:
"bridge_url": bridge_url,
"printer_ip": br._args.printer_ip,
"device_id": br._args.device_id or "",
"has_power_control": bool(
(getattr(br._args, "power_on_url", "") or "").strip()
or (getattr(br._args, "power_off_url", "") or "").strip()
),
})
return self._json_cors({"result": out})
async def handle_kx_printer_power(self, request):
"""Toggles an external smart plug (e.g. Tasmota) for a printer that
has no MQTT-level power-off/standby command of its own (Issue #103).
Just fires a plain HTTP GET at the configured power_on_url/power_off_url -
works for Tasmota's cmnd=Power%20on/off style URLs and any other
switch that exposes a GET-triggered on/off endpoint."""
pid = str(request.match_info.get("pid", "")).strip()
br = self._all_bridges.get(pid)
if br is None:
return self._json_cors({"error": "unknown printer id"}, status=404)
try:
body = await request.json()
except Exception:
body = {}
action = str(body.get("action", "")).lower()
if action not in ("on", "off"):
return self._json_cors({"error": "action must be 'on' or 'off'"}, status=400)
url = getattr(br._args, f"power_{action}_url", "") or ""
if not url:
return self._json_cors({"error": f"no power_{action}_url configured"}, status=400)
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
ok = resp.status == 200
except Exception as e:
return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502)
return self._json_cors({"result": "ok" if ok else "error", "status": "on" if action == "on" else "off"})
async def handle_kx_printer_power_status(self, request):
"""Queries the configured smart plug for its current on/off state.
Tries to parse a Tasmota-style {"POWER":"ON"/"OFF"} JSON body first,
falls back to a plain substring search for "ON"/"OFF" in the raw
response so other switch firmwares with a simpler status endpoint
still work."""
pid = str(request.match_info.get("pid", "")).strip()
br = self._all_bridges.get(pid)
if br is None:
return self._json_cors({"error": "unknown printer id"}, status=404)
url = getattr(br._args, "power_status_url", "") or ""
if not url:
return self._json_cors({"error": "no power_status_url configured"}, status=400)
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
text = await resp.text()
except Exception as e:
return self._json_cors({"error": f"power switch unreachable: {e}"}, status=502)
state = "unknown"
try:
data = json.loads(text)
power = str(data.get("POWER", "")).upper()
if power in ("ON", "OFF"):
state = power.lower()
except Exception:
pass
if state == "unknown":
up = text.upper()
if "ON" in up and "OFF" not in up:
state = "on"
elif "OFF" in up:
state = "off"
return self._json_cors({"state": state})
async def handle_kx_print(self, request):
"""Print start from the GCode store with optional filament assignments."""
try:
@@ -3445,6 +3644,7 @@ class KobraXBridge:
printer_id=getattr(self._args, "device_id", "unknown"),
filament_assignments=assignments,
)
self._current_job_filename = filename
return self._json_cors({"result": "ok", "filename": filename})
@@ -3577,6 +3777,18 @@ class KobraXBridge:
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:
@@ -4739,6 +4951,8 @@ class KobraXBridge:
"version": self._read_version(),
"pause_msg": s.get("pause_msg", ""),
"error_code": s.get("error_code", 0),
"storage_total_mb": s.get("storage_total_mb", 0),
"storage_used_mb": s.get("storage_used_mb", 0),
})
async def handle_moonraker_database(self, request):
@@ -4872,11 +5086,15 @@ class KobraXBridge:
"password": self._args.password,
"mode_id": self._args.mode_id,
"device_id": self._args.device_id,
"power_on_url": getattr(self._args, "power_on_url", "") or "",
"power_off_url": getattr(self._args, "power_off_url", "") or "",
"power_status_url": getattr(self._args, "power_status_url", "") or "",
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
"auto_leveling": getattr(self._args, "auto_leveling", 1),
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"delete_printer_file_after_print": getattr(self._args, "delete_printer_file_after_print", 0),
"print_start_dialog": getattr(self._args, "print_start_dialog", 1),
"poll_interval": getattr(self._args, "poll_interval", 3),
"verbose_http_log": getattr(self._args, "verbose_http_log", 0),
@@ -4889,12 +5107,15 @@ class KobraXBridge:
async def handle_api_settings_post(self, request):
import configparser
data = await request.json()
try:
data = await request.json()
except Exception:
return self._json_cors({"error": "invalid json"}, status=400)
config_path = self._find_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
# Read the existing config.ini (comments are lost, but values are kept)
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
if config_path.is_file():
cfg.read(config_path, encoding="utf-8")
@@ -4910,11 +5131,15 @@ class KobraXBridge:
cfg.set("connection", "password", str(data.get("password", self._args.password or "")))
cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or "")))
cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or "")))
cfg.set("connection", "power_on_url", str(data.get("power_on_url", getattr(self._args, "power_on_url", "") or "")).strip())
cfg.set("connection", "power_off_url", str(data.get("power_off_url", getattr(self._args, "power_off_url", "") or "")).strip())
cfg.set("connection", "power_status_url", str(data.get("power_status_url", getattr(self._args, "power_status_url", "") or "")).strip())
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0))))))
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
cfg.set("print", "delete_printer_file_after_print", str(int(bool(data.get("delete_printer_file_after_print", getattr(self._args, "delete_printer_file_after_print", 0))))))
cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1))))))
if "poll_interval" in data:
try:
@@ -4979,7 +5204,7 @@ class KobraXBridge:
import configparser
config_path = self._find_config_path()
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
if config_path.is_file():
cfg.read(config_path, encoding="utf-8")
@@ -5047,7 +5272,7 @@ class KobraXBridge:
import configparser
config_path = self._find_config_path()
cfg = configparser.ConfigParser()
cfg = configparser.ConfigParser(interpolation=None)
if config_path.is_file():
cfg.read(config_path, encoding="utf-8")
@@ -5143,7 +5368,12 @@ class KobraXBridge:
# ─── Update ──────────────────────────────────────────────────────────────
STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1"
# limit=1 would only ever see the single newest release regardless of type -
# if that happens to be a nightly/dev prerelease (the common case, since
# those publish far more often than stable), the stable_releases filter
# below finds nothing and update checks fail with "no stable releases
# found" even though older stable releases exist (Issue #104).
STABLE_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=20"
NIGHTLY_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=5&pre-release=true"
DEV_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=10&pre-release=true"
GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag"
@@ -5221,6 +5451,21 @@ class KobraXBridge:
async def handle_api_update_check(self, request):
current = self._read_version()
# Testing channel (testing-<sha>) has no Gitea releases at all - it's
# a Docker-only channel. Report that directly instead of falling
# through to the stable path (which would wrongly offer a stable
# "update"). The :testing image is rolling, so there's nothing to
# compare a version against.
if "testing" in current:
return web.json_response({
"current": current,
"latest": current,
"update_available": False,
"tag": current,
"docker_only": True,
"changelog": "Testing channel - updates are delivered via Docker: "
"docker compose pull && docker compose up -d",
})
is_nightly = "nightly" in current
is_dev = "-dev+" in current
if is_nightly:
@@ -5300,11 +5545,16 @@ class KobraXBridge:
]
async def handle_api_update_apply(self, request):
data = await request.json()
try:
data = await request.json()
except Exception:
return web.json_response({"error": "invalid json"}, status=400)
new_tag = data.get("tag", "")
if "nightly" in self._read_version():
_cur = self._read_version()
if "nightly" in _cur or "testing" in _cur:
channel = "testing" if "testing" in _cur else "nightly"
return web.json_response(
{"error": "nightly updates are delivered via Docker: "
{"error": f"{channel} updates are delivered via Docker: "
"docker compose pull && docker compose up -d"}, status=400)
if getattr(sys, "frozen", False):
return web.json_response(
@@ -5708,6 +5958,25 @@ class KobraXBridge:
info = self.client.query_info()
if info:
self._on_info(info)
elif not self.client.is_connected():
# publish() swallows send/reconnect failures internally and
# just returns None (Issue #105) - a falsy `info` alone
# doesn't distinguish "printer sent nothing this tick" from
# "the MQTT session itself is dead". Check is_connected()
# explicitly so a dead session gets routed into the same
# clean offline/reconnect path as a TCP-unreachable printer,
# instead of silently retrying every poll_interval forever.
log.warning("MQTT connection lost (query returned no response) - switching to offline mode")
self._state["print_state"] = "error"
self._state["kobra_state"] = "offline"
self._state["connection_error"] = f"MQTT connection lost ({self._args.printer_ip})"
try:
self.client.disconnect()
except Exception:
pass
_offline = True
stop_event.wait(getattr(self._args, "poll_interval", 3))
continue
# While printing: query print/report directly
if self._state["print_state"] in ("printing", "preheating",
"auto_leveling", "checking", "init"):
@@ -5860,6 +6129,8 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/kx/printers", bridge.handle_kx_printers)
r.add_post("/kx/printers/add", bridge.handle_kx_printer_add)
r.add_delete("/kx/printers/{pid}", bridge.handle_kx_printer_remove)
r.add_post("/kx/printers/{pid}/power", bridge.handle_kx_printer_power)
r.add_get("/kx/printers/{pid}/power-status", bridge.handle_kx_printer_power_status)
r.add_post("/kx/print", bridge.handle_kx_print)
r.add_get("/kx/files", bridge.handle_kx_files)
r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete)
@@ -5915,6 +6186,9 @@ def _build_per_printer_args(base_args, p: dict):
a.mode_id = p.get("mode_id") or base_args.mode_id
a.device_id = p.get("device_id") or base_args.device_id
a.port = int(p.get("http_port") or base_args.port)
a.power_on_url = p.get("power_on_url") or getattr(base_args, "power_on_url", "") or ""
a.power_off_url = p.get("power_off_url") or getattr(base_args, "power_off_url", "") or ""
a.power_status_url = p.get("power_status_url") or getattr(base_args, "power_status_url", "") or ""
return a
@@ -6057,11 +6331,21 @@ def main():
parser.add_argument("--password", default=env_loader.PASSWORD)
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
parser.add_argument("--power-on-url", default=env_loader.POWER_ON_URL,
help="HTTP GET URL to power the printer on (e.g. a Tasmota smart plug)")
parser.add_argument("--power-off-url", default=env_loader.POWER_OFF_URL,
help="HTTP GET URL to power the printer off")
parser.add_argument("--power-status-url", default=env_loader.POWER_STATUS_URL,
help="HTTP GET URL returning the smart plug's current on/off state")
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--delete-printer-file-after-print", type=int,
default=env_loader.DELETE_PRINTER_FILE_AFTER_PRINT,
help="After a successful print, delete the file from the printer's "
"own storage if it's also in the bridge's own GCode store")
parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int)
parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER,

View File

@@ -9,8 +9,11 @@ them as a normalized list with (id, name, vendor, type, color).
from __future__ import annotations
import json
import logging
import re
log = logging.getLogger("kobrax.filaments")
def first_str(value, default: str = "") -> str:
"""Orca profiles store some fields as ['value']. Returns the first
@@ -69,7 +72,17 @@ def parse_profile(data: dict, by_name: dict | None = None,
if system_index:
for p in system_index:
if isinstance(p, dict) and p.get("name"):
sys_by_name[p["name"]] = p
pname = p["name"]
if pname in sys_by_name and sys_by_name[pname] is not p:
# clean_name() deliberately collapses variant-suffixed
# names (e.g. "...@base" vs "...@Anycubic Kobra X 0.4
# nozzle") onto the same cleaned name - expected, but the
# last-write-wins overwrite here was previously silent,
# making an unexpected inherits-parent resolution hard to
# debug.
log.debug("orca_filaments: duplicate system profile name %r - "
"overwriting %r with %r", pname, sys_by_name[pname].get("id"), p.get("id"))
sys_by_name[pname] = p
def _resolve(key: str, depth: int = 5):
cur_list = [data]
@@ -119,7 +132,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
if not fid or not isinstance(fid, str):
return None
name_raw = data.get("name", fid)
name_raw = first_str(data.get("name"), fid)
name = clean_name(name_raw)
vendor = first_str(_resolve_full("filament_vendor")) or (path_vendor or "Generic")
ftype = first_str(_resolve_full("filament_type"), "")

View File

@@ -109,3 +109,128 @@ def test_build_lane_data_plain_type_still_uses_generic_fallback():
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") == []

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

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

@@ -39,6 +39,15 @@ async def test_settings_get_returns_configured_values(client_configured):
assert data["device_id"] == "abc123deadbeef"
@pytest.mark.asyncio
async def test_settings_post_invalid_json_returns_400(client):
"""A malformed/non-JSON body must be a clean 400, not an unhandled 500
with a raw JSONDecodeError traceback (code review finding)."""
c, _ = client
resp = await c.post("/api/settings", data=b"not json", headers={"Content-Type": "application/json"})
assert resp.status == 400
@pytest.mark.asyncio
async def test_settings_post_writes_config_ini(client):
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""

View File

@@ -0,0 +1,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

100
tests/test_update_check.py Normal file
View File

@@ -0,0 +1,100 @@
"""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"
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)"
)

View File

@@ -450,6 +450,11 @@ function applyLang(){
setText('lbl-password',T.settings_password);
setText('lbl-device-id',T.settings_device_id);
setText('lbl-mode-id',T.settings_mode_id);
setText('modal-sec-power',T.settings_power||'Power Switch');
setText('lbl-power-on-url',T.settings_power_on_url||'Power-On URL');
setText('lbl-power-off-url',T.settings_power_off_url||'Power-Off URL');
setText('lbl-power-status-url',T.settings_power_status_url||'Status URL');
setText('lbl-power-hint',T.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.');
setText('lbl-default-slot',T.settings_default_slot);
setText('opt-slot-auto',T.settings_slot_auto);
setText('lbl-auto-leveling',T.settings_auto_leveling);
@@ -459,6 +464,8 @@ function applyLang(){
setText('opt-file-ready-banner',T.settings_file_ready_banner);
setText('lbl-camera-on-print',T.settings_camera_on_print);
setText('lbl-web-upload-warning',T.settings_web_upload_warning);
setText('lbl-delete-printer-file-after-print',T.settings_delete_printer_file_after_print||'Delete file from printer after successful print');
setText('lbl-delete-printer-file-after-print-hint',T.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.');
setText('fd-options-title',T.fd_options_title);
setText('fd-lbl-auto-leveling',T.print_auto_leveling);
@@ -1127,12 +1134,16 @@ function openSettings(){
document.getElementById('s-password').value=d.password||'';
document.getElementById('s-device-id').value=d.device_id||'';
document.getElementById('s-mode-id').value=d.mode_id||'';
var pon=document.getElementById('s-power-on-url');if(pon)pon.value=d.power_on_url||'';
var poff=document.getElementById('s-power-off-url');if(poff)poff.value=d.power_off_url||'';
var pstat=document.getElementById('s-power-status-url');if(pstat)pstat.value=d.power_status_url||'';
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
var dpfap=document.getElementById('s-delete-printer-file-after-print');if(dpfap)dpfap.checked=!!d.delete_printer_file_after_print;
// Poll-Intervall (Sekunden) — Backend hat Vorrang vor localStorage
var pi=document.getElementById('s-poll-interval');
if(pi){
@@ -1895,12 +1906,16 @@ function saveSettings(){
password: document.getElementById('s-password').value,
device_id: document.getElementById('s-device-id').value,
mode_id: document.getElementById('s-mode-id').value,
power_on_url: (document.getElementById('s-power-on-url')||{}).value||'',
power_off_url: (document.getElementById('s-power-off-url')||{}).value||'',
power_status_url: (document.getElementById('s-power-status-url')||{}).value||'',
default_ams_slot: document.getElementById('s-default-slot').value,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
web_upload_warning:webUploadWarning,
delete_printer_file_after_print: (document.getElementById('s-delete-printer-file-after-print')||{}).checked?1:0,
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0,
spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'',
@@ -3984,6 +3999,7 @@ function loadPrinterTab(){
'<span style="font-weight:700;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">🖨 '+p.name+'</span>'+
'<span style="display:flex;align-items:center;gap:8px;flex-shrink:0">'+
(isActive?'<span style="font-size:11px;color:var(--accent);font-weight:600">'+T.printers_active+'</span>':'')+
(p.has_power_control?'<button id="power-btn-'+printerNum+'" onclick="togglePrinterPower(\''+printerNum+'\')" title="'+T.printers_power+'" style="background:none;border:none;color:var(--txt2);font-size:16px;cursor:pointer;line-height:1;padding:0">🔌</button>':'')+
'<button onclick="removePrinter(\''+printerNum+'\',\''+nameEsc+'\')" title="'+T.printers_remove+'" style="background:none;border:none;color:var(--txt2);font-size:16px;cursor:pointer;line-height:1;padding:0">✕</button>'+
'</span>'+
'</div>'+
@@ -4000,8 +4016,40 @@ function loadPrinterTab(){
(!isActive?'<a href="'+url+'/printer'+printerNum+'" style="display:block;text-align:center;padding:7px;background:var(--accent);color:#fff;border-radius:7px;font-size:13px;font-weight:600;text-decoration:none;margin-top:4px">'+T.printers_switch+'</a>':'<div style="text-align:center;padding:7px;font-size:12px;color:var(--txt2)">'+T.printers_current+'</div>')+
'</div>';
}).join('');
results.forEach(function(res){
if(res.printer.has_power_control)_refreshPrinterPowerIcon(res.printer.id,(res.printer.bridge_url||'').replace(/\/+$/,''));
});
});
}).catch(function(e){
if(grid)grid.innerHTML='<div style="color:var(--err);font-size:13px;padding:20px">Fehler: '+e+'</div>';
});
}
function _refreshPrinterPowerIcon(pid,bridgeUrl){
fetch((bridgeUrl||'')+'/kx/printers/'+encodeURIComponent(pid)+'/power-status',{signal:AbortSignal.timeout(5000)})
.then(function(r){return r.json()})
.then(function(d){
var btn=document.getElementById('power-btn-'+pid);
if(!btn)return;
if(d.state==='on'){btn.style.color='var(--ok)';btn.title=T.printers_power_on||'Power: On';}
else if(d.state==='off'){btn.style.color='var(--txt2)';btn.title=T.printers_power_off||'Power: Off';}
})
.catch(function(){/* status endpoint optional - icon just stays neutral */});
}
function togglePrinterPower(pid){
var btn=document.getElementById('power-btn-'+pid);
var currentlyOn=btn&&btn.style.color&&btn.style.color.indexOf('var(--ok)')!==-1;
// Without a known current state, default to "on" - turning an already-off
// switch on is harmless, whereas guessing "off" on a printer mid-print is not.
var action=currentlyOn?'off':'on';
if(action==='off'&&!confirm(T.printers_power_off_confirm||'Turn printer power off? Make sure no print is running.'))return;
if(btn)btn.style.opacity='0.5';
post('/kx/printers/'+encodeURIComponent(pid)+'/power',{action:action}).then(function(){
if(btn)btn.style.opacity='1';
setTimeout(function(){loadPrinterTab();},1500);
}).catch(function(e){
if(btn)btn.style.opacity='1';
clog('Power-Fehler: '+e,'msg-err');
});
}

View File

@@ -548,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 -->
@@ -587,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>

View File

@@ -224,6 +224,10 @@
"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",
@@ -257,6 +261,11 @@
"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",
@@ -284,6 +293,8 @@
"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",

View File

@@ -224,6 +224,10 @@
"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",
@@ -257,6 +261,11 @@
"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",
@@ -284,6 +293,8 @@
"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",

View File

@@ -224,6 +224,10 @@
"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",
@@ -257,6 +261,11 @@
"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",
@@ -284,6 +293,8 @@
"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",

View File

@@ -212,6 +212,10 @@
"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",
@@ -245,6 +249,11 @@
"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",
@@ -270,6 +279,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",

View File

@@ -212,6 +212,10 @@
"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",
@@ -245,6 +249,11 @@
"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",
@@ -270,6 +279,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",

View File

@@ -224,6 +224,10 @@
"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": "清除",
@@ -257,6 +261,11 @@
"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": "导入配置文件",
@@ -284,6 +293,8 @@
"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": "新",