Compare commits
47 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ea111c5e8 | |||
| 026badf992 | |||
| 72849cff24 | |||
| 2417f46a24 | |||
| 7be321d185 | |||
| fba95d3564 | |||
| bedd984011 | |||
| ef1956f423 | |||
| e70720bc27 | |||
| fb4a9414f4 | |||
| 752f5c790c | |||
| 287369ac90 | |||
| 51951476a9 | |||
| 5dd668e8ca | |||
| 0c520ffa00 | |||
| 3daf945612 | |||
| 60156fd589 | |||
| a106e4ab68 | |||
| 8384c69836 | |||
| a360b463f9 | |||
| 7e33cc9eda | |||
| 5a44d0abab | |||
| 23e3831232 | |||
| f54783ad16 | |||
| b37dfb4dcf | |||
| 52baaa8f70 | |||
| ecc53cd7cb | |||
| 0a9bf6def6 | |||
| 36cb97038f | |||
| 157517ffb4 | |||
| 71664e7e8b | |||
| c4f321c9b0 | |||
| 1f1d60d571 | |||
| 9f76d28622 | |||
| cbcb17f45a | |||
| 4e7f851799 | |||
| 1d5ac8dc4e | |||
| 64c8bc32d7 | |||
| 0ca7618c85 | |||
| 6d6df59ff2 | |||
| 33b42e64cc | |||
| 16c1a8ee73 | |||
| 884bdfc0c3 | |||
| 8f14580e30 | |||
| 20daa6b6b8 | |||
| 2f222a0b93 | |||
| 6e72346129 |
@ -174,3 +174,20 @@ jobs:
|
||||
--data-binary @/tmp/release_body.json \
|
||||
"https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases"
|
||||
rm -f "$BODY_FILE" /tmp/release_body.json
|
||||
|
||||
- name: Reset NIGHTLY_CHANGELOG.md for the next build
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
. /tmp/nightly_version.env
|
||||
# The changelog just consumed above must not carry over into the
|
||||
# next nightly - otherwise every build re-lists all prior entries
|
||||
# since the last manual reset instead of just what's new. Not in
|
||||
# nightly.yml's own push-trigger paths, so this commit does not
|
||||
# re-trigger this workflow.
|
||||
printf '## Changes in this build\n\n' > NIGHTLY_CHANGELOG.md
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@gitea.it-drui.de"
|
||||
git add NIGHTLY_CHANGELOG.md
|
||||
git commit -m "chore: reset NIGHTLY_CHANGELOG.md after nightly-${VERSION} release" || exit 0
|
||||
git push https://gitea-actions:${GITEA_TOKEN}@gitea.it-drui.de/viewit/KX-Bridge-Release.git HEAD:nightly
|
||||
|
||||
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.
|
||||
18
CHANGES.md
Normal file
18
CHANGES.md
Normal file
@ -0,0 +1,18 @@
|
||||
## KX-Bridge 0.9.30-nightly15 — Nightly Build
|
||||
|
||||
[experimental] May be unstable — for testers and early adopters only.
|
||||
|
||||
### Changes since `nightly-0.9.30-nightly14`
|
||||
|
||||
- Feat: the power-switch toggle in the header now has an opt-in setting (Settings → Power Switch → "Aktuellen Status statt Aktion anzeigen") to show the printer's actual current on/off state instead of the available toggle action. Default behavior (showing the action) is unchanged; some users preferred the switch to mirror reality rather than show what a click would do.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Update Docker image
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Image tag: `gitea.it-drui.de/viewit/kx-bridge:nightly`
|
||||
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,5 +1,2 @@
|
||||
## Changes in this build
|
||||
|
||||
- Fix: dashboard reprint from the cache path crashed with an AttributeError (`self._db` did not exist) instead of applying the filament slot filter (PR #93, thanks @Pavulon87)
|
||||
- Feat: the dashboard now shows a banner with the reason and error code when a print is paused, with a direct link to the Anycubic error-code docs (PR #92, thanks @Pavulon87)
|
||||
- Fix: `/api/camera/stream` (the MJPEG live view used by the dashboard and every Moonraker-compatible client) opened a brand-new ffmpeg process and printer connection per HTTP client — two simultaneous viewers could already exhaust the printer's very limited camera connection slots and cause intermittent "stream unavailable"/429 failures. All consumers now share one ffmpeg process via the same fanout pattern already used for the H.264 stream (credit to @Pavulon87 for the fix and for finding two related race conditions along the way)
|
||||
|
||||
21
README.de.md
21
README.de.md
@ -23,15 +23,13 @@ Feedback willkommen.</sub>
|
||||
|
||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||
|
||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||
[](https://www.youtube.com/watch?v=E3sDigSeSdM)
|
||||
|
||||
<sub>Gefällt dir KX-Bridge? Ein Kaffee auf <a href="https://ko-fi.com/viewitde">Ko-fi</a> hält das Projekt am Leben. ☕</sub>
|
||||
|
||||
</div>
|
||||
|
||||
> [!CAUTION]
|
||||
> **Laufende Wartungsarbeiten** — Wir strukturieren das Repository um (Branch-Modell, CI-Workflows, Beitragsprozess). Es kann zu Änderungen bei Branch-Namen, PR-Templates und der Art der Veröffentlichungen kommen. Wir entschuldigen uns für etwaige Unannehmlichkeiten. Handhabung, Workflow und langfristige Wartbarkeit werden dadurch deutlich verbessert.
|
||||
>
|
||||
|
||||
> 👉 Möchtest du beitragen? Bitte zuerst [CONTRIBUTING.md](CONTRIBUTING.md) lesen.
|
||||
|
||||
---
|
||||
@ -43,12 +41,16 @@ Feedback willkommen.</sub>
|
||||
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
|
||||
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
|
||||
| 🎨 | **AMS / Multicolor** — Slots mit **Profil-Picker pro Slot** (eigene Marke aus OrcaSlicer-Profilen pro Slot zuweisen); Bridge schreibt Material und Farbe ans Drucker-Display zurück |
|
||||
| 🏷️ | **Custom-RFID-Tag-Matching** — mit Drittanbieter-Tools (z.B. der „ACE RFID"-App) beschriebene Spulen werden automatisch nach Marke + Material gegen deine importierten OrcaSlicer-Profile gematcht, statt auf ein generisches Profil zurückzufallen |
|
||||
| 📦 | **Eigene OrcaSlicer-Profile importieren** — ZIP aus `~/.config/OrcaSlicer/user/<id>/filament/` in die Bridge ziehen; tauchen im Slot-Dropdown unter ★ Eigene Profile auf |
|
||||
| 🧵 | **Spoolman-Integration** — Spulen einzelnen AMS-Slots zuweisen, Filament-Verbrauch wird automatisch beim Drucken erfasst und synchronisiert |
|
||||
| 🔗 | **Multi-ACE-Unterstützung** — mehrere aneinandergekettete ACE-Einheiten, auch bei Druckern ohne Toolhead-Buffer |
|
||||
| 📷 | **Obico-Integration (experimentell)** — Time-Lapse und WebRTC-Livestream gegen einen selbst gehosteten [Obico-Server](https://github.com/TheSpaghettiDetective/obico-server) via moonraker-obico |
|
||||
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget) |
|
||||
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
|
||||
| 📐 | **H.264-Direkt-Stream + Z-Höhe** — sparsamer Kamera-Pfad für Obico, aktuelle Z aus der Layer-Höhe abgeleitet (Mm-Progress-Widget); erholt sich automatisch nach einem Drucker-Reboot mit rotiertem Stream-Token, kein manueller Reset nötig |
|
||||
| 🗂️ | **GCode-Browser** — zwei Tabs: hochgeladene Dateien (mit Thumbnail, Druckhistorie, Suche & Filter, Mehrfachauswahl + Sammel-Löschen) und Dateien direkt auf dem Drucker-Speicher (mit echten Thumbnails, Mehrfachauswahl + Löschen) |
|
||||
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
|
||||
| ➕ | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
|
||||
| 🖱️ | **Frei anpassbares Dashboard** — Kacheln per Drag & Drop verschieben und in der Größe anpassen, als Preset speichern |
|
||||
| 🔁 | **Robuster MQTT-Reconnect** — Bridge überlebt nächtlichen Drucker-Reboot ohne manuellen Neustart |
|
||||
| 🌐 | **Mehrsprachiges UI** — DE / EN / ES / FR / IT / 中文, Browser-Sprache automatisch erkannt |
|
||||
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
|
||||
@ -70,6 +72,11 @@ LAN-Modus am Kobra X aktivieren:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> Zusätzlich Spoolman und einen kompletten selbst gehosteten Obico-Setup
|
||||
> (Spaghetti-Erkennung, Live-Stream) neben der Bridge? [`docker-compose-KX.yml`](docker-compose-KX.yml)
|
||||
> bündelt KX-Bridge + Spoolman + Obico (Web/ML/Tasks/Redis) + moonraker-obico
|
||||
> in einem Netzwerk — Setup-Schritte in den Kommentaren am Dateianfang.
|
||||
|
||||
**Linux-Binary (kein Docker):**
|
||||
```bash
|
||||
chmod +x kx-bridge && ./kx-bridge
|
||||
@ -112,7 +119,7 @@ Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
|
||||
|
||||
## 📺 Video-Tutorial
|
||||
|
||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||
[](https://www.youtube.com/watch?v=E3sDigSeSdM)
|
||||
|
||||
---
|
||||
|
||||
|
||||
17
README.es.md
17
README.es.md
@ -22,7 +22,7 @@ ninguna está oficialmente probada ni soportada. Se agradece el feedback.</sub>
|
||||
|
||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||
|
||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||
[](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
|
||||
|
||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||
[](https://www.youtube.com/watch?v=E3sDigSeSdM)
|
||||
|
||||
---
|
||||
|
||||
|
||||
19
README.md
19
README.md
@ -22,7 +22,7 @@ officially tested or supported. Feedback welcome.</sub>
|
||||
|
||||
[](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
|
||||
|
||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||
[](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
|
||||
|
||||
[](https://www.youtube.com/watch?v=1Ql4wfH27fM)
|
||||
[](https://www.youtube.com/watch?v=E3sDigSeSdM)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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,16 @@ 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"),
|
||||
"POWER_STATUS_INVERTED": (CONFIG_SECTION_CONNECTION, "power_status_inverted"),
|
||||
"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 +84,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 +120,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 +139,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 +192,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 +254,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 +295,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 +337,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 +359,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 +419,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 +439,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 +458,42 @@ 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", "")
|
||||
POWER_STATUS_INVERTED = _safe_int(get("POWER_STATUS_INVERTED", "0"), 0)
|
||||
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)
|
||||
|
||||
@ -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
|
||||
|
||||
35
docker-publish-testing.sh
Executable file
35
docker-publish-testing.sh
Executable file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# docker-publish-testing.sh – lokaler Docker-Build+Push für den Testing-Kanal.
|
||||
#
|
||||
# Ersatz für .gitea/workflows/testing.yml: baut & pusht
|
||||
# gitea.it-drui.de/viewit/kx-bridge:testing + :testing-<shortsha>
|
||||
# direkt vom aktuellen HEAD des testing-Branches. Bewusst kein Teil von release.sh
|
||||
# (kein Versions-Argument, kein Changelog, kein Tag, kein Branch-Writeback – Testing-
|
||||
# Pushes sind commit-getrieben/ad-hoc, wie es testing.yml auch schon war).
|
||||
#
|
||||
# Verwendung:
|
||||
# ./docker-publish-testing.sh
|
||||
#
|
||||
# Voraussetzung: einmalig `docker login gitea.it-drui.de` (siehe release_lib.sh).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEV_REPO="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DEV_REPO"
|
||||
source "$DEV_REPO/release_lib.sh"
|
||||
|
||||
CUR_BRANCH=$(git branch --show-current)
|
||||
[[ "$CUR_BRANCH" == "testing" ]] || error "Muss auf 'testing'-Branch sein (aktuell: $CUR_BRANCH)"
|
||||
|
||||
VERSION="testing-$(git rev-parse --short HEAD)"
|
||||
info "Testing-Version: $VERSION"
|
||||
|
||||
# VERSION nur im Workspace setzen (kein Commit) – wie testing.yml es tat.
|
||||
echo "$VERSION" > VERSION
|
||||
|
||||
docker_build_push "$VERSION" "testing" "$VERSION"
|
||||
|
||||
# Workspace wieder sauber hinterlassen.
|
||||
git checkout -- VERSION 2>/dev/null || true
|
||||
|
||||
success "Testing-Build fertig: gitea.it-drui.de/viewit/kx-bridge:testing + :$VERSION"
|
||||
@ -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", "")
|
||||
|
||||
368
kobrax_client.py
368
kobrax_client.py
@ -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,46 +292,91 @@ 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:
|
||||
pid = self._pid
|
||||
self._pid += 1
|
||||
# MQTT packet IDs are a 16-bit field (1-65535, 0 reserved) - wrap
|
||||
# instead of growing unbounded, otherwise a long-lived bridge with
|
||||
# frequent reconnects eventually overflows pid.to_bytes(2, "big")
|
||||
# (OverflowError: int too big to convert), breaking every future
|
||||
# connect attempt including the manual "Connect" button.
|
||||
self._pid = 1 if self._pid >= 0xFFFF else self._pid + 1
|
||||
if self._sock is not None:
|
||||
self._sock.sendall(_build_subscribe(topic, pid))
|
||||
log.info("SUB %s", topic)
|
||||
@ -353,34 +459,46 @@ class KobraXClient:
|
||||
def _drain(self):
|
||||
buf = self._buf
|
||||
idx = 0
|
||||
while idx < len(buf):
|
||||
ptype = buf[idx] & 0xF0
|
||||
i = idx + 1
|
||||
mul = 1
|
||||
rem = 0
|
||||
while i < len(buf):
|
||||
b = buf[i]
|
||||
rem += (b & 0x7F) * mul
|
||||
mul *= 128
|
||||
i += 1
|
||||
if not (b & 0x80):
|
||||
try:
|
||||
while idx < len(buf):
|
||||
ptype = buf[idx] & 0xF0
|
||||
i = idx + 1
|
||||
mul = 1
|
||||
rem = 0
|
||||
while i < len(buf):
|
||||
b = buf[i]
|
||||
rem += (b & 0x7F) * mul
|
||||
mul *= 128
|
||||
i += 1
|
||||
if not (b & 0x80):
|
||||
break
|
||||
if i + rem > len(buf):
|
||||
break
|
||||
if i + rem > len(buf):
|
||||
break
|
||||
pkt = buf[i:i + rem]
|
||||
idx = i + rem
|
||||
pkt = buf[i:i + rem]
|
||||
idx = i + rem
|
||||
|
||||
if ptype == 0x30:
|
||||
topic, raw_payload = _parse_publish(pkt)
|
||||
if topic is None:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except Exception:
|
||||
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
|
||||
self._dispatch(topic, payload)
|
||||
|
||||
self._buf = buf[idx:]
|
||||
if ptype == 0x30:
|
||||
topic, raw_payload = _parse_publish(pkt)
|
||||
if topic is None:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except Exception:
|
||||
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
|
||||
try:
|
||||
self._dispatch(topic, payload)
|
||||
except Exception as e:
|
||||
# A single malformed/unexpected message (e.g. valid JSON
|
||||
# that isn't an object, like a bare number or list) must
|
||||
# not be reprocessed forever: without this, an exception
|
||||
# here would skip the buffer-advance below, leaving the
|
||||
# same bad packet at the front of self._buf so every
|
||||
# future _drain() call crashes on it again - each one
|
||||
# forcing a reconnect via the reader loop's exception
|
||||
# handler, an endless self-inflicted reconnect loop.
|
||||
log.warning("dispatch error for %s: %s", topic, e)
|
||||
finally:
|
||||
self._buf = buf[idx:]
|
||||
|
||||
def _dedup_hash(self, suffix: str, payload: dict) -> str:
|
||||
"""Hash payload ignoring volatile per-tick fields for dedup check."""
|
||||
@ -391,8 +509,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 +539,29 @@ class KobraXClient:
|
||||
log.info("RX %-25s state=%-12s data=%s",
|
||||
suffix, state, json.dumps(payload.get("data"), ensure_ascii=False))
|
||||
|
||||
# Resolve by report topic suffix (e.g. "info/report")
|
||||
if suffix in self._pending_report:
|
||||
entry = self._pending_report[suffix]
|
||||
entry["result"] = payload
|
||||
entry["event"].set()
|
||||
msgid = payload.get("msgid")
|
||||
with self._pending_lock:
|
||||
report_entry = self._pending_report.get(suffix)
|
||||
msgid_entry = self._pending_msgid.get(msgid) if msgid else None
|
||||
|
||||
# Resolve by report topic suffix (e.g. "info/report"). If the payload
|
||||
# carries a msgid that doesn't match what this waiter is actually
|
||||
# expecting, it's a stale/late reply for a different, already-timed-out
|
||||
# request that happens to share the same report_key - don't deliver it
|
||||
# to the wrong caller.
|
||||
if report_entry is not None:
|
||||
entry_msgid = report_entry.get("msgid")
|
||||
if not entry_msgid or not msgid or entry_msgid == msgid:
|
||||
report_entry["result"] = payload
|
||||
report_entry["event"].set()
|
||||
else:
|
||||
log.debug("dispatch: msgid mismatch for %s report (waiting=%s, got=%s) - ignoring stale reply",
|
||||
suffix, entry_msgid, msgid)
|
||||
|
||||
# Resolve by msgid (for generic response ACK)
|
||||
msgid = payload.get("msgid")
|
||||
if msgid and msgid in self._pending_msgid:
|
||||
entry = self._pending_msgid[msgid]
|
||||
entry["result"] = payload
|
||||
entry["event"].set()
|
||||
if msgid_entry is not None:
|
||||
msgid_entry["result"] = payload
|
||||
msgid_entry["event"].set()
|
||||
|
||||
# User callbacks by topic suffix (last two path components)
|
||||
if suffix in self.callbacks:
|
||||
@ -462,13 +597,18 @@ class KobraXClient:
|
||||
# Also register by report topic as fallback for responses without msgid.
|
||||
report_key = f"{msg_type}/report"
|
||||
event = threading.Event()
|
||||
entry = {"event": event, "result": None}
|
||||
self._pending_msgid[msgid] = entry
|
||||
# Only register report-key waiter if nobody else is waiting on it
|
||||
# entry carries its own msgid so _dispatch()'s report-suffix path can
|
||||
# confirm a reply actually belongs to THIS request before delivering
|
||||
# it - without that, a late reply for an already-timed-out request A
|
||||
# could be handed to a newer request B waiting on the same report_key.
|
||||
entry = {"event": event, "result": None, "msgid": msgid}
|
||||
report_registered = False
|
||||
if report_key not in self._pending_report:
|
||||
self._pending_report[report_key] = entry
|
||||
report_registered = True
|
||||
with self._pending_lock:
|
||||
self._pending_msgid[msgid] = entry
|
||||
# Only register report-key waiter if nobody else is waiting on it
|
||||
if report_key not in self._pending_report:
|
||||
self._pending_report[report_key] = entry
|
||||
report_registered = True
|
||||
|
||||
topic = self._pub_topic(msg_type)
|
||||
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
|
||||
@ -482,31 +622,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 +679,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 +758,9 @@ class KobraXClient:
|
||||
raise RuntimeError("Could not get info/report for upload URL")
|
||||
upload_url = info["data"]["urls"]["fileUploadurl"]
|
||||
# parse token from URL query string
|
||||
token = upload_url.split("?s=")[1] if "?s=" in upload_url else ""
|
||||
if "?s=" not in upload_url:
|
||||
raise RuntimeError(f"Upload: no session token ('?s=') in upload URL: {upload_url!r}")
|
||||
token = upload_url.split("?s=")[1]
|
||||
|
||||
with open(filepath, "rb") as f:
|
||||
file_data = f.read()
|
||||
@ -660,19 +810,25 @@ class KobraXClient:
|
||||
# (the printer processes the file before replying).
|
||||
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock = socket.create_connection(_ai[0][4], timeout=10)
|
||||
sock.settimeout(None) # blocking during send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
except socket.timeout:
|
||||
pass
|
||||
sock.close()
|
||||
sock.settimeout(None) # blocking during send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
response += chunk
|
||||
except socket.timeout:
|
||||
pass
|
||||
finally:
|
||||
# Without this, a sendall()/recv() failure other than
|
||||
# socket.timeout (e.g. ConnectionResetError/BrokenPipeError if
|
||||
# the printer drops the connection mid-upload) skipped
|
||||
# sock.close() entirely, leaking the fd on every failed attempt.
|
||||
sock.close()
|
||||
|
||||
# parse HTTP response body
|
||||
if b"\r\n\r\n" in response:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,8 +9,11 @@ them as a normalized list with (id, name, vendor, type, color).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("kobrax.filaments")
|
||||
|
||||
|
||||
def first_str(value, default: str = "") -> str:
|
||||
"""Orca profiles store some fields as ['value']. Returns the first
|
||||
@ -69,7 +72,17 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
if system_index:
|
||||
for p in system_index:
|
||||
if isinstance(p, dict) and p.get("name"):
|
||||
sys_by_name[p["name"]] = p
|
||||
pname = p["name"]
|
||||
if pname in sys_by_name and sys_by_name[pname] is not p:
|
||||
# clean_name() deliberately collapses variant-suffixed
|
||||
# names (e.g. "...@base" vs "...@Anycubic Kobra X 0.4
|
||||
# nozzle") onto the same cleaned name - expected, but the
|
||||
# last-write-wins overwrite here was previously silent,
|
||||
# making an unexpected inherits-parent resolution hard to
|
||||
# debug.
|
||||
log.debug("orca_filaments: duplicate system profile name %r - "
|
||||
"overwriting %r with %r", pname, sys_by_name[pname].get("id"), p.get("id"))
|
||||
sys_by_name[pname] = p
|
||||
|
||||
def _resolve(key: str, depth: int = 5):
|
||||
cur_list = [data]
|
||||
@ -119,7 +132,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
if not fid or not isinstance(fid, str):
|
||||
return None
|
||||
|
||||
name_raw = data.get("name", fid)
|
||||
name_raw = first_str(data.get("name"), fid)
|
||||
name = clean_name(name_raw)
|
||||
vendor = first_str(_resolve_full("filament_vendor")) or (path_vendor or "Generic")
|
||||
ftype = first_str(_resolve_full("filament_type"), "")
|
||||
|
||||
83
release_lib.sh
Normal file
83
release_lib.sh
Normal file
@ -0,0 +1,83 @@
|
||||
# release_lib.sh – gemeinsame Logging-/Docker-/Changelog-Helper für release.sh und
|
||||
# docker-publish-testing.sh. Wird per `source` eingebunden, kein eigenständiges Skript
|
||||
# (kein Shebang, kein `set -euo pipefail` hier – das übernimmt der aufrufende Caller).
|
||||
|
||||
# ── Farben ───────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
info() { echo -e "${CYAN}[release]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[release]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[release]${NC} $*"; }
|
||||
error() { echo -e "${RED}[release]${NC} $*" >&2; exit 1; }
|
||||
|
||||
# docker_build_push <version> <tag1> [<tag2> ...]
|
||||
# Baut das kx-bridge-Image lokal via buildx (kxbuilder, 3 Plattformen) und pusht es
|
||||
# direkt in die Gitea-Registry. Ersetzt die vormals CI-seitige Build-Logik aus
|
||||
# release.yml/nightly.yml/testing.yml 1:1 (gleiche Flags, gleicher Builder-Name).
|
||||
docker_build_push() {
|
||||
local version="$1"; shift
|
||||
local -a tag_args=()
|
||||
for t in "$@"; do tag_args+=( -t "gitea.it-drui.de/viewit/kx-bridge:${t}" ); done
|
||||
[[ ${#tag_args[@]} -eq 0 ]] && error "docker_build_push: keine Tags angegeben"
|
||||
|
||||
info "Docker buildx: kxbuilder aktivieren …"
|
||||
docker buildx inspect kxbuilder --bootstrap >/dev/null 2>&1 || {
|
||||
docker buildx create --name kxbuilder --driver docker-container --use
|
||||
docker buildx inspect kxbuilder --bootstrap
|
||||
}
|
||||
docker buildx use kxbuilder
|
||||
|
||||
info "QEMU/binfmt sicherstellen …"
|
||||
docker run --rm --privileged tonistiigi/binfmt:latest --install all >/dev/null
|
||||
|
||||
grep -q '"gitea.it-drui.de"' ~/.docker/config.json 2>/dev/null \
|
||||
|| error "Nicht bei gitea.it-drui.de eingeloggt — 'docker login gitea.it-drui.de' zuerst ausführen"
|
||||
|
||||
info "docker buildx build --push (${tag_args[*]}) für Version $version …"
|
||||
( cd "$DEV_REPO" && docker buildx build \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--push \
|
||||
--provenance=false \
|
||||
--no-cache \
|
||||
"${tag_args[@]}" \
|
||||
. )
|
||||
success "Docker-Image gepusht: ${tag_args[*]}"
|
||||
}
|
||||
|
||||
# author_changelog_entry <base_tag> <output_file> <title> [<seed_content_file>]
|
||||
# Öffnet $EDITOR auf einem Draft, vorausgefüllt mit dem Git-Log-Delta seit <base_tag>
|
||||
# (als Kommentar) plus optional dem Inhalt von <seed_content_file> (z.B. bestehende
|
||||
# NIGHTLY_CHANGELOG.md) als Startpunkt. Schreibt das editierte Ergebnis (Kommentarzeilen
|
||||
# entfernt) nach <output_file>. Bricht ab wenn nichts Sinnvolles übrig bleibt.
|
||||
author_changelog_entry() {
|
||||
local base="$1" outfile="$2" title="$3" seed_file="${4:-}"
|
||||
local draft
|
||||
draft=$(mktemp)
|
||||
{
|
||||
echo "# ${title}"
|
||||
echo "# Editiere den Text unten (oder ersetze ihn komplett)."
|
||||
echo "# Zeilen die mit '#' beginnen werden entfernt."
|
||||
echo "#"
|
||||
echo "# Roher Commit-Delta seit ${base:-<Repo-Anfang>} zur Orientierung:"
|
||||
gen_changelog "$base" | sed 's/^/# /'
|
||||
echo "#"
|
||||
if [[ -n "$seed_file" && -s "$seed_file" ]]; then
|
||||
echo "# ── Bestehender Inhalt (z.B. NIGHTLY_CHANGELOG.md) als Ausgangspunkt: ──"
|
||||
cat "$seed_file"
|
||||
else
|
||||
echo "### New"
|
||||
echo "- "
|
||||
echo ""
|
||||
echo "### Fixed"
|
||||
echo "- "
|
||||
fi
|
||||
} > "$draft"
|
||||
|
||||
"${EDITOR:-nano}" "$draft"
|
||||
|
||||
grep -v '^#' "$draft" > "$outfile"
|
||||
if [[ ! -s "$outfile" ]] || ! grep -q '^- ' "$outfile"; then
|
||||
rm -f "$draft"
|
||||
error "Changelog-Eintrag leer/kein Bullet gefunden — Release abgebrochen."
|
||||
fi
|
||||
rm -f "$draft"
|
||||
}
|
||||
236
tests/test_ace_rfid_vendor_matching.py
Normal file
236
tests/test_ace_rfid_vendor_matching.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Auto-matching for custom ACE-RFID filament tags (Issue #101).
|
||||
|
||||
Anycubic's ACE RFID system concatenates vendor + material + a truncated
|
||||
serial into one `type` string for custom (third-party) tags, e.g.
|
||||
"GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for
|
||||
"Basic"). Previously the bridge treated this whole string as an unknown
|
||||
material and fell back to a neutral "Generic <type>" profile, even though
|
||||
the user had already imported a matching OrcaSlicer profile via the ZIP
|
||||
import feature (Issue #41) - requiring a manual per-slot reassignment every
|
||||
time that spool was loaded.
|
||||
|
||||
_parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this
|
||||
automatically against the merged system+user filament library.
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
USER_PROFILES = [
|
||||
{"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""},
|
||||
{"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
|
||||
]
|
||||
|
||||
|
||||
def _bridge(profiles=USER_PROFILES):
|
||||
c = MagicMock(); c.callbacks = {}; c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxrfid-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._orca_filaments_cache = profiles
|
||||
return b
|
||||
|
||||
|
||||
def test_combined_rfid_type_parses_known_vendor_and_family():
|
||||
b = _bridge()
|
||||
vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas")
|
||||
assert vendor == "Geeetech"
|
||||
assert family == "PLA"
|
||||
|
||||
|
||||
def test_plain_type_string_is_unaffected():
|
||||
"""Regression guard: a normal type="PLA" report (no vendor prefix) must
|
||||
not be mistaken for a combined RFID string."""
|
||||
b = _bridge()
|
||||
vendor, family = b._parse_combined_rfid_type("PLA")
|
||||
assert vendor == ""
|
||||
assert family == ""
|
||||
|
||||
|
||||
def test_unknown_vendor_prefix_returns_no_match():
|
||||
b = _bridge()
|
||||
vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas")
|
||||
assert vendor == ""
|
||||
assert family == ""
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_finds_imported_profile():
|
||||
b = _bridge()
|
||||
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
|
||||
assert profile.get("name") == "Geeetech PLA Basic"
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_no_match_returns_empty():
|
||||
b = _bridge()
|
||||
profile = b._match_profile_by_vendor_family("Geeetech", "PETG")
|
||||
assert profile == {}
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing():
|
||||
profiles = USER_PROFILES + [
|
||||
{"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True},
|
||||
]
|
||||
b = _bridge(profiles)
|
||||
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
|
||||
assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk")
|
||||
|
||||
|
||||
def test_build_lane_data_auto_resolves_combined_rfid_slot():
|
||||
"""End-to-end: a slot reporting the combined RFID string should surface
|
||||
the imported Geeetech profile in lane_data instead of the Generic
|
||||
fallback."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path
|
||||
b._filament_mode = "ace_hub"
|
||||
b._ams_slots = [
|
||||
{"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]},
|
||||
]
|
||||
lane = b._build_lane_data()
|
||||
tray = lane["ams"][0]["tray"][0]
|
||||
assert tray["vendor_name"] == "Geeetech"
|
||||
assert tray["name"] == "Geeetech PLA Basic"
|
||||
|
||||
|
||||
def test_build_lane_data_plain_type_still_uses_generic_fallback():
|
||||
"""Regression guard: everyday type="PLA" slots must keep using the
|
||||
existing Generic-library fallback, unaffected by the new matching path."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {} # no manual per-slot override - isolate the fallback path
|
||||
b._filament_mode = "ace_hub"
|
||||
b._ams_slots = [
|
||||
{"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]},
|
||||
]
|
||||
lane = b._build_lane_data()
|
||||
tray = lane["ams"][0]["tray"][0]
|
||||
assert tray["name"] == "Generic PLA"
|
||||
assert tray["vendor_name"] == "Generic"
|
||||
|
||||
|
||||
# ─── Centralized matching via _effective_slot_profile() ────────────────────
|
||||
#
|
||||
# The bug reported in Issue #101 by @Blaim (nightly45 not working, despite
|
||||
# _parse_combined_rfid_type()/_match_profile_by_vendor_family() existing):
|
||||
# those two helpers were ONLY ever invoked inside _build_lane_data(), which
|
||||
# is only reached when OrcaSlicer polls the Moonraker lane_data endpoint -
|
||||
# never as part of the real MQTT receive path (_on_multicolor_box ->
|
||||
# self._ams_slots -> _push_status_update -> dashboard / /kx/filament/slots).
|
||||
# So the dashboard and the Happy-Hare gate data never saw a match, matching
|
||||
# exactly what the user's screenshots showed. Fixed by moving the matching
|
||||
# logic into _effective_slot_profile() itself, which all three consumers
|
||||
# already call.
|
||||
|
||||
def test_effective_slot_profile_auto_resolves_raw_rfid_string_without_override():
|
||||
"""The core Issue #101 regression: _effective_slot_profile() itself (not
|
||||
just _build_lane_data()) must resolve a combined RFID string when there is
|
||||
no manual per-slot override in config.ini."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {}
|
||||
profile = b._effective_slot_profile(0, "GEEETECH PLA Bas")
|
||||
assert profile.get("name") == "Geeetech PLA Basic"
|
||||
assert profile.get("vendor") == "Geeetech"
|
||||
|
||||
|
||||
def test_effective_slot_profile_manual_override_still_wins():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: {"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic"}}
|
||||
profile = b._effective_slot_profile(0, "GEEETECH PLA Bas")
|
||||
assert profile.get("name") == "Generic PLA"
|
||||
|
||||
|
||||
def test_effective_slot_profile_plain_type_no_override_returns_empty():
|
||||
"""Regression guard: a plain type="PLA" slot with no override must still
|
||||
fall through to {} (the caller's own generic-name fallback), not be
|
||||
treated as an RFID string."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {}
|
||||
assert b._effective_slot_profile(0, "PLA") == {}
|
||||
|
||||
|
||||
def _multicolor_box_report(raw_type: str, color=(238, 190, 152)) -> dict:
|
||||
"""A realistic multiColorBox/report payload for one ACE box (id=0) with
|
||||
a toolhead (id=-1), matching the real Kobra X topology captured live
|
||||
during Issue #100/#101 investigation - drives _detect_filament_mode()
|
||||
to "ace_hub", same as on real hardware."""
|
||||
return {
|
||||
"state": "success",
|
||||
"data": {
|
||||
"head_tools_model": 1,
|
||||
"multi_color_box": [
|
||||
{
|
||||
"id": -1, "loaded_slot": -1,
|
||||
"slots": [{"index": 0, "status": 0, "type": "", "color": [0, 0, 0]}],
|
||||
},
|
||||
{
|
||||
"id": 0, "loaded_slot": -1,
|
||||
"slots": [
|
||||
{"index": 0, "status": 0, "type": "", "color": [0, 0, 0]},
|
||||
{"index": 1, "status": 0, "type": "", "color": [0, 0, 0]},
|
||||
{
|
||||
"index": 2, "status": 5, "type": raw_type,
|
||||
"color": list(color), "sku": "",
|
||||
},
|
||||
{"index": 3, "status": 0, "type": "", "color": [0, 0, 0]},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_on_multicolor_box_end_to_end_resolves_rfid_slot_for_dashboard():
|
||||
"""End-to-end regression test for Issue #101: feed a raw MQTT
|
||||
multiColorBox/report payload through the real receive path
|
||||
(_on_multicolor_box), then check that the dashboard-facing
|
||||
/kx/filament/slots data (handle_kx_filament_slots) - which is what
|
||||
populates the dashboard's window._slotProfileMap in the browser -
|
||||
actually reflects the matched profile, not the raw "GEEETECH PLA Bas"
|
||||
string. This is the exact path that was broken and untested before."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {}
|
||||
b._on_multicolor_box(_multicolor_box_report("GEEETECH PLA Bas"))
|
||||
|
||||
assert b._filament_mode == "ace_hub"
|
||||
# global_index 6 = box_id 0 * 4 + local slot 2 in ace_hub mode's ACE block
|
||||
slot = next(s for s in b._ams_slots if s.get("type") == "GEEETECH PLA Bas")
|
||||
global_idx = slot["global_index"]
|
||||
|
||||
profile = b._effective_slot_profile(global_idx, slot["type"])
|
||||
assert profile.get("name") == "Geeetech PLA Basic"
|
||||
assert profile.get("vendor") == "Geeetech"
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_disambiguates_via_variant_tokens():
|
||||
"""Issue #101 follow-up (@Blaim): two profiles of the same vendor+family
|
||||
("Geeetech PLA Basic" vs. "Geeetech PLA Matte") must resolve to the one
|
||||
matching the RFID string's truncated variant token ("bas" -> Basic),
|
||||
not just "whichever loads first"."""
|
||||
profiles = USER_PROFILES + [
|
||||
{"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
|
||||
]
|
||||
b = _bridge(profiles)
|
||||
|
||||
basic = b._match_profile_by_vendor_family("Geeetech", "PLA", ["bas"])
|
||||
assert basic.get("name") == "Geeetech PLA Basic"
|
||||
|
||||
matte = b._match_profile_by_vendor_family("Geeetech", "PLA", ["mat"])
|
||||
assert matte.get("name") == "Geeetech PLA Matte"
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_no_variant_tokens_falls_back_to_first():
|
||||
profiles = USER_PROFILES + [
|
||||
{"id": "GTPLA03", "name": "Geeetech PLA Matte", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
|
||||
]
|
||||
b = _bridge(profiles)
|
||||
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
|
||||
assert profile.get("name") == "Geeetech PLA Basic"
|
||||
|
||||
|
||||
def test_rfid_variant_tokens_extracts_tokens_after_vendor_and_family():
|
||||
assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA Bas") == ["bas"]
|
||||
assert KobraXBridge._rfid_variant_tokens("GEEETECH PLA") == []
|
||||
assert KobraXBridge._rfid_variant_tokens("PLA") == []
|
||||
85
tests/test_auto_ams_box_mapping_empty_slot.py
Normal file
85
tests/test_auto_ams_box_mapping_empty_slot.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path.
|
||||
|
||||
Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it
|
||||
EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping`
|
||||
inserts a positional placeholder at each gap whose `ams_index` points at the
|
||||
gap's own (physically EMPTY) tray. The printer rejects a mapping entry that
|
||||
references an empty tray, even for a tool the GCode never calls.
|
||||
|
||||
Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray.
|
||||
Positional alignment (entry N = TN, from a16062f) must be preserved.
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3).
|
||||
AMS_SLOTS = [
|
||||
{"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]},
|
||||
{"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]},
|
||||
{"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY
|
||||
{"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]},
|
||||
]
|
||||
|
||||
|
||||
def _bridge(slots=AMS_SLOTS):
|
||||
c = MagicMock(); c.callbacks = {}; c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxauto-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._filament_mode = "toolhead"
|
||||
b._ams_slots = [dict(s) for s in slots]
|
||||
return b
|
||||
|
||||
|
||||
def _loaded_ams_indices(b):
|
||||
return {b._slot_to_print_ams_index(int(s["global_index"]))
|
||||
for s in b._ams_slots if s["status"] == 5}
|
||||
|
||||
|
||||
def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded():
|
||||
"""_start_print filters loaded to the used slot only: loaded=[(3, slot3)].
|
||||
Positions 0..2 are placeholders and must NOT reference empty tray idx 2."""
|
||||
b = _bridge()
|
||||
loaded = [(3, b._ams_slots[3])]
|
||||
|
||||
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
|
||||
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept
|
||||
loaded_ams = _loaded_ams_indices(b)
|
||||
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
|
||||
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
|
||||
|
||||
|
||||
def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set():
|
||||
"""All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at
|
||||
position 2 must not reference the empty tray idx 2."""
|
||||
b = _bridge()
|
||||
loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])]
|
||||
|
||||
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
|
||||
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3]
|
||||
loaded_ams = _loaded_ams_indices(b)
|
||||
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
|
||||
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
|
||||
# Real loaded slots keep their own ams_index.
|
||||
assert mapping[0]["ams_index"] == 0
|
||||
assert mapping[1]["ams_index"] == 1
|
||||
assert mapping[3]["ams_index"] == 3
|
||||
|
||||
|
||||
def test_all_full_is_unchanged():
|
||||
"""All slots loaded -> no placeholders, identity mapping (the working case)."""
|
||||
slots = [dict(s, status=5) for s in AMS_SLOTS]
|
||||
b = _bridge(slots)
|
||||
loaded = [(i, b._ams_slots[i]) for i in range(4)]
|
||||
|
||||
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
|
||||
assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3]
|
||||
111
tests/test_buried_report.py
Normal file
111
tests/test_buried_report.py
Normal 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
|
||||
51
tests/test_camera_cache_url_rotation.py
Normal file
51
tests/test_camera_cache_url_rotation.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Camera stream hangs forever after printer reboot (Issue #99).
|
||||
|
||||
The printer rotates its stream token on reboot, changing the camera URL.
|
||||
CameraCache.set_url() used to be a bare assignment - the running ffmpeg loops
|
||||
never noticed since they only re-read self._url at the top of their outer
|
||||
loop, which they never reach while permanently blocked reading stdout from
|
||||
the now-silent, stale-token connection. set_url() must detect the change and
|
||||
tear the loops down so the next ensure_running() respawns them against the
|
||||
new URL.
|
||||
"""
|
||||
from kobrax_moonraker_bridge import CameraCache
|
||||
|
||||
|
||||
def test_set_url_first_time_does_not_reset():
|
||||
"""No prior URL - nothing stale to tear down."""
|
||||
c = CameraCache()
|
||||
calls = []
|
||||
c.reset = lambda: calls.append(True)
|
||||
c.set_url("http://printer/live/tokenA")
|
||||
assert c._url == "http://printer/live/tokenA"
|
||||
assert not calls
|
||||
|
||||
|
||||
def test_set_url_same_value_does_not_reset():
|
||||
c = CameraCache()
|
||||
calls = []
|
||||
c.set_url("http://printer/live/tokenA")
|
||||
c.reset = lambda: calls.append(True)
|
||||
c.set_url("http://printer/live/tokenA")
|
||||
assert not calls
|
||||
|
||||
|
||||
def test_set_url_changed_triggers_reset():
|
||||
"""The actual bug scenario: token rotates after a printer reboot."""
|
||||
c = CameraCache()
|
||||
calls = []
|
||||
c.set_url("http://printer/live/tokenA")
|
||||
c.reset = lambda: calls.append(True)
|
||||
c.set_url("http://printer/live/tokenB")
|
||||
assert c._url == "http://printer/live/tokenB"
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_set_url_empty_to_value_does_not_reset():
|
||||
"""Startup case: no URL known yet, first status push sets it - nothing
|
||||
running to tear down."""
|
||||
c = CameraCache()
|
||||
calls = []
|
||||
c.reset = lambda: calls.append(True)
|
||||
c.set_url("http://printer/live/tokenA")
|
||||
assert not calls
|
||||
83
tests/test_camera_process_race.py
Normal file
83
tests/test_camera_process_race.py
Normal 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
|
||||
189
tests/test_client_robustness.py
Normal file
189
tests/test_client_robustness.py
Normal 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()
|
||||
66
tests/test_config_loader_robustness.py
Normal file
66
tests/test_config_loader_robustness.py
Normal 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
|
||||
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
|
||||
151
tests/test_metadata_and_print_state_reset.py
Normal file
151
tests/test_metadata_and_print_state_reset.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""server/files/metadata state-leak + terminal-state reset gaps (Issue #102).
|
||||
|
||||
Reported by @fmontagna via moonraker-obico:
|
||||
1. Querying metadata for a file OTHER than the currently/last tracked job
|
||||
leaked that job's live layer count / estimated time into the response,
|
||||
because _build_file_metadata() read from self._state first and only fell
|
||||
back to the file's own GCodeStore row when the state value was falsy.
|
||||
2. curr_layer/total_layers (and, for a successful "finished" print, every
|
||||
other per-job field) were never reset at print end - they stayed at the
|
||||
last job's values until the next print happened to overwrite them.
|
||||
3. The printer reports its own "progress" during pre-print phases
|
||||
(preheating/auto_leveling/checking/...), which used to pass straight
|
||||
through to display_status.progress/virtual_sdcard.progress and then jump
|
||||
non-monotonically once real printing started and progress reset.
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
|
||||
def _bridge():
|
||||
c = MagicMock(); c.callbacks = {}; c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxmeta-"),
|
||||
)
|
||||
return KobraXBridge(c, args=args)
|
||||
|
||||
|
||||
def _insert_store_row(b, filename, layer_count=None, est_time=None, size_bytes=0):
|
||||
with b._store._lock:
|
||||
b._store._conn.execute(
|
||||
"INSERT OR REPLACE INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(filename, filename, "/tmp/" + filename, size_bytes, "2026-01-01T00:00:00Z", layer_count, est_time),
|
||||
)
|
||||
b._store._conn.commit()
|
||||
|
||||
|
||||
# --- Fix 1: metadata state-leak -------------------------------------------
|
||||
|
||||
def test_metadata_for_untracked_file_does_not_leak_running_job_state():
|
||||
b = _bridge()
|
||||
# A job is "running": live state has its own layer/time values.
|
||||
b._state["filename"] = "running.gcode"
|
||||
b._state["total_layers"] = 999
|
||||
b._state["slicer_time"] = 12345
|
||||
b._state["layer_height"] = 0.3
|
||||
|
||||
# Querying a DIFFERENT, unrelated file must use ITS OWN store row, not
|
||||
# the running job's live state.
|
||||
_insert_store_row(b, "other.gcode", layer_count=42, est_time=600, size_bytes=1000)
|
||||
meta = b._build_file_metadata("other.gcode")
|
||||
assert meta["layer_count"] == 42
|
||||
assert meta["estimated_time"] == 600
|
||||
assert meta["size"] == 1000
|
||||
|
||||
|
||||
def test_metadata_for_tracked_file_still_uses_live_state():
|
||||
"""The currently-tracked file's OWN metadata query should still prefer
|
||||
live state (fresher than what was known at upload time)."""
|
||||
b = _bridge()
|
||||
b._state["filename"] = "running.gcode"
|
||||
b._state["total_layers"] = 55
|
||||
b._state["slicer_time"] = 999
|
||||
_insert_store_row(b, "running.gcode", layer_count=1, est_time=1)
|
||||
meta = b._build_file_metadata("running.gcode")
|
||||
assert meta["layer_count"] == 55
|
||||
assert meta["estimated_time"] == 999
|
||||
|
||||
|
||||
def test_metadata_for_nonexistent_file_falls_back_cleanly():
|
||||
b = _bridge()
|
||||
b._state["filename"] = "running.gcode"
|
||||
b._state["total_layers"] = 999
|
||||
meta = b._build_file_metadata("DOES_NOT_EXIST.gcode")
|
||||
assert meta["layer_count"] is None
|
||||
assert meta["size"] == 1 # documented fallback, unrelated to this fix
|
||||
|
||||
|
||||
# --- Fix 2: terminal-state reset -------------------------------------------
|
||||
|
||||
def _print_payload(state, **extra):
|
||||
"""print/report envelope: `state` is top-level, everything else is under
|
||||
`data` (see _on_print: kobra_state = payload.get("state", "")). """
|
||||
d = {"filename": "job.gcode"}
|
||||
d.update(extra)
|
||||
return {"state": state, "data": d}
|
||||
|
||||
|
||||
def test_finished_resets_layer_fields_like_stoped_canceled():
|
||||
b = _bridge()
|
||||
b._state["curr_layer"] = 10
|
||||
b._state["total_layers"] = 20
|
||||
b._state["progress"] = 0.5
|
||||
b._state["filename"] = "job.gcode"
|
||||
b._on_print(_print_payload("finished"))
|
||||
assert b._state["curr_layer"] == 0
|
||||
assert b._state["total_layers"] == 0
|
||||
assert b._state["progress"] == 0.0
|
||||
assert b._state["filename"] == ""
|
||||
|
||||
|
||||
def test_canceled_resets_layer_fields():
|
||||
b = _bridge()
|
||||
b._state["curr_layer"] = 7
|
||||
b._state["total_layers"] = 20
|
||||
b._on_print(_print_payload("canceled"))
|
||||
assert b._state["curr_layer"] == 0
|
||||
assert b._state["total_layers"] == 0
|
||||
|
||||
|
||||
def test_on_info_resets_layer_fields_on_terminal_state():
|
||||
b = _bridge()
|
||||
b._state["curr_layer"] = 7
|
||||
b._state["total_layers"] = 20
|
||||
b._on_info({"data": {"project": {"state": "finished"}}})
|
||||
assert b._state["curr_layer"] == 0
|
||||
assert b._state["total_layers"] == 0
|
||||
|
||||
|
||||
# --- Fix 3: progress clamping during pre-print phases ----------------------
|
||||
|
||||
def test_progress_not_updated_during_auto_leveling():
|
||||
b = _bridge()
|
||||
b._state["progress"] = 0.0
|
||||
b._on_print(_print_payload("auto_leveling", progress=87))
|
||||
assert b._state["progress"] == 0.0
|
||||
|
||||
|
||||
def test_progress_not_updated_during_preheating():
|
||||
b = _bridge()
|
||||
b._state["progress"] = 0.0
|
||||
b._on_print(_print_payload("preheating", progress=42))
|
||||
assert b._state["progress"] == 0.0
|
||||
|
||||
|
||||
def test_progress_updates_normally_once_printing():
|
||||
b = _bridge()
|
||||
b._on_print(_print_payload("printing", progress=33))
|
||||
assert b._state["progress"] == 0.33
|
||||
|
||||
|
||||
def test_on_info_progress_clamped_during_checking():
|
||||
b = _bridge()
|
||||
b._state["progress"] = 0.0
|
||||
b._on_info({"data": {"project": {"state": "checking", "progress": 55}}})
|
||||
assert b._state["progress"] == 0.0
|
||||
152
tests/test_mqtt_reconnect.py
Normal file
152
tests/test_mqtt_reconnect.py
Normal 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
|
||||
117
tests/test_multi_ace_slots.py
Normal file
117
tests/test_multi_ace_slots.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Multi-ACE aggregation in ace_direct mode (Issue #95, Kobra S1).
|
||||
|
||||
A Kobra S1 with two daisy-chained ACE Pro units reports
|
||||
multi_color_box = [{id:0, slots:[4]}, {id:1, slots:[4]}] with NO toolhead
|
||||
entry (id:-1). The old ace_direct branch kept only ace_boxes[0], silently
|
||||
dropping the second unit — the dashboard and the OrcaSlicer sync only ever
|
||||
saw 4 of the 8 slots. Payloads below are trimmed from the real log attached
|
||||
to the issue.
|
||||
"""
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
|
||||
def _slot(index, type_="PLA", color=(1, 2, 3), status=5):
|
||||
return {
|
||||
"index": index, "sku": "", "type": type_, "color": list(color),
|
||||
"edit_status": 0, "status": status,
|
||||
"color_group": [list(color) + [255]], "icon_type": 0,
|
||||
"consumables_percent": 50,
|
||||
}
|
||||
|
||||
|
||||
def _ace_box(box_id, loaded_slot=-1, n_slots=4):
|
||||
return {
|
||||
"id": box_id, "status": 1, "model_id": 0, "auto_feed": 1,
|
||||
"loaded_slot": loaded_slot,
|
||||
"feed_status": {"code": 200, "type": -1, "current_status": -1, "slot_index": -1},
|
||||
"temp": 30, "humidity": 0,
|
||||
"drying_status": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
|
||||
"slots": [_slot(i) for i in range(n_slots)],
|
||||
}
|
||||
|
||||
|
||||
def _toolhead_box(n_slots=4):
|
||||
box = _ace_box(-1, n_slots=n_slots)
|
||||
return box
|
||||
|
||||
|
||||
# ── mode detection ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_two_ace_units_no_toolhead_is_ace_direct():
|
||||
boxes = [_ace_box(0), _ace_box(1)]
|
||||
assert KobraXBridge._detect_filament_mode(boxes) == "ace_direct"
|
||||
|
||||
|
||||
# ── ace_direct aggregation ───────────────────────────────────────────────────
|
||||
|
||||
def test_single_ace_unit_yields_4_slots():
|
||||
"""Kobra X regression: one unit, global indices 0-3 exactly as before."""
|
||||
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0)], "ace_direct")
|
||||
assert len(slots) == 4
|
||||
assert [s["global_index"] for s in slots] == [0, 1, 2, 3]
|
||||
assert all(s["box_id"] == 0 for s in slots)
|
||||
assert loaded == -1
|
||||
|
||||
|
||||
def test_two_ace_units_yield_8_slots():
|
||||
"""Issue #95: the second unit's slots must appear as global 4-7."""
|
||||
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
|
||||
assert len(slots) == 8
|
||||
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
|
||||
|
||||
|
||||
def test_two_ace_units_report_order_does_not_matter():
|
||||
"""Boxes sorted by id — global numbering stays stable if the firmware
|
||||
reports unit 1 before unit 0."""
|
||||
slots, _ = KobraXBridge._aggregate_slots([_ace_box(1), _ace_box(0)], "ace_direct")
|
||||
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
|
||||
|
||||
|
||||
def test_loaded_slot_on_second_unit_maps_to_global():
|
||||
slots, loaded = KobraXBridge._aggregate_slots(
|
||||
[_ace_box(0), _ace_box(1, loaded_slot=2)], "ace_direct")
|
||||
assert loaded == 6 # 1*4 + 2
|
||||
|
||||
|
||||
def test_loaded_slot_on_first_unit_unchanged():
|
||||
slots, loaded = KobraXBridge._aggregate_slots(
|
||||
[_ace_box(0, loaded_slot=3), _ace_box(1)], "ace_direct")
|
||||
assert loaded == 3
|
||||
|
||||
|
||||
# ── ace_hub regression (unchanged behavior) ──────────────────────────────────
|
||||
|
||||
def test_ace_hub_numbering_unchanged():
|
||||
boxes = [_toolhead_box(), _ace_box(0), _ace_box(1)]
|
||||
assert KobraXBridge._detect_filament_mode(boxes) == "ace_hub"
|
||||
slots, _ = KobraXBridge._aggregate_slots(boxes, "ace_hub")
|
||||
# 3 toolhead + 4 + 4 ACE
|
||||
assert len(slots) == 11
|
||||
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
assert [s["box_id"] for s in slots][:3] == [-1, -1, -1]
|
||||
|
||||
|
||||
# ── _box_local_to_global / _global_to_box_slot round-trip ────────────────────
|
||||
|
||||
def _bridge_with_mode(mode, slots):
|
||||
b = object.__new__(KobraXBridge)
|
||||
b._filament_mode = mode
|
||||
b._ams_slots = slots
|
||||
return b
|
||||
|
||||
|
||||
def test_box_local_to_global_ace_direct_second_unit():
|
||||
b = _bridge_with_mode("ace_direct", [])
|
||||
assert b._box_local_to_global(0, 2, []) == 2
|
||||
assert b._box_local_to_global(1, 2, []) == 6
|
||||
|
||||
|
||||
def test_global_to_box_slot_round_trip_two_units():
|
||||
slots, _ = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
|
||||
b = _bridge_with_mode("ace_direct", slots)
|
||||
for g in range(8):
|
||||
box_id, local = b._global_to_box_slot(g)
|
||||
assert (box_id, local) == (g // 4, g % 4)
|
||||
assert b._box_local_to_global(box_id, local, []) == g
|
||||
81
tests/test_multicolor_box_failed_state.py
Normal file
81
tests/test_multicolor_box_failed_state.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""AttributeError crash on multiColorBox/report failure (Issue #100).
|
||||
|
||||
Real KX2-Pro bug: manually assigning a filament profile to an ACE slot
|
||||
(custom-RFID / third-party filament) gets rejected by the printer. Instead of
|
||||
echoing the normal success shape, the printer replies with `state: "failed"`
|
||||
and `data` as a 2-element LIST (`["multi_color_box", [...]]`) instead of the
|
||||
usual dict (`{"multi_color_box": [...]}`). `_on_multicolor_box` called
|
||||
`data.get(...)` unconditionally, crashing with
|
||||
`AttributeError: 'list' object has no attribute 'get'` and silently dropping
|
||||
the report (including the slot-state update it would otherwise have done).
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
# Exact failure payload from the Issue #100 log.
|
||||
FAILED_PAYLOAD = {
|
||||
"state": "failed",
|
||||
"data": ["multi_color_box", [{"filaments": {"id": 2}, "id": 0}]],
|
||||
}
|
||||
|
||||
SUCCESS_PAYLOAD = {
|
||||
"state": "success",
|
||||
"data": {
|
||||
"head_tools_model": 1,
|
||||
"multi_color_box": [
|
||||
{"id": -1, "slots": []},
|
||||
{
|
||||
"id": 0,
|
||||
"slots": [
|
||||
{"index": 0, "type": "PLA", "color": [0, 156, 189], "status": 5},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _bridge():
|
||||
c = MagicMock(); c.callbacks = {}; c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxmcb-"),
|
||||
)
|
||||
return KobraXBridge(c, args=args)
|
||||
|
||||
|
||||
def test_failed_state_does_not_crash():
|
||||
b = _bridge()
|
||||
b._on_multicolor_box(FAILED_PAYLOAD) # must not raise
|
||||
assert b._state["last_ams_set_error"] is True
|
||||
|
||||
|
||||
def test_success_after_failure_clears_error_flag():
|
||||
b = _bridge()
|
||||
b._on_multicolor_box(FAILED_PAYLOAD)
|
||||
assert b._state["last_ams_set_error"] is True
|
||||
b._on_multicolor_box(SUCCESS_PAYLOAD)
|
||||
assert b._state["last_ams_set_error"] is False
|
||||
|
||||
|
||||
def test_non_dict_data_without_failed_state_does_not_crash():
|
||||
"""Defensive guard: any future non-dict `data` shape must not crash,
|
||||
even if the printer doesn't set state="failed" for it."""
|
||||
b = _bridge()
|
||||
b._on_multicolor_box({"state": "success", "data": ["multi_color_box", []]})
|
||||
|
||||
|
||||
def test_failed_report_is_logged_with_the_triggering_request(caplog):
|
||||
"""The failure payload alone carries no slot/type/color info - the log
|
||||
must correlate it with the setInfo request that triggered it, otherwise
|
||||
the failure reason can't be diagnosed from bridge logs alone."""
|
||||
import logging
|
||||
b = _bridge()
|
||||
b._last_ams_set_request = {"global": 6, "box": 0, "local_slot": 3, "type": "PLA", "color": [33, 39, 33]}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
b._on_multicolor_box(FAILED_PAYLOAD)
|
||||
assert any("global" in r.message and "6" in r.message for r in caplog.records)
|
||||
182
tests/test_orca_filaments_parser.py
Normal file
182
tests/test_orca_filaments_parser.py
Normal 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
|
||||
96
tests/test_poll_loop_reconnect.py
Normal file
96
tests/test_poll_loop_reconnect.py
Normal 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()
|
||||
233
tests/test_printer_files_endpoint.py
Normal file
233
tests/test_printer_files_endpoint.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""
|
||||
Tests für /kx/printer-files (list) und /kx/printer-files/delete —
|
||||
der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt
|
||||
(via MQTT file/listLocal + file/deleteBatch, live gegen den echten
|
||||
Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md).
|
||||
|
||||
Important: publish()'s own return value for these actions is just a
|
||||
generic immediate ACK skeleton (code=0, empty fields) - the real answer
|
||||
arrives asynchronously via the file/report callback (_on_file), same as
|
||||
the existing fileDetails fire-and-forget pattern. So publish() itself
|
||||
returns None/skeleton here, and the "real" response is delivered by
|
||||
firing bridge._on_file(...) from a background thread, simulating what
|
||||
the MQTT reader thread would do when the printer's file/report arrives.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
LISTLOCAL_SUCCESS = {
|
||||
"action": "listLocal",
|
||||
"code": 200,
|
||||
"state": "success",
|
||||
"data": {
|
||||
"list_mode": 0,
|
||||
"records": [
|
||||
{"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000},
|
||||
{"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000},
|
||||
{"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
LISTLOCAL_FAILED = {
|
||||
"action": "listLocal",
|
||||
"code": 10112,
|
||||
"state": "failed",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
DELETEBATCH_SUCCESS = {
|
||||
"action": "deleteBatch",
|
||||
"code": 200,
|
||||
"state": "success",
|
||||
"data": None,
|
||||
"msg": "done",
|
||||
}
|
||||
|
||||
DELETEBATCH_FAILED = {
|
||||
"action": "deleteBatch",
|
||||
"code": 10112,
|
||||
"state": "failed",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
FILEDETAILS_SUCCESS = {
|
||||
"action": "fileDetails",
|
||||
"code": 200,
|
||||
"state": "done",
|
||||
"data": {
|
||||
"file_details": {
|
||||
"thumbnail": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB",
|
||||
"png_image": "",
|
||||
"svg_image": "",
|
||||
"objects_skip_parts": [],
|
||||
},
|
||||
"filename": "a.gcode",
|
||||
"root": "local",
|
||||
},
|
||||
}
|
||||
|
||||
FILEDETAILS_NO_THUMBNAIL = {
|
||||
"action": "fileDetails",
|
||||
"code": 200,
|
||||
"state": "done",
|
||||
"data": {
|
||||
"file_details": {"thumbnail": "", "png_image": "", "svg_image": "", "objects_skip_parts": []},
|
||||
"filename": "a.gcode",
|
||||
"root": "local",
|
||||
},
|
||||
}
|
||||
|
||||
FILEDETAILS_FAILED = {
|
||||
"action": "fileDetails",
|
||||
"code": 10112,
|
||||
"state": "failed",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
def _deliver_async(bridge, payload, delay=0.05):
|
||||
"""Simulates the MQTT reader thread delivering a file/report a moment
|
||||
after the fire-and-forget publish() call returns."""
|
||||
def _fire():
|
||||
time.sleep(delay)
|
||||
bridge._on_file(payload)
|
||||
threading.Thread(target=_fire, daemon=True).start()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_lists_files_and_excludes_dirs(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
|
||||
resp = await c.get("/kx/printer-files")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
filenames = [f["filename"] for f in data["result"]]
|
||||
assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_uses_correct_mqtt_payload(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
|
||||
await c.get("/kx/printer-files")
|
||||
args, kwargs = bridge.client.publish.call_args
|
||||
assert args[0] == "file"
|
||||
assert args[1] == "listLocal"
|
||||
assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_returns_502_on_printer_failure(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1]
|
||||
resp = await c.get("/kx/printer-files")
|
||||
assert resp.status == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_returns_502_on_timeout(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.return_value = None # no file/report ever arrives
|
||||
resp = await c.get("/kx/printer-files")
|
||||
assert resp.status == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_success(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
|
||||
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["result"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_uses_correct_mqtt_payload(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
|
||||
await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]})
|
||||
args, kwargs = bridge.client.publish.call_args
|
||||
assert args[0] == "file"
|
||||
assert args[1] == "deleteBatch"
|
||||
assert args[2] == {
|
||||
"root": "local",
|
||||
"files": [
|
||||
{"path": "/", "filename": "a.gcode"},
|
||||
{"path": "/", "filename": "b.gcode"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_empty_filenames_returns_400(client):
|
||||
c, _ = client
|
||||
resp = await c.post("/kx/printer-files/delete", json={"filenames": []})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_returns_502_on_printer_rejection(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1]
|
||||
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
|
||||
assert resp.status == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_thumbnail_success(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
|
||||
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_thumbnail_uses_correct_mqtt_payload(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
|
||||
await c.get("/kx/printer-files/a.gcode/thumbnail")
|
||||
args, kwargs = bridge.client.publish.call_args
|
||||
assert args[0] == "file"
|
||||
assert args[1] == "fileDetails"
|
||||
assert args[2] == {"root": "local", "filename": "a.gcode"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_thumbnail_empty_when_no_thumbnail_embedded(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_NO_THUMBNAIL), None)[1]
|
||||
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["result"]["thumbnail"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_thumbnail_returns_502_on_printer_failure(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_FAILED), None)[1]
|
||||
resp = await c.get("/kx/printer-files/a.gcode/thumbnail")
|
||||
assert resp.status == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_thumbnail_is_cached_after_first_fetch(client):
|
||||
"""Second request for the same filename must not call publish() again."""
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, FILEDETAILS_SUCCESS), None)[1]
|
||||
resp1 = await c.get("/kx/printer-files/a.gcode/thumbnail")
|
||||
assert resp1.status == 200
|
||||
call_count_after_first = bridge.client.publish.call_count
|
||||
|
||||
resp2 = await c.get("/kx/printer-files/a.gcode/thumbnail")
|
||||
assert resp2.status == 200
|
||||
data2 = await resp2.json()
|
||||
assert data2["result"]["thumbnail"] == "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||
assert bridge.client.publish.call_count == call_count_after_first # no new MQTT call
|
||||
175
tests/test_printer_power.py
Normal file
175
tests/test_printer_power.py
Normal 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"
|
||||
@ -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)."""
|
||||
|
||||
87
tests/test_spoolman_unreported.py
Normal file
87
tests/test_spoolman_unreported.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Regression test: Spoolman fallback split silently deducted filament from
|
||||
spools not used in the current print.
|
||||
|
||||
_spoolman_unreported() falls back to an equal split across every mapped
|
||||
AMS slot when per-slot attribution data (_spoolman_slot_usage) is still
|
||||
empty - previously true right after print start, because
|
||||
_spoolman_sync_midprint() ran (in the poll loop) before the first
|
||||
_spoolman_attribute_tick() had a chance to populate any per-slot data,
|
||||
since _spoolman_last_sync was reset to 0.0 (== "due immediately") instead
|
||||
of the current time. With 4 spools mapped, that meant the full
|
||||
(possibly already nonzero) supplies_usage got reported equally to all 4
|
||||
spools regardless of which one was actually printing with.
|
||||
|
||||
The fix: only fall back to a full-credit report when there is exactly
|
||||
one mapped slot (single-extruder, no ambiguity possible) - otherwise
|
||||
report nothing until real attribution data exists. Additionally,
|
||||
_on_print() now resets _spoolman_last_sync to the current time (not 0.0)
|
||||
at print start, so the first mid-print sync check is due only after a
|
||||
real sync_rate interval has passed.
|
||||
"""
|
||||
import time
|
||||
|
||||
|
||||
def _configure(bridge, slot_spools):
|
||||
bridge._spoolman_slot_spools = dict(slot_spools)
|
||||
bridge._spoolman_slot_usage = {}
|
||||
bridge._spoolman_slot_reported = {}
|
||||
|
||||
|
||||
async def test_unreported_reports_nothing_for_multiple_slots_without_attribution(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 11, 1: 15, 2: 17, 3: 23})
|
||||
bridge._state["supplies_usage"] = 4369
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_unreported_credits_the_single_slot_without_attribution(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 42})
|
||||
bridge._state["supplies_usage"] = 500
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
assert result == {0: 500}
|
||||
|
||||
|
||||
async def test_unreported_single_slot_subtracts_already_reported(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 42})
|
||||
bridge._spoolman_slot_reported = {0: 200.0}
|
||||
bridge._state["supplies_usage"] = 500
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
assert result == {0: 300.0}
|
||||
|
||||
|
||||
async def test_unreported_uses_real_attribution_once_available(client):
|
||||
_, bridge = client
|
||||
_configure(bridge, {0: 11, 1: 15})
|
||||
bridge._spoolman_slot_usage = {0: 120.0}
|
||||
bridge._state["supplies_usage"] = 120
|
||||
|
||||
result = bridge._spoolman_unreported()
|
||||
|
||||
# Only the slot that was actually attributed usage gets a nonzero report;
|
||||
# the untouched slot must not receive any share of it.
|
||||
assert result[0] == 120.0
|
||||
assert result[1] == 0.0
|
||||
|
||||
|
||||
async def test_print_start_resets_last_sync_to_now_not_epoch(client):
|
||||
"""_spoolman_last_sync=0.0 at print start meant the poll loop's mid-print
|
||||
sync check (time.time() - last_sync >= sync_rate) was true on the very
|
||||
first tick, before _spoolman_attribute_tick() ever ran once - triggering
|
||||
the fallback-split bug above immediately after print start, before any
|
||||
real per-slot usage existed yet."""
|
||||
_, bridge = client
|
||||
before = time.time()
|
||||
|
||||
bridge._on_print({"state": "printing", "data": {"filename": "test.gcode"}})
|
||||
|
||||
assert bridge._spoolman_last_sync >= before
|
||||
assert bridge._spoolman_last_sync <= time.time()
|
||||
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
|
||||
100
tests/test_update_check.py
Normal file
100
tests/test_update_check.py
Normal 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)"
|
||||
)
|
||||
161
tools/probe_ams_setinfo.py
Normal file
161
tools/probe_ams_setinfo.py
Normal file
@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manual probe for Issue #100's open remainder: the printer rejects
|
||||
multiColorBox/setInfo for physical ACE-box slots (box id >= 0) while the
|
||||
same call succeeds for the no-ACE/single-slot case (box id == -1).
|
||||
|
||||
Sends a series of setInfo payload variants against ONE real ACE slot and
|
||||
prints the printer's multiColorBox/report response for each, so the
|
||||
accepted schema (if any of these match it) becomes visible by diff.
|
||||
|
||||
DO NOT run this against a printer with a print in progress - AMS slot
|
||||
writes are safe at idle but untested behavior mid-print.
|
||||
|
||||
Usage:
|
||||
python3 tools/probe_ams_setinfo.py <printer_ip> <box_id> <local_slot> <material_type> <r> <g> <b>
|
||||
|
||||
Example (box 0, slot 2, keep existing PLA/color to be non-destructive):
|
||||
python3 tools/probe_ams_setinfo.py 192.168.2.144 0 2 PLA 238 190 152
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bridge"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from kobrax_client import KobraXClient # noqa: E402
|
||||
|
||||
MODE_ID = "20030"
|
||||
|
||||
|
||||
def make_client(host):
|
||||
c = KobraXClient(
|
||||
host=host,
|
||||
username=os.environ.get("PROBE_USER", ""),
|
||||
password=os.environ.get("PROBE_PASS", ""),
|
||||
mode_id=os.environ.get("PROBE_MODE_ID", MODE_ID),
|
||||
device_id=os.environ.get("PROBE_DEVICE_ID", ""),
|
||||
port=9883,
|
||||
client_id="ams-probe",
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
def variant_original(box_id, local_slot, mat, color):
|
||||
return {"multi_color_box": [{"id": box_id, "slots": [{"index": local_slot, "type": mat, "color": color}]}]}
|
||||
|
||||
|
||||
def variant_full_slot_object(box_id, local_slot, mat, color, existing_slot):
|
||||
slot = dict(existing_slot)
|
||||
slot["index"] = local_slot
|
||||
slot["type"] = mat
|
||||
slot["color"] = color
|
||||
slot["color_group"] = [color + [255]]
|
||||
return {"multi_color_box": [{"id": box_id, "slots": [slot]}]}
|
||||
|
||||
|
||||
def variant_filaments_key(box_id, local_slot, mat, color):
|
||||
# Mirrors the shape the printer's OWN rejection echo uses:
|
||||
# {"filaments": {"id": local_slot}, "id": box_id}
|
||||
return {"multi_color_box": [{"id": box_id, "filaments": [{"id": local_slot, "type": mat, "color": color}]}]}
|
||||
|
||||
|
||||
def variant_filaments_singular(box_id, local_slot, mat, color):
|
||||
return {"multi_color_box": [{"id": box_id, "filaments": {"id": local_slot, "type": mat, "color": color}}]}
|
||||
|
||||
|
||||
def _extract_slot(payload, box_id, local_slot):
|
||||
"""multiColorBox/report's data comes as either a dict ({"multi_color_box": [...]})
|
||||
or, on rejection, a bare 2-element list (["multi_color_box", [...]]) - see
|
||||
Issue #100. Handle both so the probe doesn't silently miss a rejection reply."""
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
boxes = data.get("multi_color_box") or []
|
||||
elif isinstance(data, list) and len(data) == 2 and data[0] == "multi_color_box":
|
||||
boxes = data[1] or []
|
||||
else:
|
||||
return None
|
||||
for box in boxes:
|
||||
if int(box.get("id", -999)) == box_id:
|
||||
slots = box.get("slots") or []
|
||||
if 0 <= local_slot < len(slots):
|
||||
return slots[local_slot]
|
||||
return None
|
||||
|
||||
|
||||
def get_existing_slot(client, box_id, local_slot, timeout=5.0):
|
||||
"""Reads the current slot state via the multiColorBox/report CALLBACK,
|
||||
not publish()'s return value - the generic empty response ACK
|
||||
({"code":0,"data":None}) can arrive under the same msgid and race the
|
||||
real report, silently winning the publish() wait (observed live)."""
|
||||
result = {}
|
||||
done = threading.Event()
|
||||
|
||||
def on_report(payload):
|
||||
slot = _extract_slot(payload, box_id, local_slot)
|
||||
if slot is not None:
|
||||
result["slot"] = slot
|
||||
done.set()
|
||||
|
||||
client.callbacks["multiColorBox/report"] = on_report
|
||||
client.publish_web("multiColorBox", "getInfo", None)
|
||||
done.wait(timeout)
|
||||
client.callbacks.pop("multiColorBox/report", None)
|
||||
return result.get("slot")
|
||||
|
||||
|
||||
def try_variant(client, name, payload, settle=3.0):
|
||||
print(f"\n=== {name} ===")
|
||||
print("TX:", payload)
|
||||
reports = []
|
||||
client.callbacks["multiColorBox/report"] = lambda p: reports.append(p)
|
||||
client.publish_web("multiColorBox", "setInfo", payload)
|
||||
time.sleep(settle)
|
||||
client.callbacks.pop("multiColorBox/report", None)
|
||||
for r in reports:
|
||||
print("RX report:", json.dumps(r, ensure_ascii=False)[:600])
|
||||
return reports
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 8:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
host = sys.argv[1]
|
||||
box_id = int(sys.argv[2])
|
||||
local_slot = int(sys.argv[3])
|
||||
mat = sys.argv[4]
|
||||
color = [int(sys.argv[5]), int(sys.argv[6]), int(sys.argv[7])]
|
||||
|
||||
client = make_client(host)
|
||||
client.connect()
|
||||
time.sleep(1)
|
||||
|
||||
existing = get_existing_slot(client, box_id, local_slot)
|
||||
print("Existing slot state:", existing)
|
||||
if existing is None:
|
||||
print("Could not read existing slot - aborting (don't probe blind).")
|
||||
return
|
||||
|
||||
try_variant(client, "A: original bridge shape (slots/index/type/color)",
|
||||
variant_original(box_id, local_slot, mat, color))
|
||||
|
||||
try_variant(client, "B: full slot object (all fields from getInfo, only type/color changed)",
|
||||
variant_full_slot_object(box_id, local_slot, mat, color, existing))
|
||||
|
||||
try_variant(client, "C: filaments list (mirrors printer's rejection echo shape)",
|
||||
variant_filaments_key(box_id, local_slot, mat, color))
|
||||
|
||||
try_variant(client, "D: filaments singular dict",
|
||||
variant_filaments_singular(box_id, local_slot, mat, color))
|
||||
|
||||
print("\nDone. Compare RX multiColorBox/report lines above/in the bridge log "
|
||||
"for each variant - state=success with the slot's type actually changed "
|
||||
"in the FOLLOWING getInfo/report is the only real confirmation.")
|
||||
client.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -279,6 +279,72 @@ function initPrinters(){
|
||||
renderPrinterDropdown();
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
// ── Header Power Switch (Issue: hard to find + poorly visible in the
|
||||
// Printers tab; mirrors togglePrinterPower()/_refreshPrinterPowerIcon() for
|
||||
// the currently active printer, right next to its name/status in the header
|
||||
// so it works the same in single- and multi-printer setups - switching the
|
||||
// active printer via the dropdown re-evaluates has_power_control for the
|
||||
// newly active one instead of a fixed dashboard card tied to whichever
|
||||
// printer happened to be active on page load) ──
|
||||
// Single button showing the action a click would perform (not the current
|
||||
// state): printer on → button offers "Aus", printer off → button offers
|
||||
// "An". _headerPowerState holds the last known actual state ('on'/'off'/
|
||||
// null=unknown) so toggleHeaderPower() knows which action to send.
|
||||
var _headerPowerState=null;
|
||||
function _initHeaderPower(){
|
||||
var btn=document.getElementById('h-power-btn');
|
||||
if(!btn)return;
|
||||
if(!_activePrinter||!_activePrinter.has_power_control){btn.style.display='none';return;}
|
||||
btn.style.display='';
|
||||
_refreshHeaderPower();
|
||||
}
|
||||
function _applyHeaderPowerState(state){
|
||||
_headerPowerState=state;
|
||||
var btn=document.getElementById('h-power-btn'), lbl=document.getElementById('h-power-lbl');
|
||||
if(!btn||!lbl)return;
|
||||
// Default: show the action a click would perform (printer on -> offer
|
||||
// "Aus"). With power_status_inverted set (Settings -> Power Switch), show
|
||||
// the actual current state instead (printer on -> show "An") - some users
|
||||
// expect the button to mirror reality, not the available action.
|
||||
var inverted=!!(_activePrinter&&_activePrinter.power_status_inverted);
|
||||
var showOn=inverted?state==='on':state==='off';
|
||||
var showOff=inverted?state==='off':state==='on';
|
||||
if(showOff){
|
||||
btn.classList.remove('h-power-can-on');btn.classList.add('h-power-can-off');
|
||||
lbl.textContent=T.printers_power_off_short||'Aus';
|
||||
}else if(showOn){
|
||||
btn.classList.remove('h-power-can-off');btn.classList.add('h-power-can-on');
|
||||
lbl.textContent=T.printers_power_on_short||'An';
|
||||
}else{
|
||||
btn.classList.remove('h-power-can-on','h-power-can-off');
|
||||
lbl.textContent=T.printers_power_on_short||'An';
|
||||
}
|
||||
}
|
||||
function _refreshHeaderPower(){
|
||||
if(!_activePrinter||!_activePrinter.has_power_control)return;
|
||||
var bridgeUrl=(_activePrinter.bridge_url||'').replace(/\/+$/,'');
|
||||
fetch(bridgeUrl+'/kx/printers/'+encodeURIComponent(_activePrinter.id)+'/power-status',{signal:AbortSignal.timeout(5000)})
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){_applyHeaderPowerState(d.state==='on'||d.state==='off'?d.state:null);})
|
||||
.catch(function(){/* status endpoint optional - button just stays neutral */});
|
||||
}
|
||||
function toggleHeaderPower(){
|
||||
if(!_activePrinter)return;
|
||||
// Unknown current state: default to "on" - turning an already-off switch
|
||||
// on is harmless, whereas guessing "off" on a printer mid-print is not.
|
||||
var action=_headerPowerState==='on'?'off':'on';
|
||||
if(action==='off'&&!confirm(T.printers_power_off_confirm||'Turn printer power off? Make sure no print is running.'))return;
|
||||
var btn=document.getElementById('h-power-btn');
|
||||
if(btn)btn.style.opacity='0.5';
|
||||
post('/kx/printers/'+encodeURIComponent(_activePrinter.id)+'/power',{action:action}).then(function(){
|
||||
if(btn)btn.style.opacity='1';
|
||||
setTimeout(_refreshHeaderPower,1500);
|
||||
}).catch(function(e){
|
||||
if(btn)btn.style.opacity='1';
|
||||
clog('Power-Fehler: '+e,'msg-err');
|
||||
});
|
||||
}
|
||||
function renderPrinterDropdown(){
|
||||
var wrap=document.getElementById('printer-dropdown-wrap');
|
||||
var single=document.getElementById('h-pname-single');
|
||||
@ -302,6 +368,7 @@ function renderPrinterDropdown(){
|
||||
if(wrap)wrap.style.display='none';
|
||||
if(single)single.style.display='';
|
||||
}
|
||||
_initHeaderPower();
|
||||
}
|
||||
function togglePrinterDropdown(){
|
||||
var menu=document.getElementById('printer-dropdown-menu');
|
||||
@ -354,6 +421,14 @@ function applyLang(){
|
||||
setText('store-web-verify-msg',T.store_web_verify_msg);
|
||||
setText('store-web-verify-confirm',T.store_web_verify_confirm);
|
||||
setText('store-web-verify-abort',T.store_web_verify_abort);
|
||||
setText('store-lbl-select-all',T.store_select_all||'Select All');
|
||||
setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
|
||||
setText('store-lbl-exit-select',T.store_exit_select||'Cancel');
|
||||
setText('btab-lbl-uploaded',T.browser_tab_uploaded||'Uploaded');
|
||||
setText('btab-lbl-printer',T.browser_tab_printer||'On Printer');
|
||||
setText('printer-store-lbl-select-all',T.store_select_all||'Select All');
|
||||
setText('printer-store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
|
||||
setText('printer-store-lbl-exit-select',T.store_exit_select||'Cancel');
|
||||
// Dashboard card titles
|
||||
setText('d-card-progress',T.card_progress);
|
||||
setText('d-card-temps',T.card_temps);
|
||||
@ -442,6 +517,12 @@ 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-power-status-inverted',T.settings_power_status_inverted||'Show current status instead of action');
|
||||
setText('lbl-default-slot',T.settings_default_slot);
|
||||
setText('opt-slot-auto',T.settings_slot_auto);
|
||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||
@ -451,6 +532,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);
|
||||
|
||||
@ -591,6 +674,17 @@ function showSettingsCat(name){
|
||||
var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active');
|
||||
}
|
||||
|
||||
// Browser-Sub-Tab umschalten: hochgeladene Dateien (Bridge-Store) vs. Dateien
|
||||
// auf dem Drucker selbst (interner Speicher, via listLocal MQTT).
|
||||
var _printerFilesLoaded=false;
|
||||
function showBrowserTab(name){
|
||||
document.querySelectorAll('.browser-group').forEach(g=>g.classList.remove('active'));
|
||||
document.querySelectorAll('.browser-tab').forEach(b=>b.classList.remove('active'));
|
||||
var g=document.getElementById('browser-group-'+name);if(g)g.classList.add('active');
|
||||
var t=document.getElementById('btab-'+name);if(t)t.classList.add('active');
|
||||
if(name==='printer'&&!_printerFilesLoaded)loadPrinterFiles();
|
||||
}
|
||||
|
||||
// ── Console log ──
|
||||
var consoleLogs=[];
|
||||
var logAutoScroll=true;
|
||||
@ -1108,12 +1202,17 @@ 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||'';
|
||||
var pinv=document.getElementById('s-power-status-inverted');if(pinv)pinv.checked=!!d.power_status_inverted;
|
||||
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){
|
||||
@ -1876,12 +1975,17 @@ 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||'',
|
||||
power_status_inverted: (document.getElementById('s-power-status-inverted')||{}).checked?1:0,
|
||||
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||'',
|
||||
@ -1991,6 +2095,7 @@ var pollTimer;
|
||||
}).catch(function(){});
|
||||
poll();pollTimer=setInterval(poll,ms);
|
||||
setInterval(_loadSpoolmanStatus,30000);
|
||||
setInterval(_refreshHeaderPower,30000);
|
||||
// initDashGrid() is called at the very end of the file, after all the
|
||||
// DASH_* var declarations below have executed (they are hoisted but not yet
|
||||
// assigned at this point in the IIFE).
|
||||
@ -2756,10 +2861,42 @@ function aceDryStop(aceId){
|
||||
function loadStore(){
|
||||
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
|
||||
storeFiles=d.result||[];
|
||||
// Reset any pending selection - stale ids from a deleted/renamed file
|
||||
// should never carry over into a fresh file list.
|
||||
storeExitSelectMode();
|
||||
renderStore();
|
||||
}).catch(function(e){clog('Store-Fehler: '+e,'msg-err')});
|
||||
}
|
||||
|
||||
// Applies the store's search/filter/sort controls to storeFiles. Shared by
|
||||
// renderStore() and storeToggleSelectAll() so "Select All" only selects what
|
||||
// the user can currently see, not the entire (possibly filtered-out) list.
|
||||
function _storeFilteredFiles(){
|
||||
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
|
||||
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
|
||||
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
|
||||
|
||||
var files=storeFiles.filter(function(f){
|
||||
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
|
||||
if(filter==='completed'&&f.last_print_status!=='completed') return false;
|
||||
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
|
||||
if(filter==='never'&&f.last_print_status) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
files.sort(function(a,b){
|
||||
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
|
||||
if(sort==='duration_asc'){
|
||||
var da=a.last_print_duration||a.est_print_time_sec||0;
|
||||
var db=b.last_print_duration||b.est_print_time_sec||0;
|
||||
return da-db;
|
||||
}
|
||||
// date_desc (default)
|
||||
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
function uploadGcode(file){
|
||||
if(!file) return;
|
||||
var zone=document.getElementById('store-upload-zone');
|
||||
@ -2804,32 +2941,7 @@ function uploadGcode(file){
|
||||
function renderStore(){
|
||||
var grid=document.getElementById('store-grid');
|
||||
var empty=document.getElementById('store-empty');
|
||||
|
||||
// Suche
|
||||
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
|
||||
// Filter
|
||||
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
|
||||
// Sortierung
|
||||
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
|
||||
|
||||
var files=storeFiles.filter(function(f){
|
||||
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
|
||||
if(filter==='completed'&&f.last_print_status!=='completed') return false;
|
||||
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
|
||||
if(filter==='never'&&f.last_print_status) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
files.sort(function(a,b){
|
||||
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
|
||||
if(sort==='duration_asc'){
|
||||
var da=a.last_print_duration||a.est_print_time_sec||0;
|
||||
var db=b.last_print_duration||b.est_print_time_sec||0;
|
||||
return da-db;
|
||||
}
|
||||
// date_desc (default)
|
||||
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
|
||||
});
|
||||
var files=_storeFilteredFiles();
|
||||
|
||||
if(!storeFiles.length){
|
||||
empty.textContent=T.store_empty;
|
||||
@ -2862,22 +2974,56 @@ function renderStore(){
|
||||
} else if(!f.last_print_status){
|
||||
lastInfo='<div style="font-size:11px;color:var(--txt2);margin-bottom:2px;opacity:.6">'+T.store_never+'</div>';
|
||||
}
|
||||
return '<div style="background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:10px;display:flex;flex-direction:column">'+
|
||||
var isSelected=!!_storeSelected[f.id];
|
||||
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
|
||||
// Always visible (not just once select mode is on) - otherwise there is
|
||||
// no way to ever enter select mode, since it's normally entered by
|
||||
// clicking this very checkbox. White circle behind it so it stays
|
||||
// legible against any thumbnail.
|
||||
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
|
||||
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
|
||||
'align-items:center;justify-content:center">'+
|
||||
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
|
||||
' onclick="event.stopPropagation();storeToggleSelect(\''+f.id+'\')" '+
|
||||
'style="width:16px;height:16px;margin:0"></span>';
|
||||
var cardClick=_storeSelectMode?'onclick="storeToggleSelect(\''+f.id+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
|
||||
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
|
||||
return '<div '+cardClick+'>'+
|
||||
checkbox+
|
||||
thumb+
|
||||
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+statusBadge+'</div>'+
|
||||
lastInfo+
|
||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">⏱ '+T.store_estimate+': '+est+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
|
||||
'<div style="display:flex;gap:6px;margin-top:auto">'+
|
||||
'<button onclick="storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
|
||||
'<button onclick="event.stopPropagation();storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
|
||||
'style="flex:1;font-size:12px;padding:5px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer">'+T.store_print+'</button>'+
|
||||
'<button onclick="storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
|
||||
'<button onclick="event.stopPropagation();storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
|
||||
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">⬇</button>'+
|
||||
'<button onclick="storeDelete(\''+f.id+'\')" '+
|
||||
'<button onclick="event.stopPropagation();storeDelete(\''+f.id+'\')" '+
|
||||
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
_storeUpdateSelectBar(files);
|
||||
}
|
||||
|
||||
// Reflects the current selection onto the select-all checkbox (incl.
|
||||
// indeterminate state), the selected-count label, and the delete button's
|
||||
// disabled state. `files` is the currently-filtered/visible list.
|
||||
function _storeUpdateSelectBar(files){
|
||||
var selectAll=document.getElementById('store-select-all');
|
||||
var countEl=document.getElementById('store-selected-count');
|
||||
var delBtn=document.getElementById('store-delete-selected-btn');
|
||||
if(!selectAll||!countEl||!delBtn)return;
|
||||
var visibleIds=files.map(function(f){return f.id;});
|
||||
var selectedVisible=visibleIds.filter(function(id){return _storeSelected[id];});
|
||||
var n=selectedVisible.length;
|
||||
selectAll.checked=n>0&&n===visibleIds.length;
|
||||
selectAll.indeterminate=n>0&&n<visibleIds.length;
|
||||
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
|
||||
delBtn.disabled=n===0;
|
||||
}
|
||||
|
||||
function formatDur(sec){
|
||||
@ -2897,6 +3043,234 @@ var _pendingWebVerifyAutoOpen=false;
|
||||
// wurde (Issue #29 / Theme-Auslagerung PR #27).
|
||||
var storeFiles=[];
|
||||
|
||||
// Multi-select state for the GCode browser (Issue #94).
|
||||
var _storeSelectMode=false;
|
||||
var _storeSelected={}; // file.id -> true
|
||||
|
||||
function storeToggleSelect(id){
|
||||
if(!_storeSelectMode){
|
||||
_storeSelectMode=true;
|
||||
var bar=document.getElementById('store-select-bar');
|
||||
if(bar)bar.style.display='flex';
|
||||
}
|
||||
if(_storeSelected[id])delete _storeSelected[id];
|
||||
else _storeSelected[id]=true;
|
||||
renderStore();
|
||||
}
|
||||
|
||||
function storeToggleSelectAll(checked){
|
||||
// Only the currently filtered/visible files are affected, so search/filter
|
||||
// never causes "Select All" to silently pick up hidden files too.
|
||||
var visible=_storeFilteredFiles();
|
||||
_storeSelected={};
|
||||
if(checked)visible.forEach(function(f){_storeSelected[f.id]=true;});
|
||||
renderStore();
|
||||
}
|
||||
|
||||
function storeExitSelectMode(){
|
||||
_storeSelectMode=false;
|
||||
_storeSelected={};
|
||||
var bar=document.getElementById('store-select-bar');
|
||||
if(bar)bar.style.display='none';
|
||||
renderStore();
|
||||
}
|
||||
|
||||
function storeDeleteSelected(){
|
||||
var ids=Object.keys(_storeSelected);
|
||||
if(!ids.length)return;
|
||||
if(!confirm((T.store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',ids.length)))return;
|
||||
Promise.all(ids.map(function(id){
|
||||
return fetch(_apiUrl('/kx/files/'+id),{method:'DELETE'}).then(function(r){return{id:id,ok:r.ok};});
|
||||
})).then(function(results){
|
||||
var failed=results.filter(function(r){return !r.ok;});
|
||||
if(failed.length)clog((T.log_delete_failed||'Delete failed')+': '+failed.length,'msg-err');
|
||||
storeExitSelectMode();
|
||||
loadStore();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Files on the printer's own internal storage (Issue: 2nd Browser tab) ──
|
||||
// Uses filename as the identity key (the printer has no numeric file id like
|
||||
// the bridge's own GCodeStore) and a single batch-delete call, since the
|
||||
// printer's file/deleteBatch MQTT action natively accepts a filename list.
|
||||
var printerFiles=[];
|
||||
var _printerFilesSelectMode=false;
|
||||
var _printerFilesSelected={}; // filename -> true
|
||||
|
||||
function loadPrinterFiles(){
|
||||
var errEl=document.getElementById('printer-store-error');
|
||||
if(errEl)errEl.style.display='none';
|
||||
fetch(_apiUrl('/kx/printer-files')).then(function(r){return r.json()}).then(function(d){
|
||||
_printerFilesLoaded=true;
|
||||
if(d.error){
|
||||
printerFiles=[];
|
||||
if(errEl){errEl.textContent=T.printer_store_unreachable||d.error;errEl.style.display='block';}
|
||||
renderPrinterFiles();
|
||||
return;
|
||||
}
|
||||
printerFiles=d.result||[];
|
||||
_printerFilesSelected={};
|
||||
_printerFilesSelectMode=false;
|
||||
var bar=document.getElementById('printer-store-select-bar');
|
||||
if(bar)bar.style.display='none';
|
||||
renderPrinterFiles();
|
||||
}).catch(function(e){
|
||||
_printerFilesLoaded=true;
|
||||
if(errEl){errEl.textContent=T.printer_store_unreachable||String(e);errEl.style.display='block';}
|
||||
});
|
||||
}
|
||||
|
||||
function renderPrinterFiles(){
|
||||
var grid=document.getElementById('printer-store-grid');
|
||||
var empty=document.getElementById('printer-store-empty');
|
||||
if(!grid||!empty)return;
|
||||
if(!printerFiles.length){
|
||||
empty.textContent=T.printer_store_empty||'No files on the printer.';
|
||||
grid.innerHTML='';
|
||||
empty.style.display='block';
|
||||
return;
|
||||
}
|
||||
empty.style.display='none';
|
||||
grid.innerHTML=printerFiles.map(function(f,idx){
|
||||
var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename;
|
||||
var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'–';
|
||||
var date=f.timestamp?new Date(f.timestamp).toISOString().replace('T',' ').slice(0,16):'';
|
||||
var isSelected=!!_printerFilesSelected[f.filename];
|
||||
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
|
||||
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
|
||||
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
|
||||
'align-items:center;justify-content:center">'+
|
||||
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
|
||||
' onclick="event.stopPropagation();printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" '+
|
||||
'style="width:16px;height:16px;margin:0"></span>';
|
||||
var cardClick=_printerFilesSelectMode?'onclick="printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
|
||||
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
|
||||
var thumbId='pft-'+idx;
|
||||
var cachedThumb=_printerThumbCache[f.filename];
|
||||
var thumbHtml=cachedThumb
|
||||
? '<img id="'+thumbId+'" src="data:image/png;base64,'+cachedThumb+'" style="width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px">'
|
||||
: '<div id="'+thumbId+'" data-filename="'+encodeURIComponent(f.filename)+'" style="width:100%;height:60px;background:var(--raised);border-radius:6px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;font-size:28px">🖨</div>';
|
||||
return '<div '+cardClick+'>'+
|
||||
checkbox+
|
||||
thumbHtml+
|
||||
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">💾 '+sizeKb+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
|
||||
'<div style="display:flex;gap:6px;margin-top:auto">'+
|
||||
'<button onclick="event.stopPropagation();printerFileDelete(\''+f.filename.replace(/'/g,"\\'")+'\')" '+
|
||||
'style="flex:1;font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
_printerFilesUpdateSelectBar();
|
||||
_loadVisiblePrinterThumbnails();
|
||||
}
|
||||
|
||||
// Thumbnails are fetched lazily, one at a time, only for cards not already
|
||||
// cached - fetching all of them upfront would mean one MQTT roundtrip per
|
||||
// file (145+ files is common), overwhelming the single MQTT connection.
|
||||
var _printerThumbCache={}; // filename -> base64 PNG string ("" = no thumbnail)
|
||||
var _printerThumbQueue=[];
|
||||
var _printerThumbLoading=false;
|
||||
|
||||
function _loadVisiblePrinterThumbnails(){
|
||||
var placeholders=document.querySelectorAll('#printer-store-grid [data-filename]');
|
||||
_printerThumbQueue=Array.prototype.slice.call(placeholders);
|
||||
_pumpPrinterThumbQueue();
|
||||
}
|
||||
|
||||
function _pumpPrinterThumbQueue(){
|
||||
if(_printerThumbLoading||!_printerThumbQueue.length)return;
|
||||
var el=_printerThumbQueue.shift();
|
||||
if(!el||!el.isConnected){_pumpPrinterThumbQueue();return;}
|
||||
var encodedName=el.getAttribute('data-filename');
|
||||
_printerThumbLoading=true;
|
||||
fetch(_apiUrl('/kx/printer-files/'+encodedName+'/thumbnail'))
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
var thumb=(d.result&&d.result.thumbnail)||'';
|
||||
_printerThumbCache[decodeURIComponent(encodedName)]=thumb;
|
||||
if(thumb&&el.isConnected){
|
||||
var img=document.createElement('img');
|
||||
img.src='data:image/png;base64,'+thumb;
|
||||
img.style.cssText='width:100%;height:60px;object-fit:contain;border-radius:6px;background:var(--raised);margin-bottom:8px';
|
||||
img.id=el.id;
|
||||
el.replaceWith(img);
|
||||
}
|
||||
})
|
||||
.catch(function(){/* keep the placeholder icon on failure */})
|
||||
.finally(function(){
|
||||
_printerThumbLoading=false;
|
||||
_pumpPrinterThumbQueue();
|
||||
});
|
||||
}
|
||||
|
||||
function _printerFilesUpdateSelectBar(){
|
||||
var selectAll=document.getElementById('printer-store-select-all');
|
||||
var countEl=document.getElementById('printer-store-selected-count');
|
||||
var delBtn=document.getElementById('printer-store-delete-selected-btn');
|
||||
if(!selectAll||!countEl||!delBtn)return;
|
||||
var allNames=printerFiles.map(function(f){return f.filename;});
|
||||
var selected=allNames.filter(function(n){return _printerFilesSelected[n];});
|
||||
var n=selected.length;
|
||||
selectAll.checked=n>0&&n===allNames.length;
|
||||
selectAll.indeterminate=n>0&&n<allNames.length;
|
||||
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
|
||||
delBtn.disabled=n===0;
|
||||
}
|
||||
|
||||
function printerFileToggleSelect(filename){
|
||||
if(!_printerFilesSelectMode){
|
||||
_printerFilesSelectMode=true;
|
||||
var bar=document.getElementById('printer-store-select-bar');
|
||||
if(bar)bar.style.display='flex';
|
||||
}
|
||||
if(_printerFilesSelected[filename])delete _printerFilesSelected[filename];
|
||||
else _printerFilesSelected[filename]=true;
|
||||
renderPrinterFiles();
|
||||
}
|
||||
|
||||
function printerFileToggleSelectAll(checked){
|
||||
_printerFilesSelected={};
|
||||
if(checked)printerFiles.forEach(function(f){_printerFilesSelected[f.filename]=true;});
|
||||
renderPrinterFiles();
|
||||
}
|
||||
|
||||
function printerFileExitSelectMode(){
|
||||
_printerFilesSelectMode=false;
|
||||
_printerFilesSelected={};
|
||||
var bar=document.getElementById('printer-store-select-bar');
|
||||
if(bar)bar.style.display='none';
|
||||
renderPrinterFiles();
|
||||
}
|
||||
|
||||
function _printerFilesDeleteRequest(filenames){
|
||||
return fetch(_apiUrl('/kx/printer-files/delete'),{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({filenames:filenames}),
|
||||
}).then(function(r){return r.json().then(function(d){return{ok:r.ok,body:d};});});
|
||||
}
|
||||
|
||||
function printerFileDelete(filename){
|
||||
if(!confirm(T.printer_store_delete_confirm||'Delete file?'))return;
|
||||
_printerFilesDeleteRequest([filename]).then(function(res){
|
||||
if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;}
|
||||
loadPrinterFiles();
|
||||
});
|
||||
}
|
||||
|
||||
function printerFileDeleteSelected(){
|
||||
var names=Object.keys(_printerFilesSelected);
|
||||
if(!names.length)return;
|
||||
if(!confirm((T.printer_store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',names.length)))return;
|
||||
_printerFilesDeleteRequest(names).then(function(res){
|
||||
if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;}
|
||||
printerFileExitSelectMode();
|
||||
loadPrinterFiles();
|
||||
});
|
||||
}
|
||||
|
||||
var _gcodeFilaments=[];
|
||||
|
||||
function _setGcodeFilamentsFromFileObj(fileObj){
|
||||
@ -3696,6 +4070,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>'+
|
||||
@ -3712,8 +4087,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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -35,6 +35,9 @@
|
||||
</div>
|
||||
<div id="h-pname-single" class="hname">Anycubic Kobra X</div>
|
||||
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
|
||||
<button id="h-power-btn" class="h-power-btn" style="display:none;margin-left:10px" onclick="toggleHeaderPower()" title="Steckdose">
|
||||
🔌 <span id="h-power-lbl">An</span>
|
||||
</button>
|
||||
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
|
||||
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
|
||||
<button class="theme-btn" onclick="showPanel('settings')" id="settings-btn" title="Einstellungen">⚙</button>
|
||||
@ -388,36 +391,81 @@
|
||||
<span id="store-panel-title">🗂 Datei-Browser</span>
|
||||
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
|
||||
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<select id="store-filter" onchange="renderStore()"
|
||||
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<option value="all" id="sf-all">Alle</option>
|
||||
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
|
||||
<option value="failed" id="sf-err">✗ Fehler</option>
|
||||
<option value="never" id="sf-new">Neu</option>
|
||||
</select>
|
||||
<select id="store-sort" onchange="renderStore()"
|
||||
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<option value="date_desc" id="ss-date">↓ Datum</option>
|
||||
<option value="name_asc" id="ss-name">A–Z Name</option>
|
||||
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
|
||||
</select>
|
||||
<div class="browser-subtabs" style="display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--border)">
|
||||
<button class="browser-tab active" id="btab-uploaded" onclick="showBrowserTab('uploaded')"
|
||||
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
|
||||
<span id="btab-lbl-uploaded">Hochgeladen</span></button>
|
||||
<button class="browser-tab" id="btab-printer" onclick="showBrowserTab('printer')"
|
||||
style="padding:8px 14px;background:none;border:none;border-bottom:2px solid transparent;color:var(--txt2);cursor:pointer;font-size:13px">
|
||||
<span id="btab-lbl-printer">Auf dem Drucker</span></button>
|
||||
</div>
|
||||
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
|
||||
ondragover="event.preventDefault();this.classList.add('drag-over')"
|
||||
ondragleave="this.classList.remove('drag-over')"
|
||||
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
|
||||
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
|
||||
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
|
||||
<span id="store-upload-icon">⬆</span>
|
||||
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
|
||||
<span id="store-upload-status" style="display:none"></span>
|
||||
|
||||
<div id="browser-group-uploaded" class="browser-group active">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
|
||||
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<select id="store-filter" onchange="renderStore()"
|
||||
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<option value="all" id="sf-all">Alle</option>
|
||||
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
|
||||
<option value="failed" id="sf-err">✗ Fehler</option>
|
||||
<option value="never" id="sf-new">Neu</option>
|
||||
</select>
|
||||
<select id="store-sort" onchange="renderStore()"
|
||||
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<option value="date_desc" id="ss-date">↓ Datum</option>
|
||||
<option value="name_asc" id="ss-name">A–Z Name</option>
|
||||
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
|
||||
ondragover="event.preventDefault();this.classList.add('drag-over')"
|
||||
ondragleave="this.classList.remove('drag-over')"
|
||||
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
|
||||
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
|
||||
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
|
||||
<span id="store-upload-icon">⬆</span>
|
||||
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
|
||||
<span id="store-upload-status" style="display:none"></span>
|
||||
</div>
|
||||
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
|
||||
</div>
|
||||
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
||||
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
|
||||
<span id="store-lbl-select-all">Alle auswählen</span>
|
||||
</label>
|
||||
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
|
||||
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
|
||||
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
|
||||
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
|
||||
<button onclick="storeExitSelectMode()"
|
||||
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
|
||||
<span id="store-lbl-exit-select">Abbrechen</span></button>
|
||||
</div>
|
||||
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
|
||||
</div>
|
||||
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
|
||||
|
||||
<div id="browser-group-printer" class="browser-group">
|
||||
<div id="printer-store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
|
||||
</div>
|
||||
<div id="printer-store-error" style="display:none;color:var(--err);text-align:center;padding:20px 0;font-size:13px">
|
||||
</div>
|
||||
<div id="printer-store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
||||
<input type="checkbox" id="printer-store-select-all" onchange="printerFileToggleSelectAll(this.checked)">
|
||||
<span id="printer-store-lbl-select-all">Alle auswählen</span>
|
||||
</label>
|
||||
<span id="printer-store-selected-count" style="color:var(--txt2);font-size:13px"></span>
|
||||
<button id="printer-store-delete-selected-btn" disabled onclick="printerFileDeleteSelected()"
|
||||
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
|
||||
🗑 <span id="printer-store-lbl-delete-selected">Auswahl löschen</span></button>
|
||||
<button onclick="printerFileExitSelectMode()"
|
||||
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
|
||||
<span id="printer-store-lbl-exit-select">Abbrechen</span></button>
|
||||
</div>
|
||||
<div id="printer-store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
|
||||
</div>
|
||||
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -503,6 +551,26 @@
|
||||
<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 class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-power-status-inverted" style="width:auto;margin:0">
|
||||
<label id="lbl-power-status-inverted" style="margin:0;cursor:pointer" for="s-power-status-inverted">Aktuellen Status statt Aktion anzeigen</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drucker -->
|
||||
@ -542,6 +610,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>
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
GridStack's resize-handle hit area and drag listeners. */
|
||||
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
|
||||
the cell smaller than the content needs. */
|
||||
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box}
|
||||
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow-y:auto;overflow-x:hidden;box-sizing:border-box}
|
||||
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
|
||||
as the user starts dragging, throwing off GridStack's position math. Kill
|
||||
the hover transform for dashboard cards specifically. */
|
||||
@ -164,6 +164,11 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
.cam-toggle:hover{background:rgba(0,0,0,.7)}
|
||||
|
||||
/* ── PROGRESS ── */
|
||||
/* Same pattern as #card-camera above: without this, shrinking the tile's
|
||||
height just clips the lower content (time-grid/filename/buttons) outside
|
||||
the visible area instead of making it scrollable (Issue #97). */
|
||||
.grid-stack-item-content>#card-progress{display:flex;flex-direction:column;min-height:0}
|
||||
.grid-stack-item-content>#card-progress>*{flex-shrink:0}
|
||||
.hero-info{display:flex;flex-direction:column;gap:12px}
|
||||
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
|
||||
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}
|
||||
@ -197,6 +202,14 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
.spd-btn .spd-icon{font-size:22px}
|
||||
.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
|
||||
.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
|
||||
.h-power-btn{border:1.5px solid var(--border);background:var(--raised);color:var(--txt2);
|
||||
border-radius:6px;padding:4px 10px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s}
|
||||
.h-power-btn:hover{opacity:.85}
|
||||
/* Shows the action a click would perform, not the current state - green
|
||||
"An" while the printer is off (click turns it on), red "Aus" while it's
|
||||
on (click turns it off). */
|
||||
.h-power-btn.h-power-can-on{border-color:var(--ok);background:rgba(76,222,128,.12);color:var(--ok)}
|
||||
.h-power-btn.h-power-can-off{border-color:var(--err);background:rgba(255,77,109,.12);color:var(--err)}
|
||||
|
||||
/* ── TIME CARDS ── */
|
||||
.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
|
||||
@ -317,6 +330,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
|
||||
.set-cat .nav-text{display:inline}
|
||||
}
|
||||
|
||||
/* ── BROWSER SUB-TABS (uploaded vs. on-printer files) ── */
|
||||
.browser-tab.active{color:var(--accent);border-bottom-color:var(--accent)!important}
|
||||
.browser-tab:hover{color:var(--txt)}
|
||||
.browser-group{display:none}
|
||||
.browser-group.active{display:block}
|
||||
|
||||
/* ── FILE BROWSER UPLOAD ZONE ── */
|
||||
#store-upload-zone{
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
"apd_placeholder_name": "z.B. Kobra X Wohnzimmer",
|
||||
"apd_success": "Drucker hinzugefügt, Bridge startet neu…",
|
||||
"apd_title": "Drucker hinzufügen",
|
||||
"browser_tab_printer": "Auf dem Drucker",
|
||||
"browser_tab_uploaded": "Hochgeladen",
|
||||
"btn_cam_start": "▶ Kamera",
|
||||
"btn_cam_start2": "▶ Start",
|
||||
"btn_cam_stop": "◼ Kamera",
|
||||
@ -66,8 +68,21 @@
|
||||
"card_light_fan": "Lüfter",
|
||||
"card_progress": "Fortschritt",
|
||||
"card_speed": "Druckgeschwindigkeit",
|
||||
"card_power": "Steckdose",
|
||||
"card_temps": "Temperaturen",
|
||||
"confirm_cancel": "Druck wirklich abbrechen?",
|
||||
"dash_done": "Fertig",
|
||||
"dash_edit": "Dashboard anpassen",
|
||||
"dash_hidden_cards": "Ausgeblendete Karten",
|
||||
"dash_hide": "Ausblenden",
|
||||
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
|
||||
"dash_preset_name_prompt": "Preset-Name:",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Desktop breit",
|
||||
"dash_reset": "Zurücksetzen",
|
||||
"dash_save_preset": "Als Preset speichern",
|
||||
"dash_show": "Einblenden",
|
||||
"dash_toggle_width": "Breite umschalten",
|
||||
"fd_cancel": "Abbrechen",
|
||||
"fd_no_matching_material": "Kein passendes Material",
|
||||
"fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?",
|
||||
@ -200,12 +215,22 @@
|
||||
"panel_temps_chart": "Verlauf (letzte 60 Messungen)",
|
||||
"panel_temps_nozzle": "Düse",
|
||||
"print_auto_leveling": "Auto-Leveling für diesen Druck",
|
||||
"printer_store_delete_confirm": "Datei vom Drucker löschen?",
|
||||
"printer_store_delete_selected_confirm": "{n} ausgewählte Dateien vom Drucker löschen?",
|
||||
"printer_store_empty": "Keine Dateien auf dem Drucker.",
|
||||
"printer_store_unreachable": "Drucker nicht erreichbar oder Abfrage fehlgeschlagen.",
|
||||
"printers_active": "● aktiv",
|
||||
"printers_current": "Aktueller Drucker",
|
||||
"printers_empty_hint": "Noch kein Drucker eingerichtet.",
|
||||
"printers_loading": "Lade…",
|
||||
"printers_none": "Keine Drucker konfiguriert.",
|
||||
"printers_remove": "Drucker entfernen",
|
||||
"printers_power": "Drucker-Stromversorgung schalten",
|
||||
"printers_power_on": "Strom: An",
|
||||
"printers_power_off": "Strom: Aus",
|
||||
"printers_power_off_confirm": "Drucker-Strom ausschalten? Stelle sicher, dass kein Druck läuft.",
|
||||
"printers_power_on_short": "An",
|
||||
"printers_power_off_short": "Aus",
|
||||
"printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
|
||||
"printers_switch": "Wechseln →",
|
||||
"progress_action_clear": "Leeren",
|
||||
@ -213,7 +238,6 @@
|
||||
"progress_action_slots": "Slots zuordnen",
|
||||
"settings_auto_leveling": "Auto-Leveling vor Druck",
|
||||
"settings_auto_leveling_label": "Auto-Leveling vor dem Druck",
|
||||
"settings_vibration_compensation": "Resonanzkompensation",
|
||||
"settings_btn_tooltip": "Einstellungen",
|
||||
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
|
||||
"settings_cat_connection": "Verbindung",
|
||||
@ -240,6 +264,12 @@
|
||||
"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_power_status_inverted": "Aktuellen Status statt Aktion anzeigen",
|
||||
"settings_mqtt_port": "MQTT-Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Profile importieren",
|
||||
@ -247,19 +277,6 @@
|
||||
"settings_password": "MQTT-Passwort",
|
||||
"settings_poll": "Poll-Intervall (Sekunden)",
|
||||
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
|
||||
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
|
||||
"dash_edit": "Dashboard anpassen",
|
||||
"dash_done": "Fertig",
|
||||
"dash_reset": "Zurücksetzen",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Desktop breit",
|
||||
"dash_hidden_cards": "Ausgeblendete Karten",
|
||||
"dash_show": "Einblenden",
|
||||
"dash_hide": "Ausblenden",
|
||||
"dash_toggle_width": "Breite umschalten",
|
||||
"dash_save_preset": "Als Preset speichern",
|
||||
"dash_preset_name_prompt": "Preset-Name:",
|
||||
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
|
||||
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
|
||||
"settings_print": "Druckeinstellungen",
|
||||
"settings_printer_ip": "Drucker-IP",
|
||||
@ -271,13 +288,17 @@
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_username": "MQTT-Benutzername",
|
||||
"settings_vendor_filter_placeholder": "Hersteller suchen…",
|
||||
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
|
||||
"settings_version": "Version",
|
||||
"settings_vibration_compensation": "Resonanzkompensation",
|
||||
"settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)",
|
||||
"settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.",
|
||||
"settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)",
|
||||
"settings_visible_vendors_save": "Auswahl speichern",
|
||||
"settings_visible_vendors_save_label": "Auswahl speichern",
|
||||
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
|
||||
"settings_delete_printer_file_after_print": "Datei nach erfolgreichem Druck vom Drucker löschen",
|
||||
"settings_delete_printer_file_after_print_hint": "Gilt nur für Drucke, die über diese Bridge gestartet wurden (selbst hochgeladene Dateien) - direkt am Drucker oder über Anycubic Slicer gestartete Drucke werden nie gelöscht, da davon sonst keine Kopie mehr existiert.",
|
||||
"sf_all": "Alle",
|
||||
"sf_err": "✗ Fehler",
|
||||
"sf_new": "Neu",
|
||||
@ -293,6 +314,7 @@
|
||||
"skip_sending": "Sende …",
|
||||
"skip_success": "Objekte werden übersprungen.",
|
||||
"skip_title": "✂ Objekte überspringen",
|
||||
"slot_copy_from": "Farbe von Slot kopieren…",
|
||||
"slot_edit_color": "Farbe",
|
||||
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Einziehen",
|
||||
@ -311,15 +333,20 @@
|
||||
"ss_dur": "⏱ Druckzeit",
|
||||
"ss_name": "A–Z Name",
|
||||
"store_delete_confirm": "Datei löschen?",
|
||||
"store_delete_selected": "Auswahl löschen",
|
||||
"store_delete_selected_confirm": "{n} ausgewählte Dateien löschen?",
|
||||
"store_download": "⬇ Download",
|
||||
"store_empty": "Noch keine Dateien hochgeladen.",
|
||||
"store_estimate": "Schätzung",
|
||||
"store_exit_select": "Abbrechen",
|
||||
"store_never": "noch nicht gedruckt",
|
||||
"store_no_results": "Keine Dateien gefunden.",
|
||||
"store_print": "▶ Drucken",
|
||||
"store_print_confirm": "Datei drucken?",
|
||||
"store_refresh": "↻ Aktualisieren",
|
||||
"store_search_placeholder": "🔍 Suche…",
|
||||
"store_select_all": "Alle auswählen",
|
||||
"store_selected_count": "{n} ausgewählt",
|
||||
"store_upload_busy": "⏳ Hochladen…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
"store_upload_label_browse": "durchsuchen",
|
||||
@ -339,6 +366,5 @@
|
||||
"update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d",
|
||||
"update_error": "Fehler",
|
||||
"update_none": "Bereits aktuell",
|
||||
"update_restarting": "Starte neu...",
|
||||
"slot_copy_from": "Farbe von Slot kopieren…"
|
||||
}
|
||||
"update_restarting": "Starte neu..."
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
"apd_placeholder_name": "e.g. Kobra X Living Room",
|
||||
"apd_success": "Printer added, bridge restarting…",
|
||||
"apd_title": "Add printer",
|
||||
"browser_tab_printer": "On Printer",
|
||||
"browser_tab_uploaded": "Uploaded",
|
||||
"btn_cam_start": "▶ Camera",
|
||||
"btn_cam_start2": "▶ Start",
|
||||
"btn_cam_stop": "◼ Camera",
|
||||
@ -66,8 +68,21 @@
|
||||
"card_light_fan": "Fan",
|
||||
"card_progress": "Progress",
|
||||
"card_speed": "Print Speed",
|
||||
"card_power": "Power Switch",
|
||||
"card_temps": "Temperatures",
|
||||
"confirm_cancel": "Really cancel the print?",
|
||||
"dash_done": "Done",
|
||||
"dash_edit": "Customize dashboard",
|
||||
"dash_hidden_cards": "Hidden cards",
|
||||
"dash_hide": "Hide",
|
||||
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
|
||||
"dash_preset_name_prompt": "Preset name:",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Wide desktop",
|
||||
"dash_reset": "Reset",
|
||||
"dash_save_preset": "Save as preset",
|
||||
"dash_show": "Show",
|
||||
"dash_toggle_width": "Toggle width",
|
||||
"fd_cancel": "Cancel",
|
||||
"fd_no_matching_material": "No matching material",
|
||||
"fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?",
|
||||
@ -200,12 +215,22 @@
|
||||
"panel_temps_chart": "History (last 60 readings)",
|
||||
"panel_temps_nozzle": "Nozzle",
|
||||
"print_auto_leveling": "Auto-Leveling",
|
||||
"printer_store_delete_confirm": "Delete file from the printer?",
|
||||
"printer_store_delete_selected_confirm": "Delete {n} selected files from the printer?",
|
||||
"printer_store_empty": "No files on the printer.",
|
||||
"printer_store_unreachable": "Printer unreachable or query failed.",
|
||||
"printers_active": "● active",
|
||||
"printers_current": "Current printer",
|
||||
"printers_empty_hint": "No printer set up yet.",
|
||||
"printers_loading": "Loading…",
|
||||
"printers_none": "No printers configured.",
|
||||
"printers_remove": "Remove printer",
|
||||
"printers_power": "Toggle printer power",
|
||||
"printers_power_on": "Power: On",
|
||||
"printers_power_off": "Power: Off",
|
||||
"printers_power_off_confirm": "Turn printer power off? Make sure no print is running.",
|
||||
"printers_power_on_short": "On",
|
||||
"printers_power_off_short": "Off",
|
||||
"printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
|
||||
"printers_switch": "Switch →",
|
||||
"progress_action_clear": "Clear",
|
||||
@ -213,7 +238,6 @@
|
||||
"progress_action_slots": "Map slots",
|
||||
"settings_auto_leveling": "Auto-Leveling Default",
|
||||
"settings_auto_leveling_label": "Auto-Leveling before print",
|
||||
"settings_vibration_compensation": "Resonance Compensation",
|
||||
"settings_btn_tooltip": "Settings",
|
||||
"settings_camera_on_print": "Turn camera on at print start",
|
||||
"settings_cat_connection": "Connection",
|
||||
@ -240,6 +264,12 @@
|
||||
"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_power_status_inverted": "Show current status instead of action",
|
||||
"settings_mqtt_port": "MQTT Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Import profiles",
|
||||
@ -247,19 +277,6 @@
|
||||
"settings_password": "MQTT Password",
|
||||
"settings_poll": "Poll Interval (seconds)",
|
||||
"settings_poll_interval_hint": "How often the bridge queries printer status",
|
||||
"settings_verbose_http_log": "Log every HTTP request (verbose)",
|
||||
"dash_edit": "Customize dashboard",
|
||||
"dash_done": "Done",
|
||||
"dash_reset": "Reset",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Wide desktop",
|
||||
"dash_hidden_cards": "Hidden cards",
|
||||
"dash_show": "Show",
|
||||
"dash_hide": "Hide",
|
||||
"dash_toggle_width": "Toggle width",
|
||||
"dash_save_preset": "Save as preset",
|
||||
"dash_preset_name_prompt": "Preset name:",
|
||||
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Poll Interval (seconds)",
|
||||
"settings_print": "Print Settings",
|
||||
"settings_printer_ip": "Printer IP",
|
||||
@ -271,13 +288,17 @@
|
||||
"settings_title": "Settings",
|
||||
"settings_username": "MQTT Username",
|
||||
"settings_vendor_filter_placeholder": "Search vendors…",
|
||||
"settings_verbose_http_log": "Log every HTTP request (verbose)",
|
||||
"settings_version": "Version",
|
||||
"settings_vibration_compensation": "Resonance Compensation",
|
||||
"settings_visible_vendors": "Visible vendors (profile dropdown)",
|
||||
"settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.",
|
||||
"settings_visible_vendors_label": "Visible vendors (profile dropdown)",
|
||||
"settings_visible_vendors_save": "Save selection",
|
||||
"settings_visible_vendors_save_label": "Save selection",
|
||||
"settings_web_upload_warning": "Show warning when printing web uploads",
|
||||
"settings_delete_printer_file_after_print": "Delete file from printer after successful print",
|
||||
"settings_delete_printer_file_after_print_hint": "Only applies to prints started through this bridge (files it uploaded itself) - prints started directly from the printer or Anycubic Slicer are never deleted, since no copy of those exists anywhere else.",
|
||||
"sf_all": "All",
|
||||
"sf_err": "✗ Failed",
|
||||
"sf_new": "New",
|
||||
@ -293,6 +314,7 @@
|
||||
"skip_sending": "Sending …",
|
||||
"skip_success": "Objects will be skipped.",
|
||||
"skip_title": "✂ Skip objects",
|
||||
"slot_copy_from": "Copy color from slot…",
|
||||
"slot_edit_color": "Color",
|
||||
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Load",
|
||||
@ -311,15 +333,20 @@
|
||||
"ss_dur": "⏱ Print time",
|
||||
"ss_name": "A–Z Name",
|
||||
"store_delete_confirm": "Delete file?",
|
||||
"store_delete_selected": "Delete Selected",
|
||||
"store_delete_selected_confirm": "Delete {n} selected files?",
|
||||
"store_download": "⬇ Download",
|
||||
"store_empty": "No files uploaded yet.",
|
||||
"store_estimate": "Estimate",
|
||||
"store_exit_select": "Cancel",
|
||||
"store_never": "never printed",
|
||||
"store_no_results": "No files found.",
|
||||
"store_print": "▶ Print",
|
||||
"store_print_confirm": "Print file?",
|
||||
"store_refresh": "↻ Refresh",
|
||||
"store_search_placeholder": "🔍 Search…",
|
||||
"store_select_all": "Select All",
|
||||
"store_selected_count": "{n} selected",
|
||||
"store_upload_busy": "⏳ Uploading…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
"store_upload_label_browse": "browse",
|
||||
@ -339,6 +366,5 @@
|
||||
"update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d",
|
||||
"update_error": "Error",
|
||||
"update_none": "Already up to date",
|
||||
"update_restarting": "Restarting...",
|
||||
"slot_copy_from": "Copy color from slot…"
|
||||
}
|
||||
"update_restarting": "Restarting..."
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
"apd_placeholder_name": "p. ej. Kobra X Sala",
|
||||
"apd_success": "Impresora añadida, reiniciando bridge…",
|
||||
"apd_title": "Agregar impresora",
|
||||
"browser_tab_printer": "En la impresora",
|
||||
"browser_tab_uploaded": "Subidos",
|
||||
"btn_cam_start": "▶ Cámara",
|
||||
"btn_cam_start2": "▶ Iniciar",
|
||||
"btn_cam_stop": "◼ Cámara",
|
||||
@ -66,8 +68,21 @@
|
||||
"card_light_fan": "Ventilador",
|
||||
"card_progress": "Progreso",
|
||||
"card_speed": "Velocidad de impresión",
|
||||
"card_power": "Enchufe",
|
||||
"card_temps": "Temperaturas",
|
||||
"confirm_cancel": "¿Realmente cancelar la impresión?",
|
||||
"dash_done": "Listo",
|
||||
"dash_edit": "Personalizar panel",
|
||||
"dash_hidden_cards": "Tarjetas ocultas",
|
||||
"dash_hide": "Ocultar",
|
||||
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
|
||||
"dash_preset_name_prompt": "Nombre del preset:",
|
||||
"dash_preset_standard": "Estándar",
|
||||
"dash_preset_wide89": "Escritorio ancho",
|
||||
"dash_reset": "Restablecer",
|
||||
"dash_save_preset": "Guardar como preset",
|
||||
"dash_show": "Mostrar",
|
||||
"dash_toggle_width": "Cambiar ancho",
|
||||
"fd_cancel": "Cancelar",
|
||||
"fd_no_matching_material": "No hay material compatible",
|
||||
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
|
||||
@ -200,12 +215,22 @@
|
||||
"panel_temps_chart": "Historial (últimas 60 lecturas)",
|
||||
"panel_temps_nozzle": "Boquilla",
|
||||
"print_auto_leveling": "Autonivelado para esta impresión",
|
||||
"printer_store_delete_confirm": "¿Eliminar archivo de la impresora?",
|
||||
"printer_store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados de la impresora?",
|
||||
"printer_store_empty": "No hay archivos en la impresora.",
|
||||
"printer_store_unreachable": "Impresora inaccesible o consulta fallida.",
|
||||
"printers_active": "● activa",
|
||||
"printers_current": "Impresora actual",
|
||||
"printers_empty_hint": "Aún no hay impresora configurada.",
|
||||
"printers_loading": "Cargando…",
|
||||
"printers_none": "No hay impresoras configuradas.",
|
||||
"printers_remove": "Eliminar impresora",
|
||||
"printers_power": "Alternar alimentación de la impresora",
|
||||
"printers_power_on": "Alimentación: Encendida",
|
||||
"printers_power_off": "Alimentación: Apagada",
|
||||
"printers_power_off_confirm": "¿Apagar la alimentación de la impresora? Asegúrate de que no haya ninguna impresión en curso.",
|
||||
"printers_power_on_short": "Encendido",
|
||||
"printers_power_off_short": "Apagado",
|
||||
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
|
||||
"printers_switch": "Cambiar →",
|
||||
"progress_action_clear": "Vaciar",
|
||||
@ -213,7 +238,6 @@
|
||||
"progress_action_slots": "Asignar ranuras",
|
||||
"settings_auto_leveling": "Autonivelado antes de imprimir",
|
||||
"settings_auto_leveling_label": "Autonivelado antes de imprimir",
|
||||
"settings_vibration_compensation": "Compensación de resonancia",
|
||||
"settings_btn_tooltip": "Ajustes",
|
||||
"settings_camera_on_print": "Encender cámara al iniciar impresión",
|
||||
"settings_cat_connection": "Conexión",
|
||||
@ -240,6 +264,12 @@
|
||||
"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_power_status_inverted": "Mostrar el estado actual en lugar de la acción",
|
||||
"settings_mqtt_port": "MQTT Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importar perfiles",
|
||||
@ -247,19 +277,6 @@
|
||||
"settings_password": "Contraseña MQTT",
|
||||
"settings_poll": "Intervalo de sondeo (segundos)",
|
||||
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
|
||||
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
|
||||
"dash_edit": "Personalizar panel",
|
||||
"dash_done": "Listo",
|
||||
"dash_reset": "Restablecer",
|
||||
"dash_preset_standard": "Estándar",
|
||||
"dash_preset_wide89": "Escritorio ancho",
|
||||
"dash_hidden_cards": "Tarjetas ocultas",
|
||||
"dash_show": "Mostrar",
|
||||
"dash_hide": "Ocultar",
|
||||
"dash_toggle_width": "Cambiar ancho",
|
||||
"dash_save_preset": "Guardar como preset",
|
||||
"dash_preset_name_prompt": "Nombre del preset:",
|
||||
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
|
||||
"settings_print": "Ajustes de impresión",
|
||||
"settings_printer_ip": "IP de impresora",
|
||||
@ -271,13 +288,17 @@
|
||||
"settings_title": "Configuración",
|
||||
"settings_username": "Usuario MQTT",
|
||||
"settings_vendor_filter_placeholder": "Buscar fabricantes…",
|
||||
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
|
||||
"settings_version": "Versión",
|
||||
"settings_vibration_compensation": "Compensación de resonancia",
|
||||
"settings_visible_vendors": "Fabricantes visibles (lista de perfiles)",
|
||||
"settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.",
|
||||
"settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)",
|
||||
"settings_visible_vendors_save": "Guardar selección",
|
||||
"settings_visible_vendors_save_label": "Guardar selección",
|
||||
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
|
||||
"settings_delete_printer_file_after_print": "Eliminar archivo de la impresora tras una impresión exitosa",
|
||||
"settings_delete_printer_file_after_print_hint": "Solo aplica a impresiones iniciadas a través de este bridge (archivos que él mismo subió) - las impresiones iniciadas directamente desde la impresora o Anycubic Slicer nunca se eliminan, ya que no existe ninguna copia en otro lugar.",
|
||||
"sf_all": "Todos",
|
||||
"sf_err": "✗ Fallido",
|
||||
"sf_new": "Nuevo",
|
||||
@ -293,6 +314,7 @@
|
||||
"skip_sending": "Enviando …",
|
||||
"skip_success": "Se omitirán los objetos.",
|
||||
"skip_title": "✂ Omitir objetos",
|
||||
"slot_copy_from": "Copiar color del slot…",
|
||||
"slot_edit_color": "Color",
|
||||
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Cargar",
|
||||
@ -311,15 +333,20 @@
|
||||
"ss_dur": "⏱ Tiempo de impresión",
|
||||
"ss_name": "A–Z Nombre",
|
||||
"store_delete_confirm": "¿Eliminar archivo?",
|
||||
"store_delete_selected": "Eliminar seleccionados",
|
||||
"store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados?",
|
||||
"store_download": "⬇ Descargar",
|
||||
"store_empty": "Aún no hay archivos subidos.",
|
||||
"store_estimate": "Estimación",
|
||||
"store_exit_select": "Cancelar",
|
||||
"store_never": "nunca impreso",
|
||||
"store_no_results": "No se encontraron archivos.",
|
||||
"store_print": "▶ Imprimir",
|
||||
"store_print_confirm": "¿Imprimir archivo?",
|
||||
"store_refresh": "↻ Actualizar",
|
||||
"store_search_placeholder": "🔍 Buscar…",
|
||||
"store_select_all": "Seleccionar todo",
|
||||
"store_selected_count": "{n} seleccionados",
|
||||
"store_upload_busy": "⏳ Subiendo…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
"store_upload_label_browse": "buscar",
|
||||
@ -339,6 +366,5 @@
|
||||
"update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d",
|
||||
"update_error": "Error",
|
||||
"update_none": "Ya actualizado",
|
||||
"update_restarting": "Reiniciando...",
|
||||
"slot_copy_from": "Copiar color del slot…"
|
||||
}
|
||||
"update_restarting": "Reiniciando..."
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
"apd_placeholder_name": "ex. Kobra X Salon",
|
||||
"apd_success": "Imprimante ajoutée, redémarrage du bridge…",
|
||||
"apd_title": "Ajouter une imprimante",
|
||||
"browser_tab_printer": "Sur l'imprimante",
|
||||
"browser_tab_uploaded": "Téléversés",
|
||||
"btn_cam_start": "▶ Caméra",
|
||||
"btn_cam_start2": "▶ Démarrer",
|
||||
"btn_cam_stop": "◼ Caméra",
|
||||
@ -66,6 +68,7 @@
|
||||
"card_light_fan": "Ventilateur",
|
||||
"card_progress": "Progression",
|
||||
"card_speed": "Vitesse d'impression",
|
||||
"card_power": "Prise électrique",
|
||||
"card_temps": "Températures",
|
||||
"confirm_cancel": "Vraiment annuler l'impression ?",
|
||||
"fd_cancel": "Annuler",
|
||||
@ -200,12 +203,22 @@
|
||||
"panel_temps_chart": "Historique (60 dernières valeurs)",
|
||||
"panel_temps_nozzle": "Buse",
|
||||
"print_auto_leveling": "Mise à niveau auto pour cette impression",
|
||||
"printer_store_delete_confirm": "Supprimer le fichier de l'imprimante ?",
|
||||
"printer_store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés de l'imprimante ?",
|
||||
"printer_store_empty": "Aucun fichier sur l'imprimante.",
|
||||
"printer_store_unreachable": "Imprimante injoignable ou requête échouée.",
|
||||
"printers_active": "● actif",
|
||||
"printers_current": "Imprimante actuelle",
|
||||
"printers_empty_hint": "Aucune imprimante configurée.",
|
||||
"printers_loading": "Chargement…",
|
||||
"printers_none": "Aucune imprimante configurée.",
|
||||
"printers_remove": "Supprimer l'imprimante",
|
||||
"printers_power": "Basculer l'alimentation de l'imprimante",
|
||||
"printers_power_on": "Alimentation : Allumée",
|
||||
"printers_power_off": "Alimentation : Éteinte",
|
||||
"printers_power_off_confirm": "Éteindre l'alimentation de l'imprimante ? Assurez-vous qu'aucune impression n'est en cours.",
|
||||
"printers_power_on_short": "Marche",
|
||||
"printers_power_off_short": "Arrêt",
|
||||
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
|
||||
"printers_switch": "Changer →",
|
||||
"progress_action_clear": "Vider",
|
||||
@ -239,6 +252,12 @@
|
||||
"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_power_status_inverted": "Afficher l'état actuel au lieu de l'action",
|
||||
"settings_mqtt_port": "Port MQTT",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importer des profils",
|
||||
@ -264,6 +283,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 +300,7 @@
|
||||
"skip_sending": "Envoi …",
|
||||
"skip_success": "Les objets seront ignorés.",
|
||||
"skip_title": "✂ Ignorer des objets",
|
||||
"slot_copy_from": "Copier la couleur du slot…",
|
||||
"slot_edit_color": "Couleur",
|
||||
"slot_edit_custom": "ex. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Charger",
|
||||
@ -297,15 +319,20 @@
|
||||
"ss_dur": "⏱ Durée d'impression",
|
||||
"ss_name": "A–Z Nom",
|
||||
"store_delete_confirm": "Supprimer le fichier ?",
|
||||
"store_delete_selected": "Supprimer la sélection",
|
||||
"store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés ?",
|
||||
"store_download": "⬇ Télécharger",
|
||||
"store_empty": "Aucun fichier uploadé.",
|
||||
"store_estimate": "Estimation",
|
||||
"store_exit_select": "Annuler",
|
||||
"store_never": "jamais imprimé",
|
||||
"store_no_results": "Aucun fichier trouvé.",
|
||||
"store_print": "▶ Imprimer",
|
||||
"store_print_confirm": "Imprimer le fichier ?",
|
||||
"store_refresh": "↻ Actualiser",
|
||||
"store_search_placeholder": "🔍 Rechercher…",
|
||||
"store_select_all": "Tout sélectionner",
|
||||
"store_selected_count": "{n} sélectionné(s)",
|
||||
"store_upload_busy": "⏳ Envoi en cours…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
"store_upload_label_browse": "parcourir",
|
||||
@ -325,6 +352,5 @@
|
||||
"update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d",
|
||||
"update_error": "Erreur",
|
||||
"update_none": "Déjà à jour",
|
||||
"update_restarting": "Redémarrage…",
|
||||
"slot_copy_from": "Copier la couleur du slot…"
|
||||
}
|
||||
"update_restarting": "Redémarrage…"
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
"apd_placeholder_name": "es. Kobra X Soggiorno",
|
||||
"apd_success": "Stampante aggiunta, riavvio del bridge in corso…",
|
||||
"apd_title": "Aggiungi stampante",
|
||||
"browser_tab_printer": "Sulla stampante",
|
||||
"browser_tab_uploaded": "Caricati",
|
||||
"btn_cam_start": "▶ Camera",
|
||||
"btn_cam_start2": "▶ Avvia",
|
||||
"btn_cam_stop": "◼ Camera",
|
||||
@ -66,6 +68,7 @@
|
||||
"card_light_fan": "Ventola",
|
||||
"card_progress": "Avanzamento",
|
||||
"card_speed": "Velocità di stampa",
|
||||
"card_power": "Presa di corrente",
|
||||
"card_temps": "Temperature",
|
||||
"confirm_cancel": "Annullare davvero la stampa?",
|
||||
"fd_cancel": "Annulla",
|
||||
@ -200,12 +203,22 @@
|
||||
"panel_temps_chart": "Cronologia (ultime 60 letture)",
|
||||
"panel_temps_nozzle": "Ugello",
|
||||
"print_auto_leveling": "Livellamento automatico",
|
||||
"printer_store_delete_confirm": "Eliminare il file dalla stampante?",
|
||||
"printer_store_delete_selected_confirm": "Eliminare {n} file selezionati dalla stampante?",
|
||||
"printer_store_empty": "Nessun file sulla stampante.",
|
||||
"printer_store_unreachable": "Stampante non raggiungibile o richiesta fallita.",
|
||||
"printers_active": "● attiva",
|
||||
"printers_current": "Stampante corrente",
|
||||
"printers_empty_hint": "Nessuna stampante ancora configurata.",
|
||||
"printers_loading": "Caricamento in corso…",
|
||||
"printers_none": "Nessuna stampante configurata.",
|
||||
"printers_remove": "Rimuovi stampante",
|
||||
"printers_power": "Attiva/disattiva alimentazione stampante",
|
||||
"printers_power_on": "Alimentazione: Accesa",
|
||||
"printers_power_off": "Alimentazione: Spenta",
|
||||
"printers_power_off_confirm": "Spegnere l'alimentazione della stampante? Assicurati che non sia in corso alcuna stampa.",
|
||||
"printers_power_on_short": "Acceso",
|
||||
"printers_power_off_short": "Spento",
|
||||
"printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
|
||||
"printers_switch": "Cambia →",
|
||||
"progress_action_clear": "Cancella",
|
||||
@ -239,6 +252,12 @@
|
||||
"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_power_status_inverted": "Mostra lo stato attuale invece dell'azione",
|
||||
"settings_mqtt_port": "Porta MQTT",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importa profili",
|
||||
@ -264,6 +283,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",
|
||||
@ -279,6 +300,7 @@
|
||||
"skip_sending": "Invio in corso …",
|
||||
"skip_success": "Gli oggetti verranno saltati.",
|
||||
"skip_title": "✂ Salta oggetti",
|
||||
"slot_copy_from": "Copia colore dallo slot…",
|
||||
"slot_edit_color": "Colore",
|
||||
"slot_edit_custom": "es. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Carica",
|
||||
@ -297,15 +319,20 @@
|
||||
"ss_dur": "⏱ Tempo di stampa",
|
||||
"ss_name": "Nome A–Z",
|
||||
"store_delete_confirm": "Eliminare il file?",
|
||||
"store_delete_selected": "Elimina selezionati",
|
||||
"store_delete_selected_confirm": "Eliminare {n} file selezionati?",
|
||||
"store_download": "⬇ Scarica",
|
||||
"store_empty": "Nessun file caricato.",
|
||||
"store_estimate": "Stima",
|
||||
"store_exit_select": "Annulla",
|
||||
"store_never": "mai stampato",
|
||||
"store_no_results": "Nessun file trovato.",
|
||||
"store_print": "▶ Stampa",
|
||||
"store_print_confirm": "Stampare il file?",
|
||||
"store_refresh": "↻ Aggiorna",
|
||||
"store_search_placeholder": "🔍 Cerca…",
|
||||
"store_select_all": "Seleziona tutto",
|
||||
"store_selected_count": "{n} selezionati",
|
||||
"store_upload_busy": "⏳ Caricamento in corso…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
"store_upload_label_browse": "sfoglia",
|
||||
@ -325,6 +352,5 @@
|
||||
"update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d",
|
||||
"update_error": "Errore",
|
||||
"update_none": "Già aggiornato",
|
||||
"update_restarting": "Riavvio in corso...",
|
||||
"slot_copy_from": "Copia colore dallo slot…"
|
||||
}
|
||||
"update_restarting": "Riavvio in corso..."
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
"apd_placeholder_name": "例如 Kobra X 客厅",
|
||||
"apd_success": "打印机已添加,Bridge 正在重启…",
|
||||
"apd_title": "添加打印机",
|
||||
"browser_tab_printer": "打印机上",
|
||||
"browser_tab_uploaded": "已上传",
|
||||
"btn_cam_start": "▶ 相机",
|
||||
"btn_cam_start2": "▶ 启动",
|
||||
"btn_cam_stop": "◼ 相机",
|
||||
@ -66,8 +68,21 @@
|
||||
"card_light_fan": "风扇",
|
||||
"card_progress": "进度",
|
||||
"card_speed": "打印速度",
|
||||
"card_power": "电源插座",
|
||||
"card_temps": "温度",
|
||||
"confirm_cancel": "确定要取消打印吗?",
|
||||
"dash_done": "完成",
|
||||
"dash_edit": "自定义仪表盘",
|
||||
"dash_hidden_cards": "隐藏的卡片",
|
||||
"dash_hide": "隐藏",
|
||||
"dash_preset_delete_confirm": "删除预设 \"{name}\"?",
|
||||
"dash_preset_name_prompt": "预设名称:",
|
||||
"dash_preset_standard": "标准",
|
||||
"dash_preset_wide89": "宽屏桌面",
|
||||
"dash_reset": "重置",
|
||||
"dash_save_preset": "另存为预设",
|
||||
"dash_show": "显示",
|
||||
"dash_toggle_width": "切换宽度",
|
||||
"fd_cancel": "取消",
|
||||
"fd_no_matching_material": "无匹配材料",
|
||||
"fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?",
|
||||
@ -200,12 +215,22 @@
|
||||
"panel_temps_chart": "历史 (最近 60 次读数)",
|
||||
"panel_temps_nozzle": "喷嘴",
|
||||
"print_auto_leveling": "本次打印自动调平",
|
||||
"printer_store_delete_confirm": "从打印机删除文件?",
|
||||
"printer_store_delete_selected_confirm": "从打印机删除选中的 {n} 个文件?",
|
||||
"printer_store_empty": "打印机上没有文件。",
|
||||
"printer_store_unreachable": "无法连接打印机或查询失败。",
|
||||
"printers_active": "● 活动",
|
||||
"printers_current": "当前打印机",
|
||||
"printers_empty_hint": "尚未设置打印机。",
|
||||
"printers_loading": "加载中…",
|
||||
"printers_none": "未配置打印机。",
|
||||
"printers_remove": "移除打印机",
|
||||
"printers_power": "切换打印机电源",
|
||||
"printers_power_on": "电源:开",
|
||||
"printers_power_off": "电源:关",
|
||||
"printers_power_off_confirm": "关闭打印机电源?请确认当前没有正在进行的打印任务。",
|
||||
"printers_power_on_short": "开",
|
||||
"printers_power_off_short": "关",
|
||||
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
|
||||
"printers_switch": "切换 →",
|
||||
"progress_action_clear": "清除",
|
||||
@ -213,7 +238,6 @@
|
||||
"progress_action_slots": "分配槽位",
|
||||
"settings_auto_leveling": "打印前自动调平",
|
||||
"settings_auto_leveling_label": "打印前自动调平",
|
||||
"settings_vibration_compensation": "打印前共振补偿",
|
||||
"settings_btn_tooltip": "设置",
|
||||
"settings_camera_on_print": "打印开始时开启相机",
|
||||
"settings_cat_connection": "连接",
|
||||
@ -240,6 +264,12 @@
|
||||
"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_power_status_inverted": "显示当前状态而非可执行操作",
|
||||
"settings_mqtt_port": "MQTT 端口",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "导入配置文件",
|
||||
@ -247,19 +277,6 @@
|
||||
"settings_password": "MQTT 密码",
|
||||
"settings_poll": "轮询间隔(秒)",
|
||||
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
|
||||
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
|
||||
"dash_edit": "自定义仪表盘",
|
||||
"dash_done": "完成",
|
||||
"dash_reset": "重置",
|
||||
"dash_preset_standard": "标准",
|
||||
"dash_preset_wide89": "宽屏桌面",
|
||||
"dash_hidden_cards": "隐藏的卡片",
|
||||
"dash_show": "显示",
|
||||
"dash_hide": "隐藏",
|
||||
"dash_toggle_width": "切换宽度",
|
||||
"dash_save_preset": "另存为预设",
|
||||
"dash_preset_name_prompt": "预设名称:",
|
||||
"dash_preset_delete_confirm": "删除预设 \"{name}\"?",
|
||||
"settings_poll_interval_label": "轮询间隔(秒)",
|
||||
"settings_print": "打印设置",
|
||||
"settings_printer_ip": "打印机 IP",
|
||||
@ -271,13 +288,17 @@
|
||||
"settings_title": "设置",
|
||||
"settings_username": "MQTT 用户名",
|
||||
"settings_vendor_filter_placeholder": "搜索厂商…",
|
||||
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
|
||||
"settings_version": "版本",
|
||||
"settings_vibration_compensation": "打印前共振补偿",
|
||||
"settings_visible_vendors": "可见厂商(配置下拉框)",
|
||||
"settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。",
|
||||
"settings_visible_vendors_label": "可见厂商(配置下拉框)",
|
||||
"settings_visible_vendors_save": "保存选择",
|
||||
"settings_visible_vendors_save_label": "保存选择",
|
||||
"settings_web_upload_warning": "打印网页上传文件时显示警告",
|
||||
"settings_delete_printer_file_after_print": "打印成功后从打印机删除文件",
|
||||
"settings_delete_printer_file_after_print_hint": "仅适用于通过此网桥启动的打印(即由网桥自己上传的文件)——直接从打印机或 Anycubic Slicer 启动的打印任务永远不会被删除,因为它们没有其他备份。",
|
||||
"sf_all": "全部",
|
||||
"sf_err": "✗ 失败",
|
||||
"sf_new": "新",
|
||||
@ -293,6 +314,7 @@
|
||||
"skip_sending": "发送中 …",
|
||||
"skip_success": "对象将被跳过。",
|
||||
"skip_title": "✂ 跳过对象",
|
||||
"slot_copy_from": "从插槽复制颜色…",
|
||||
"slot_edit_color": "颜色",
|
||||
"slot_edit_custom": "例如 PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ 进料",
|
||||
@ -311,15 +333,20 @@
|
||||
"ss_dur": "⏱ 打印时间",
|
||||
"ss_name": "A–Z 名称",
|
||||
"store_delete_confirm": "删除文件?",
|
||||
"store_delete_selected": "删除所选",
|
||||
"store_delete_selected_confirm": "删除已选择的 {n} 个文件?",
|
||||
"store_download": "⬇ 下载",
|
||||
"store_empty": "尚未上传文件。",
|
||||
"store_estimate": "估算",
|
||||
"store_exit_select": "取消",
|
||||
"store_never": "从未打印",
|
||||
"store_no_results": "未找到文件。",
|
||||
"store_print": "▶ 打印",
|
||||
"store_print_confirm": "打印文件?",
|
||||
"store_refresh": "↻ 刷新",
|
||||
"store_search_placeholder": "🔍 搜索…",
|
||||
"store_select_all": "全选",
|
||||
"store_selected_count": "已选择 {n} 个",
|
||||
"store_upload_busy": "⏳ 上传中…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
"store_upload_label_browse": "浏览",
|
||||
@ -339,6 +366,5 @@
|
||||
"update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d",
|
||||
"update_error": "错误",
|
||||
"update_none": "已是最新版本",
|
||||
"update_restarting": "重启中...",
|
||||
"slot_copy_from": "从插槽复制颜色…"
|
||||
}
|
||||
"update_restarting": "重启中..."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user