Compare commits
6 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a360b463f9 | ||
| 7e33cc9eda | |||
| 5a44d0abab | |||
| 23e3831232 | |||
| f54783ad16 | |||
|
|
b37dfb4dcf |
256
API.md
Normal file
256
API.md
Normal 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}` (0–100) |
|
||||
| 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
330
MANUAL.md
Normal 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
|
||||
(30–80 °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.
|
||||
@@ -1,6 +1,2 @@
|
||||
## Changes in this build
|
||||
|
||||
- Feat: `server/files/metadata` now uses the printer's own `buried/report` analytics event (fires once per print start, regardless of slicer) as a fallback for `size`/`estimated_time`/`layer_count` — fixes broken `size: 1`/`estimated_time: null` placeholders for files not in the bridge's own GCode store, e.g. prints started directly from Anycubic Slicer Next (Issue #102, thanks @fmontagna). Also surfaces the printer's storage usage (`storage_total_mb`/`storage_used_mb`) in `/api/state`.
|
||||
- Fix: **the bridge could get stuck in an endless reconnect loop after a printer disconnect, even once the printer was back online and reachable** — a container restart was the only way out. Two independent reconnect paths (the MQTT reader thread and the status-poll loop) could race into competing TLS handshakes, and the poll loop never noticed a dead MQTT session on its own since a failed send silently returned no data instead of raising an error. The bridge now reconnects automatically without manual intervention (Issue #105, thanks @p2l for the precise report).
|
||||
- Fix: the in-app update check on **stable** releases only ever looked at the single newest release on Gitea regardless of type — since nightly/dev prereleases publish far more often than stable ones, that newest release is almost always a prerelease, so the check found nothing and reported "no stable releases found" even though a newer stable release existed (Issue #104, thanks @Nerdinat0r).
|
||||
- Feat: printers with no MQTT-level standby/power-off command (i.e. all of them) can now be switched via an external smart plug (e.g. Tasmota) directly from the dashboard — configure a power-on/power-off/status URL per printer in Settings, and a power button with a live on/off indicator appears on that printer's card (Issue #103, thanks @ok24). Also fixes `config.ini` values containing a literal `%` (e.g. Tasmota's `cmnd=Power%20on` URLs) being rejected/corrupted by `configparser`'s default string interpolation.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,6 +67,7 @@ CONFIG_ENV_MAPPING = {
|
||||
"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"),
|
||||
@@ -459,6 +460,7 @@ 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")))
|
||||
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
|
||||
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
|
||||
|
||||
@@ -54,5 +54,6 @@ 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", "")
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -189,6 +229,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])
|
||||
|
||||
@@ -252,19 +293,31 @@ class KobraXClient:
|
||||
with self._lock:
|
||||
return self._sock is not None
|
||||
|
||||
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.
|
||||
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 waits for it to finish instead of
|
||||
starting a second, competing _do_connect() - the printer likely only
|
||||
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."""
|
||||
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
|
||||
@@ -290,6 +343,12 @@ class KobraXClient:
|
||||
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.
|
||||
@@ -522,7 +581,10 @@ class KobraXClient:
|
||||
self._pending_msgid.pop(msgid, None)
|
||||
if report_registered:
|
||||
self._pending_report.pop(report_key, None)
|
||||
if not self._reconnect():
|
||||
# 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:
|
||||
@@ -569,10 +631,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
|
||||
|
||||
|
||||
@@ -1073,6 +1073,11 @@ class KobraXBridge:
|
||||
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()
|
||||
@@ -1396,6 +1401,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 = {}
|
||||
@@ -1408,11 +1414,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
|
||||
@@ -1579,6 +1595,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
|
||||
@@ -2243,12 +2278,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()
|
||||
@@ -2256,12 +2311,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
|
||||
@@ -2278,21 +2349,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.
|
||||
@@ -2338,30 +2432,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")
|
||||
@@ -3547,6 +3630,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})
|
||||
|
||||
@@ -4996,6 +5080,7 @@ class KobraXBridge:
|
||||
"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),
|
||||
@@ -5037,6 +5122,7 @@ class KobraXBridge:
|
||||
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:
|
||||
@@ -6219,6 +6305,10 @@ def main():
|
||||
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,
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
157
tests/test_delete_printer_file_after_print.py
Normal file
157
tests/test_delete_printer_file_after_print.py
Normal 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
|
||||
@@ -92,3 +92,61 @@ def test_reconnect_second_waiter_returns_after_first_completes():
|
||||
|
||||
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
|
||||
|
||||
80
tests/test_tcp_keepalive.py
Normal file
80
tests/test_tcp_keepalive.py
Normal 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
|
||||
@@ -464,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);
|
||||
|
||||
@@ -1141,6 +1143,7 @@ function openSettings(){
|
||||
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){
|
||||
@@ -1912,6 +1915,7 @@ function saveSettings(){
|
||||
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||'',
|
||||
|
||||
@@ -603,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>
|
||||
|
||||
|
||||
@@ -293,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",
|
||||
|
||||
@@ -293,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",
|
||||
|
||||
@@ -293,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",
|
||||
|
||||
@@ -279,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",
|
||||
|
||||
@@ -279,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",
|
||||
|
||||
@@ -293,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": "新",
|
||||
|
||||
Reference in New Issue
Block a user