Compare commits
43 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| f54783ad16 | |||
|
|
b37dfb4dcf | ||
| 52baaa8f70 | |||
| ecc53cd7cb | |||
| 0a9bf6def6 | |||
| 36cb97038f | |||
| 157517ffb4 | |||
| 71664e7e8b | |||
|
|
c4f321c9b0 | ||
| 1f1d60d571 | |||
|
|
9f76d28622 | ||
| cbcb17f45a | |||
|
|
4e7f851799 | ||
| 1d5ac8dc4e | |||
| 64c8bc32d7 | |||
| 0ca7618c85 | |||
| 6d6df59ff2 | |||
| 33b42e64cc | |||
| 16c1a8ee73 | |||
| 884bdfc0c3 | |||
| 8f14580e30 | |||
| 20daa6b6b8 | |||
|
|
2f222a0b93 | ||
| 6e72346129 | |||
| 99bc1797c8 | |||
| e1b9480098 | |||
|
|
03089db6af | ||
|
|
cd4a8ce48e | ||
| bf3f043888 | |||
| b9594d4a22 | |||
|
|
bf94a8f563 | ||
| eda14db897 | |||
| dcf852db49 | |||
| d2c92c2deb | |||
| b541aafc74 | |||
| f2f4447809 | |||
| a3deb33b97 | |||
| 2e2061a269 | |||
| e4bf2c9b95 | |||
| aea6e457f3 | |||
| 0ae8ae59be | |||
| 0322ade606 | |||
| 786fa08ca0 |
@@ -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.
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -3,6 +3,17 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Slot kept showing/printing a stale filament type after a spool swap.** The
|
||||
per-slot profile override (config.ini `[filament_profiles]`) stores only
|
||||
vendor+name and was sticky: swapping the physical filament updated the AMS
|
||||
colour and type live, but the saved profile persisted, so a slot that held
|
||||
e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the
|
||||
OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts.
|
||||
The override is now applied only while its material *family* still matches the
|
||||
loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so
|
||||
within-family swaps never invalidate a valid profile). On a family change the
|
||||
slot falls back to the generic default; the override is not deleted, so
|
||||
reloading the original material reactivates it.
|
||||
- **Filament profiles not isolated between printers in a multi-printer bridge**
|
||||
(issue #74). The slot→profile mapping and `visible_vendors` were stored in a
|
||||
single global `[filament_profiles]` section, so configuring one printer
|
||||
|
||||
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,7 +1,2 @@
|
||||
## Changes in this build
|
||||
|
||||
- Fix: Spoolman spool-slot assignment persistence was silently broken (NameError on config_loader swallowed by bare except) — now uses correct module reference and logs failures; per-printer isolation added so multi-printer setups no longer overwrite each other (Issue #80, PR #83 by @walterioo)
|
||||
- Fix: spool dropdown in print dialog showed "[object Object]" instead of vendor name (PR #83)
|
||||
- Fix: gate_spool_id in MMU object now populated from per-printer slot map instead of hardcoded -1 (PR #83)
|
||||
- Fix: PLA variants (PLA+, PLA Silk, PLA Matte) now recognized as their material family — correct tray_info_idx sent to printer, correct Generic profile synced to OrcaSlicer, and vendor profiles for the variant shown in slot editor dropdown (Issue #82)
|
||||
- UI: PLA+, PLA Silk and PLA Matte buttons added to slot material selector
|
||||
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
146
config_loader.py
146
config_loader.py
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
config_loader.py – lädt Verbindungsparameter aus config/config.ini (primär)
|
||||
oder .env (Fallback / Migration).
|
||||
Umgebungsvariablen haben immer Vorrang.
|
||||
config_loader.py - loads connection parameters from config/config.ini (primary)
|
||||
or .env (fallback / migration).
|
||||
Environment variables always take precedence.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -34,7 +34,7 @@ def _find_env_file() -> pathlib.Path | None:
|
||||
|
||||
|
||||
def _load_env_file(path: pathlib.Path):
|
||||
"""Lädt .env-Datei als Fallback – setzt nur Keys die noch nicht in os.environ sind."""
|
||||
"""Loads the .env file as a fallback - only sets keys not yet in os.environ."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
@@ -47,28 +47,42 @@ def _load_env_file(path: pathlib.Path):
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
# Single source of truth for env-var <-> config.ini mapping. _restart_bridge()
|
||||
# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ
|
||||
# before restarting, so a value removed here or in the UI settings save can
|
||||
# never survive as a stale env var read by the new process. Add new settings
|
||||
# here ONLY - no second list to keep in sync.
|
||||
CONFIG_ENV_MAPPING = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"POWER_ON_URL": (CONFIG_SECTION_CONNECTION, "power_on_url"),
|
||||
"POWER_OFF_URL": (CONFIG_SECTION_CONNECTION, "power_off_url"),
|
||||
"POWER_STATUS_URL": (CONFIG_SECTION_CONNECTION, "power_status_url"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
|
||||
"POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"),
|
||||
"VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
|
||||
|
||||
def _load_config_file(path: pathlib.Path):
|
||||
"""Lädt config.ini und setzt Keys in os.environ (nur wenn nicht bereits gesetzt)."""
|
||||
cfg = configparser.ConfigParser()
|
||||
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.read(path, encoding="utf-8")
|
||||
|
||||
mapping = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
for env_key, (section, option) in mapping.items():
|
||||
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
|
||||
if env_key not in os.environ:
|
||||
try:
|
||||
val = cfg.get(section, option)
|
||||
@@ -102,7 +116,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
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"),
|
||||
@@ -113,8 +127,9 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
}
|
||||
cfg[CONFIG_SECTION_PRINT] = {
|
||||
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
|
||||
}
|
||||
cfg[CONFIG_SECTION_BRIDGE] = {
|
||||
@@ -122,12 +137,12 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
}
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.write("# KX-Bridge Konfigurationsdatei\n")
|
||||
f.write("# Automatisch migriert aus .env\n\n")
|
||||
f.write("# Automatically migrated from .env\n\n")
|
||||
cfg.write(f)
|
||||
|
||||
|
||||
def find_config_path() -> pathlib.Path:
|
||||
"""Gibt den Pfad zur config.ini zurück (auch wenn sie noch nicht existiert)."""
|
||||
"""Returns the path to config.ini (even if it does not exist yet)."""
|
||||
for base in (_BASE, _BASE.parent):
|
||||
config_dir = base / "config"
|
||||
if config_dir.is_dir():
|
||||
@@ -143,7 +158,7 @@ _env_path = _find_env_file()
|
||||
if _config_path:
|
||||
_load_config_file(_config_path)
|
||||
elif _env_path:
|
||||
# Kein config.ini vorhanden → aus .env migrieren
|
||||
# No config.ini present -> migrate from .env
|
||||
_target = find_config_path()
|
||||
migrate_env_to_config(_env_path, _target)
|
||||
_load_config_file(_target)
|
||||
@@ -151,19 +166,19 @@ elif _env_path:
|
||||
|
||||
|
||||
def list_printers() -> list[dict]:
|
||||
"""Liest alle [printer_N]-Sektionen aus config.ini.
|
||||
"""Reads all [printer_N] sections from config.ini.
|
||||
|
||||
Jede Sektion kann folgende Keys haben:
|
||||
Each section may contain the following keys:
|
||||
name, printer_ip, mqtt_port, username, password, mode_id, device_id,
|
||||
bridge_url, default_ams_slot, auto_leveling
|
||||
|
||||
Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind
|
||||
Returns an empty list when no [printer_N] sections exist
|
||||
(Single-Printer-Betrieb via [connection]).
|
||||
"""
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return []
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.read(path, encoding="utf-8")
|
||||
printers: list[dict] = []
|
||||
idx = 1
|
||||
@@ -198,34 +213,34 @@ def _filament_section(printer_id: Optional[str] = None) -> str:
|
||||
|
||||
|
||||
def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
||||
"""Reads the [filament_profiles] section from config.ini.
|
||||
|
||||
With ``printer_id`` set, reads the per-printer ``[filament_profiles_<id>]``
|
||||
section and falls back to the legacy global ``[filament_profiles]`` while
|
||||
that printer has no own section yet.
|
||||
|
||||
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
|
||||
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
|
||||
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
|
||||
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
|
||||
Format per AMS slot - the primary selector is (vendor, name); the `id` is
|
||||
looked up from orca_filaments.json on save and carried along
|
||||
(as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing
|
||||
the same filament_id like 'OGFL99', i.e. the ID is not unique):
|
||||
|
||||
[filament_profiles]
|
||||
slot_0_vendor = Polymaker
|
||||
slot_0_name = PolyTerra PLA
|
||||
slot_0_id = OGFL01
|
||||
|
||||
Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}
|
||||
zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
|
||||
(per filament_type) in der Bridge bleibt dann aktiv.
|
||||
Returns a dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}.
|
||||
Empty/missing slots are NOT included - the default mapping
|
||||
(per filament_type) in the bridge then stays active.
|
||||
|
||||
Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name`
|
||||
fehlt dann und der Aufrufer kann optional aus der orca_filaments.json
|
||||
Backwards compat: old configs with only (vendor, id) stay readable; `name`
|
||||
is then missing and the caller can optionally resolve it from orca_filaments.json
|
||||
rekonstruieren.
|
||||
"""
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return {}
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
if not cfg.has_section(section):
|
||||
@@ -234,7 +249,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
return {}
|
||||
result: dict[int, dict] = {}
|
||||
for key, value in cfg.items(section):
|
||||
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
|
||||
# Expects: slot_<idx>_id or slot_<idx>_vendor or slot_<idx>_name
|
||||
if not key.startswith("slot_"):
|
||||
continue
|
||||
parts = key.split("_", 2)
|
||||
@@ -254,11 +269,11 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
|
||||
|
||||
def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool:
|
||||
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
||||
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
||||
"""Writes the given slot profiles into the [filament_profiles]
|
||||
section of config.ini. Existing entries are completely replaced.
|
||||
|
||||
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
|
||||
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
|
||||
At least vendor+name must be set; id is optional (hint).
|
||||
|
||||
With ``printer_id`` set, writes the per-printer ``[filament_profiles_<id>]``
|
||||
section only — other printers and the legacy global section are untouched.
|
||||
@@ -266,11 +281,11 @@ 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) ist kein Slot-Mapping — beim Ersetzen der
|
||||
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
|
||||
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
|
||||
# replacing the section, otherwise the vendor filter is lost on slot save.
|
||||
# First save of a per-printer section inherits the legacy global filter.
|
||||
preserved_vendors = None
|
||||
if cfg.has_option(section, "visible_vendors"):
|
||||
@@ -297,10 +312,10 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str]
|
||||
|
||||
|
||||
def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
|
||||
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
|
||||
"""Reads [filament_profiles] visible_vendors (comma-separated) from config.ini.
|
||||
|
||||
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
|
||||
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
|
||||
Vendor visibility filter for the slot profile dropdown (Issue #41 option A).
|
||||
Empty list = no restriction (backwards compatible: all vendors).
|
||||
|
||||
With ``printer_id`` set, reads the per-printer section and falls back to the
|
||||
legacy global ``[filament_profiles]`` filter.
|
||||
@@ -308,7 +323,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"):
|
||||
@@ -320,7 +335,7 @@ def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
|
||||
|
||||
|
||||
def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool:
|
||||
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
|
||||
"""Writes visible_vendors into [filament_profiles] without touching the
|
||||
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.
|
||||
|
||||
With ``printer_id`` set, writes the per-printer section. When that section is
|
||||
@@ -330,7 +345,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):
|
||||
@@ -390,7 +405,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"):
|
||||
@@ -410,7 +425,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}
|
||||
@@ -429,17 +444,24 @@ def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
# Häufig verwendete Shortcuts
|
||||
# Frequently used shortcuts
|
||||
PRINTER_IP = get("PRINTER_IP", "")
|
||||
MQTT_PORT = int(get("MQTT_PORT", "9883"))
|
||||
USERNAME = get("MQTT_USERNAME", "")
|
||||
PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
POWER_ON_URL = get("POWER_ON_URL", "")
|
||||
POWER_OFF_URL = get("POWER_OFF_URL", "")
|
||||
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
|
||||
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
|
||||
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "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
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
- ./.env:/app/.env:ro
|
||||
ports:
|
||||
- "7125-7130:7125-7130"
|
||||
# environment:
|
||||
# - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige)
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
env_loader.py – lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsverzeichnis).
|
||||
Umgebungsvariablen haben Vorrang vor .env-Werten.
|
||||
env_loader.py - loads connection parameters from .env (repo root or working directory).
|
||||
Environment variables take precedence over .env values.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -39,15 +39,20 @@ def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
# Häufig verwendete Shortcuts
|
||||
# Frequently used shortcuts
|
||||
PRINTER_IP = get("PRINTER_IP", "")
|
||||
MQTT_PORT = int(get("MQTT_PORT", "9883"))
|
||||
USERNAME = get("MQTT_USERNAME", "")
|
||||
PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
POWER_ON_URL = get("POWER_ON_URL", "")
|
||||
POWER_OFF_URL = get("POWER_OFF_URL", "")
|
||||
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
|
||||
203
kobrax_client.py
203
kobrax_client.py
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
kobrax_client.py – Anycubic Kobra X LAN-MQTT-Client
|
||||
|
||||
Protokoll vollständig rekonstruiert via Sniffer 2026-04-17 (953 Nachrichten).
|
||||
Protocol fully reconstructed via sniffer 2026-04-17 (953 messages).
|
||||
|
||||
Voraussetzungen:
|
||||
- /tmp/anycubic_slicer.crt und .key (aus cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
|
||||
- /tmp/anycubic_slicer.crt and .key (from cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
|
||||
- Drucker im LAN-Modus erreichbar auf Port 9883
|
||||
|
||||
Verwendung:
|
||||
@@ -121,11 +121,18 @@ class KobraXClient:
|
||||
self._buf = b""
|
||||
self._pid = 1
|
||||
self._lock = threading.Lock()
|
||||
# Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der
|
||||
# Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm
|
||||
# ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd.
|
||||
# Generation marker: incremented on every socket swap/close so the
|
||||
# reader thread notices when _reconnect/_do_connect swapped the socket
|
||||
# underneath it (Issue #53). Protects against recv on a stale fd.
|
||||
self._sock_gen = 0
|
||||
self._running = False
|
||||
# Guards _reconnect() against concurrent invocation - both the reader
|
||||
# thread (keepalive ping failure) and publish()/publish_web() (send
|
||||
# failure) can trigger a reconnect independently. Without this, two
|
||||
# threads could race into _do_connect() at once, each opening its own
|
||||
# competing TLS handshake to a printer that likely only accepts one
|
||||
# mTLS session at a time (Issue #105).
|
||||
self._reconnect_lock = threading.Lock()
|
||||
|
||||
# Pending requests by msgid (for response ACK)
|
||||
self._pending_msgid: dict[str, dict] = {}
|
||||
@@ -137,6 +144,11 @@ class KobraXClient:
|
||||
|
||||
# Dedup: last hash per topic suffix to suppress repeated identical messages
|
||||
self._last_rx_hash: dict[str, str] = {}
|
||||
# Debug switch (MQTT_RAW_LOG=1): logs every RX message unfiltered on
|
||||
# INFO, including dedup'd duplicates and topics with no registered
|
||||
# callback - for capturing printer behavior the bridge doesn't
|
||||
# normally surface (e.g. reverse-engineering a rejected command).
|
||||
self._raw_log = os.environ.get("MQTT_RAW_LOG", "").strip().lower() in ("1", "true", "yes")
|
||||
# Fields that change every tick and should be stripped before dedup-hashing
|
||||
_VOLATILE = {"timestamp", "msgid", "progress", "curr_layer",
|
||||
"curr_nozzle_temp", "curr_hotbed_temp",
|
||||
@@ -162,9 +174,9 @@ class KobraXClient:
|
||||
if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
|
||||
raise FileNotFoundError(
|
||||
f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key "
|
||||
f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). "
|
||||
f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke "
|
||||
f"die Dateien dorthin."
|
||||
f"must sit next to the kx-bridge binary ({_SCRIPT_DIR}/). "
|
||||
f"Download anycubic-certs.zip from the Gitea release and extract "
|
||||
f"the files there."
|
||||
)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
@@ -172,9 +184,9 @@ class KobraXClient:
|
||||
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
|
||||
ctx.load_cert_chain(CERT_FILE, KEY_FILE)
|
||||
|
||||
# Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK)
|
||||
# läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht
|
||||
# einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53).
|
||||
# Build the socket as a local variable - the handshake (connect + CONNACK)
|
||||
# runs WITHOUT holding the lock so a slow connect does not freeze
|
||||
# senders. Only the finished socket is swapped in under the lock (#53).
|
||||
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw = socket.create_connection(_ai[0][4], timeout=5)
|
||||
new_sock = ctx.wrap_socket(raw)
|
||||
@@ -196,7 +208,7 @@ class KobraXClient:
|
||||
self._sock = new_sock
|
||||
self._sock_gen += 1
|
||||
self._buf = b""
|
||||
self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln
|
||||
self._subscribe(self._sub_topic()) # takes the lock itself - do not nest
|
||||
log.debug("MQTT connected to %s:%s", self.host, self.port)
|
||||
|
||||
def connect(self):
|
||||
@@ -206,10 +218,10 @@ class KobraXClient:
|
||||
time.sleep(0.3)
|
||||
|
||||
def _ensure_reader(self):
|
||||
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
|
||||
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
|
||||
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
|
||||
würde dann zwar senden, aber auf Antworten ewig warten."""
|
||||
"""Ensures the reader thread is alive. If the reader died after a
|
||||
previous disconnect/reconnect sequence or an unhandled error,
|
||||
received replies would never arrive - publish()
|
||||
would still send but wait for replies forever."""
|
||||
if not self._running:
|
||||
return # gewollter disconnect
|
||||
t = getattr(self, "_reader_thread", None)
|
||||
@@ -231,41 +243,63 @@ class KobraXClient:
|
||||
self._sock = None
|
||||
self._sock_gen += 1
|
||||
|
||||
def _reconnect(self):
|
||||
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
|
||||
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
|
||||
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
|
||||
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
|
||||
ausgeschaltet) zu vermeiden."""
|
||||
log.warning("Verbindung verloren – reconnect…")
|
||||
# Close + Invalidierung unter Lock, damit kein Sender mitten im sendall
|
||||
# auf den gerade geschlossenen Socket trifft (Issue #53).
|
||||
def is_connected(self) -> bool:
|
||||
"""Thread-safe check whether the MQTT socket is currently up. Used by
|
||||
the bridge's poll loop to detect a dead session even when publish()
|
||||
already swallowed the send failure and returned None instead of
|
||||
raising (Issue #105) - a TCP-reachable printer alone doesn't mean the
|
||||
MQTT/TLS session is still alive."""
|
||||
with self._lock:
|
||||
try:
|
||||
if self._sock is not None:
|
||||
self._sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._sock = None
|
||||
self._sock_gen += 1
|
||||
delays = [2, 4, 8, 15, 30, 60]
|
||||
attempt = 0
|
||||
while self._running:
|
||||
delay = delays[min(attempt, len(delays) - 1)]
|
||||
try:
|
||||
self._do_connect()
|
||||
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
|
||||
return True
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
lvl = log.warning if attempt <= 5 else log.debug
|
||||
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
|
||||
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
|
||||
slept = 0.0
|
||||
while slept < delay and self._running:
|
||||
time.sleep(min(0.5, delay - slept))
|
||||
slept += 0.5
|
||||
return False # nur wenn disconnect() gerufen wurde
|
||||
return self._sock is not None
|
||||
|
||||
def _reconnect(self):
|
||||
"""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.
|
||||
|
||||
Guarded by _reconnect_lock (Issue #105): if another thread's reconnect
|
||||
is already in flight, this call waits for it to finish instead of
|
||||
starting a second, competing _do_connect() - the printer likely only
|
||||
accepts one mTLS session at a time, so two parallel handshakes would
|
||||
just interfere with each other and neither converges."""
|
||||
if not self._reconnect_lock.acquire(blocking=False):
|
||||
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
|
||||
lvl = log.warning if attempt <= 5 else log.debug
|
||||
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
|
||||
# Split sleep so disconnect() breaks the loop faster.
|
||||
slept = 0.0
|
||||
while slept < delay and self._running:
|
||||
time.sleep(min(0.5, delay - slept))
|
||||
slept += 0.5
|
||||
return False # only when disconnect() was called
|
||||
finally:
|
||||
self._reconnect_lock.release()
|
||||
|
||||
def _subscribe(self, topic: str):
|
||||
with self._lock:
|
||||
@@ -290,15 +324,15 @@ class KobraXClient:
|
||||
ping_ok = True
|
||||
except Exception:
|
||||
ping_ok = False
|
||||
# _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock
|
||||
# selbst, und threading.Lock ist nicht reentrant (sonst Deadlock).
|
||||
# Call _reconnect() OUTSIDE the lock - it takes the lock
|
||||
# itself, and threading.Lock is not reentrant (deadlock otherwise).
|
||||
if not ping_ok:
|
||||
if self._running and not self._reconnect():
|
||||
break
|
||||
last_ping = time.time()
|
||||
# Aktuellen Socket + Generation unter Lock greifen, damit ein
|
||||
# paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale
|
||||
# fd pollen lässt (Issue #53).
|
||||
# Grab the current socket + generation under the lock so a
|
||||
# parallel _reconnect/_do_connect swap does not leave us polling
|
||||
# a stale fd (Issue #53).
|
||||
with self._lock:
|
||||
sock = self._sock
|
||||
gen = self._sock_gen
|
||||
@@ -306,37 +340,37 @@ class KobraXClient:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
# Idle-Wartezeit OHNE Lock — select probt nur die Bereitschaft, so
|
||||
# blockiert der Reader während Leerlauf nie das gemeinsame Lock.
|
||||
# Idle wait WITHOUT the lock - select only probes readiness, so
|
||||
# the reader never blocks the shared lock while idle.
|
||||
try:
|
||||
ready, _, _ = select.select([sock], [], [], 0.2)
|
||||
except (OSError, ValueError):
|
||||
# fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select)
|
||||
# fd closed/invalid (reconnect or disconnect mid-select)
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if not ready:
|
||||
continue # Leerlauf, kein Lock gehalten
|
||||
continue # idle, no lock held
|
||||
|
||||
# Daten liegen an: Lock kurz greifen für das eine recv, serialisiert
|
||||
# gegen alle sendall-Caller. recv blockiert nicht lange (select sagte
|
||||
# ready, Socket-Timeout ist 0.2s).
|
||||
# Data pending: briefly take the lock for the single recv, serialized
|
||||
# against all sendall callers. recv does not block long (select said
|
||||
# ready, socket timeout is 0.2s).
|
||||
try:
|
||||
with self._lock:
|
||||
# Socket könnte zwischen select und hier ersetzt worden sein.
|
||||
# The socket could have been swapped between select and here.
|
||||
if self._sock_gen != gen or self._sock is not sock:
|
||||
continue
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
|
||||
# Windows SSL can briefly return b"" without a real EOF
|
||||
_empty_count += 1
|
||||
if _empty_count >= 5:
|
||||
raise ConnectionResetError("EOF")
|
||||
continue
|
||||
_empty_count = 0
|
||||
self._buf += data
|
||||
self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt
|
||||
self._drain() # outside the lock - dispatch/event.set() stays prompt
|
||||
except ssl.SSLWantReadError:
|
||||
continue
|
||||
except socket.timeout:
|
||||
@@ -393,6 +427,9 @@ class KobraXClient:
|
||||
def _dispatch(self, topic: str, payload: dict):
|
||||
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
|
||||
@@ -445,8 +482,8 @@ class KobraXClient:
|
||||
# -- Publish + request/response ------------------------------------------
|
||||
|
||||
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
|
||||
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
|
||||
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
|
||||
# If the reader thread is dead for historical reasons, revive it -
|
||||
# otherwise replies would never arrive and event.wait() would time out.
|
||||
self._ensure_reader()
|
||||
msgid = str(uuid.uuid4())
|
||||
payload = json.dumps({
|
||||
@@ -471,7 +508,7 @@ class KobraXClient:
|
||||
report_registered = True
|
||||
|
||||
topic = self._pub_topic(msg_type)
|
||||
# Status-Poll-TX (query/getInfo) ist reines Rauschen (alle paar Sekunden) →
|
||||
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
|
||||
# auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar.
|
||||
_tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO
|
||||
log.log(_tx_level, "TX %-25s action=%-12s data=%s",
|
||||
@@ -531,8 +568,8 @@ class KobraXClient:
|
||||
self._sock.sendall(_build_publish(topic, payload))
|
||||
except Exception as e:
|
||||
log.error("web send error: %s, reconnecting…", e)
|
||||
# Reconnect triggern (analog zu publish()); ohne Retry weil
|
||||
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
|
||||
# Trigger a reconnect (like publish()); no retry because it is
|
||||
# fire-and-forget - the next call will hit the fresh socket
|
||||
# treffen.
|
||||
try:
|
||||
self._reconnect()
|
||||
@@ -579,13 +616,13 @@ class KobraXClient:
|
||||
# -- Part-Skip ("Exclude Object") ---------------------------------------
|
||||
|
||||
def query_skip_objects(self) -> dict | None:
|
||||
"""Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste."""
|
||||
"""Asks the printer for the current object/skip list."""
|
||||
return self.publish("skip", "query_obj")
|
||||
|
||||
def skip_objects(self, names: list[str]) -> dict | None:
|
||||
"""Überspringt die genannten Objekte – auch mid-print möglich.
|
||||
"""Skips the named objects - also possible mid-print.
|
||||
|
||||
Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen
|
||||
Names correspond to the EXCLUDE_OBJECT_DEFINE NAME=... entries
|
||||
im GCode-Header bzw. file_details.objects_skip_parts.
|
||||
"""
|
||||
return self.publish("skip", "start", {"objects_skip_parts": list(names)})
|
||||
@@ -653,14 +690,14 @@ class KobraXClient:
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode()
|
||||
|
||||
# Connect-Timeout kurz (LAN). Während sendall() darf der Socket so
|
||||
# lange brauchen wie nötig — bei großen Dateien (>100 MB) und
|
||||
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
|
||||
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
|
||||
# generös (Drucker verarbeitet die Datei bevor er antwortet).
|
||||
# Short connect timeout (LAN). During sendall() the socket may take
|
||||
# as long as needed - with large files (>100 MB) and slower WiFi
|
||||
# at the printer, pushing otherwise takes >30 s and would falsely
|
||||
# trip the connect timeout. The read timeout afterwards is generous
|
||||
# (the printer processes the file before replying).
|
||||
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock = socket.create_connection(_ai[0][4], timeout=10)
|
||||
sock.settimeout(None) # blocking während Send
|
||||
sock.settimeout(None) # blocking during send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
@@ -717,7 +754,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||||
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
|
||||
parser.add_argument("--monitor", action="store_true",
|
||||
help="Dauerhaft mithören und alle Reports ausgeben")
|
||||
help="Listen continuously and print all reports")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = KobraXClient(
|
||||
@@ -741,7 +778,7 @@ if __name__ == "__main__":
|
||||
|
||||
client.callbacks["*"] = on_msg
|
||||
client.connect()
|
||||
print("[kobrax] Monitor-Modus aktiv (Ctrl-C zum Beenden)")
|
||||
print("[kobrax] Monitor mode active (Ctrl-C to stop)")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
@@ -755,7 +792,7 @@ if __name__ == "__main__":
|
||||
info = client.query_info()
|
||||
if info:
|
||||
d = info.get("data", {})
|
||||
print(f" Drucker: {d.get('printerName')} FW {d.get('version')}")
|
||||
print(f" Printer: {d.get('printerName')} FW {d.get('version')}")
|
||||
print(f" Status: {d.get('state')}")
|
||||
t = d.get("temp", {})
|
||||
print(f" Nozzle: {t.get('curr_nozzle_temp')}°C → {t.get('target_nozzle_temp')}°C")
|
||||
@@ -764,6 +801,6 @@ if __name__ == "__main__":
|
||||
print(f" Upload: {urls.get('fileUploadurl')}")
|
||||
print(f" Kamera: {urls.get('rtspUrl')}")
|
||||
else:
|
||||
print(" Keine Antwort")
|
||||
print(" No response")
|
||||
|
||||
client.disconnect()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
"""OrcaSlicer Filament-Profil Parser.
|
||||
|
||||
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
|
||||
Shared between the generator (tools/gen_orca_filament_list.py) and the
|
||||
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
|
||||
|
||||
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
|
||||
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
|
||||
Reads Orca filament JSON files (system or user profiles) and returns
|
||||
them as a normalized list with (id, name, vendor, type, color).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,8 +13,8 @@ import re
|
||||
|
||||
|
||||
def first_str(value, default: str = "") -> str:
|
||||
"""Orca-Profile speichern manche Felder als ['wert']. Liefert erstes
|
||||
Element als String."""
|
||||
"""Orca profiles store some fields as ['value']. Returns the first
|
||||
element as a string."""
|
||||
if isinstance(value, list):
|
||||
return str(value[0]) if value else default
|
||||
if isinstance(value, str):
|
||||
@@ -37,34 +37,34 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
path_vendor: str | None = None,
|
||||
source_path: str = "",
|
||||
system_index: list | None = None) -> dict | None:
|
||||
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
|
||||
"""Parses a single Orca filament profile into the bridge schema.
|
||||
|
||||
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
|
||||
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
|
||||
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
|
||||
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
|
||||
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
|
||||
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
|
||||
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
|
||||
`by_name` is optionally a {name: [profile, ...]} index for inherits resolution
|
||||
from the raw source tree (generator). For single-file imports (user file
|
||||
from the OrcaSlicer user dir) we pass `system_index` instead -
|
||||
the finished system profile list from orca_filaments.json. This lets
|
||||
us derive filament_id/vendor/type/color via the `inherits` chain from
|
||||
the system parent even when the user profile does not set these
|
||||
fields itself (typically: user override profiles only contain tweaks).
|
||||
|
||||
Liefert {id, name, vendor, type, color} oder None wenn das Profil
|
||||
keine filament_id hat (z.B. abstrakte @base-Templates).
|
||||
Returns {id, name, vendor, type, color} or None when the profile
|
||||
has no filament_id (e.g. abstract @base templates).
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld —
|
||||
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
|
||||
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
|
||||
# User profiles from the OrcaSlicer user dir often set NO "type" field -
|
||||
# it comes from the system parent. We accept that when either "type"
|
||||
# is explicitly "filament" OR an "inherits" points to another profile.
|
||||
if data.get("type") not in (None, "filament") and not data.get("inherits"):
|
||||
return None
|
||||
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
|
||||
# type=filament aber kein parent + keine ID → wertloses Stub
|
||||
# type=filament but no parent + no ID -> worthless stub
|
||||
return None
|
||||
inst = data.get("instantiation", "true")
|
||||
if isinstance(inst, str) and inst.lower() == "false":
|
||||
return None
|
||||
|
||||
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
|
||||
# Build the system name index for the fallback lookup when system_index is set.
|
||||
sys_by_name: dict[str, dict] = {}
|
||||
if system_index:
|
||||
for p in system_index:
|
||||
@@ -92,7 +92,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
return None
|
||||
|
||||
def _resolve_via_system_index(key: str):
|
||||
"""Inherits-Kette über system_index (clean_name-Match)."""
|
||||
"""Inherits chain via system_index (clean_name match)."""
|
||||
parent_raw = data.get("inherits")
|
||||
if not parent_raw or not sys_by_name:
|
||||
return None
|
||||
@@ -100,7 +100,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
sys_p = sys_by_name.get(parent_clean)
|
||||
if not sys_p:
|
||||
return None
|
||||
# System-JSON benutzt schon das normalisierte Schema
|
||||
# The system JSON already uses the normalized schema
|
||||
mapping = {
|
||||
"filament_id": "id",
|
||||
"filament_vendor": "vendor",
|
||||
@@ -136,10 +136,10 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
|
||||
def parse_profile_bytes(blob: bytes, source_name: str = "",
|
||||
system_index: list | None = None) -> dict | None:
|
||||
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
|
||||
`system_index` ist optional die fertige Liste aus orca_filaments.json —
|
||||
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
|
||||
Schema vom System-Parent erben."""
|
||||
"""Reads a single profile from JSON bytes. For the file upload path.
|
||||
`system_index` is optionally the finished list from orca_filaments.json -
|
||||
used for the inherits resolution of user profiles that do not carry the full
|
||||
schema from the system parent."""
|
||||
try:
|
||||
data = json.loads(blob.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
|
||||
64
start.sh
64
start.sh
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# start.sh – KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
|
||||
# start.sh – KX-Bridge starten (zieht das fertige Image aus der Registry)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge"
|
||||
|
||||
# .env anlegen falls nicht vorhanden
|
||||
if [[ ! -f .env ]]; then
|
||||
if [[ -f .env.example ]]; then
|
||||
@@ -30,38 +32,38 @@ if ! docker info > /dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prüfen ob Build nötig ist
|
||||
NEEDS_BUILD=0
|
||||
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
|
||||
echo "[start] Image nicht vorhanden – baue kx-bridge:latest ..."
|
||||
NEEDS_BUILD=1
|
||||
# Release-Kanal abfragen
|
||||
CHANNEL=""
|
||||
if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then
|
||||
CHANNEL="$1"
|
||||
else
|
||||
# Image-Erstellungszeit in Unix-Sekunden
|
||||
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
|
||||
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
|
||||
s=s[:26].rstrip('Z').replace('T',' '); \
|
||||
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
|
||||
|
||||
for f in Dockerfile \
|
||||
bridge/kobrax_moonraker_bridge.py \
|
||||
bridge/kobrax_client.py \
|
||||
bridge/env_loader.py \
|
||||
bridge/requirements.txt \
|
||||
bridge/anycubic_slicer.crt \
|
||||
bridge/anycubic_slicer.key; do
|
||||
if [[ -f "$f" ]]; then
|
||||
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
|
||||
if [[ $FILE_TS -gt $IMAGE_TS ]]; then
|
||||
echo "[start] '$f' ist neuer als das Image – baue neu ..."
|
||||
NEEDS_BUILD=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " Welchen Release-Kanal möchtest du starten?"
|
||||
echo " 1) stable (empfohlen)"
|
||||
echo " 2) nightly (getestete Vorabversion)"
|
||||
echo -n " Auswahl [1]: "
|
||||
read -r CHOICE
|
||||
case "$CHOICE" in
|
||||
2) CHANNEL="nightly" ;;
|
||||
*) CHANNEL="stable" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ $NEEDS_BUILD -eq 1 ]]; then
|
||||
docker build -t kx-bridge:latest .
|
||||
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||
IMAGE_TAG="nightly"
|
||||
else
|
||||
IMAGE_TAG="latest"
|
||||
fi
|
||||
IMAGE="$IMAGE_BASE:$IMAGE_TAG"
|
||||
|
||||
echo "[start] Kanal: $CHANNEL → Image: $IMAGE"
|
||||
echo "[start] Ziehe aktuelles Image ..."
|
||||
docker pull "$IMAGE"
|
||||
|
||||
# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile)
|
||||
if [[ -f docker-compose.yml ]]; then
|
||||
sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml
|
||||
rm -f docker-compose.yml.bak
|
||||
fi
|
||||
|
||||
# Container starten
|
||||
@@ -70,7 +72,7 @@ docker-compose down 2>/dev/null || true
|
||||
docker-compose up -d
|
||||
|
||||
echo ""
|
||||
echo " ✓ KX-Bridge läuft"
|
||||
echo " ✓ KX-Bridge läuft ($CHANNEL)"
|
||||
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
|
||||
echo " Logs : docker-compose logs -f"
|
||||
echo " Stop : docker-compose down"
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
Shared fixtures für KX-Bridge Tests.
|
||||
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
|
||||
"""
|
||||
import sys, types, argparse, pytest, pytest_asyncio
|
||||
import sys, types, argparse, tempfile, pytest, pytest_asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
# ── Pfad ──────────────────────────────────────────────────────────────────────
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
|
||||
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
|
||||
|
||||
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
|
||||
env_mod = types.ModuleType("env_loader")
|
||||
@@ -41,6 +42,7 @@ def make_args(**overrides):
|
||||
device_id = "",
|
||||
host = "127.0.0.1",
|
||||
port = 7125,
|
||||
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
|
||||
)
|
||||
for k, v in overrides.items():
|
||||
setattr(args, k, v)
|
||||
|
||||
111
tests/test_ace_rfid_vendor_matching.py
Normal file
111
tests/test_ace_rfid_vendor_matching.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""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"
|
||||
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
|
||||
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
|
||||
94
tests/test_mqtt_reconnect.py
Normal file
94
tests/test_mqtt_reconnect.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
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
|
||||
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)
|
||||
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"
|
||||
@@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_writes_env(client):
|
||||
"""POST /api/settings schreibt Werte in .env-Datei."""
|
||||
async def test_settings_post_writes_config_ini(client):
|
||||
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process
|
||||
|
||||
resp = await c.post("/api/settings", json={
|
||||
"printer_ip": "10.0.0.5",
|
||||
@@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client):
|
||||
})
|
||||
assert resp.status == 200
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "PRINTER_IP=10.0.0.5" in content
|
||||
assert "MQTT_USERNAME=userABCD" in content
|
||||
assert "DEVICE_ID=deadbeef01234567" in content
|
||||
content = config_path.read_text()
|
||||
assert "printer_ip = 10.0.0.5" in content
|
||||
assert "username = userABCD" in content
|
||||
assert "device_id = deadbeef01234567" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_preserves_existing_keys(client):
|
||||
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
|
||||
"""POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server)."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
config_path.write_text(
|
||||
"[spoolman]\nserver = http://192.168.1.50:7912\n\n"
|
||||
"[connection]\nprinter_ip = old\n"
|
||||
)
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None
|
||||
|
||||
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "GITEA_TOKEN=mytoken" in content
|
||||
assert "PRINTER_IP=10.0.0.99" in content
|
||||
content = config_path.read_text()
|
||||
assert "server = http://192.168.1.50:7912" in content
|
||||
assert "printer_ip = 10.0.0.99" in content
|
||||
|
||||
157
tests/test_slot_profile_material_guard.py
Normal file
157
tests/test_slot_profile_material_guard.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Stale slot-profile guard: suppress a saved per-slot filament profile when the
|
||||
AMS now reports a *different material family* than the profile was assigned for.
|
||||
|
||||
Real-world bug (KX1): slot 1 held a PETG spool and got the profile
|
||||
"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the
|
||||
colour (live) but the saved profile stuck on PETG, so the panel + the slicer
|
||||
hint kept showing/using PETG. Restarting did not help — the override lives in
|
||||
config.ini.
|
||||
|
||||
Fix = non-destructive suppression (Option A): resolve the effective profile as
|
||||
"the saved override only if its material *family* matches the current AMS
|
||||
material; otherwise none (fall back to the generic default)". The override is
|
||||
never deleted, so putting the original material back reactivates it.
|
||||
|
||||
Comparison must be by *family*, never strict string equality — PLA / PLA+ /
|
||||
PLA SILK / PLA MATTE are the same family and must NOT invalidate each other
|
||||
(regression guard for the earlier over-strict material compare).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader.
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type).
|
||||
LIBRARY = [
|
||||
{"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"},
|
||||
{"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"},
|
||||
{"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"},
|
||||
]
|
||||
|
||||
PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"}
|
||||
SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"}
|
||||
UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"}
|
||||
|
||||
|
||||
def _bridge():
|
||||
c = MagicMock()
|
||||
c.callbacks = {}
|
||||
c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxguard-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is
|
||||
return b
|
||||
|
||||
|
||||
# ── _material_family ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_material_family_collapses_pla_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") == "PLA"
|
||||
assert fam("PLA+") == "PLA"
|
||||
assert fam("PLA SILK") == "PLA"
|
||||
assert fam("PLA MATTE") == "PLA"
|
||||
assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction
|
||||
|
||||
|
||||
def test_material_family_collapses_petg_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PETG") == "PETG"
|
||||
assert fam("PETG+") == "PETG"
|
||||
|
||||
|
||||
def test_material_family_distinguishes_pla_from_petg():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") != fam("PETG")
|
||||
assert fam("PLA SILK") != fam("PETG")
|
||||
|
||||
|
||||
def test_material_family_empty_for_empty_input():
|
||||
assert KobraXBridge._material_family("") == ""
|
||||
assert KobraXBridge._material_family(None) == ""
|
||||
|
||||
|
||||
# ── _effective_slot_profile ───────────────────────────────────────────────────
|
||||
|
||||
def test_suppressed_when_family_changes_petg_profile_pla_loaded():
|
||||
"""The exact KX1 bug: PETG profile, AMS now reports PLA → suppress."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PLA") == {}
|
||||
|
||||
|
||||
def test_kept_when_family_matches_petg_profile_petg_loaded():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE
|
||||
|
||||
|
||||
def test_kept_for_pla_variant_no_false_positive():
|
||||
"""PLA+ profile with a PLA SILK spool loaded is the same family → keep."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {1: dict(SILK_PROFILE)}
|
||||
assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE
|
||||
|
||||
|
||||
def test_kept_when_profile_material_unknown_failsafe():
|
||||
"""If the profile is not in the library we cannot know its family → never
|
||||
suppress on uncertainty (fail-safe keeps the user's choice)."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {2: dict(UNKNOWN_PROFILE)}
|
||||
assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE
|
||||
|
||||
|
||||
def test_empty_when_no_override():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {}
|
||||
assert b._effective_slot_profile(3, "PLA") == {}
|
||||
|
||||
|
||||
# ── Integration: display endpoint (the visible panel) ─────────────────────────
|
||||
|
||||
async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded():
|
||||
"""/kx/filament/slots must not show the stale PETG identity once PLA loads."""
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["material"] == "PLA" # AMS truth, always
|
||||
assert row["filament_name"] == "" # stale PETG identity gone
|
||||
assert row["filament_vendor"] == ""
|
||||
|
||||
|
||||
async def test_display_endpoint_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["filament_name"] == "KINGROON PETG Basic"
|
||||
assert row["filament_vendor"] == "KINGROON"
|
||||
|
||||
|
||||
# ── Integration: print path (lane_data sent to OrcaSlicer) ────────────────────
|
||||
|
||||
async def test_lane_data_does_not_leak_stale_petg_identity():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["tray_type"] == "PLA"
|
||||
assert "PETG" not in tray["name"].upper()
|
||||
assert tray["vendor_name"] != "KINGROON"
|
||||
|
||||
|
||||
async def test_lane_data_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["name"] == "KINGROON PETG Basic"
|
||||
assert tray["vendor_name"] == "KINGROON"
|
||||
57
tests/test_update_check.py
Normal file
57
tests/test_update_check.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""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
|
||||
|
||||
|
||||
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)"
|
||||
)
|
||||
@@ -45,7 +45,7 @@ var ACE_DRY_PRESETS={
|
||||
};
|
||||
|
||||
// Spoolman state
|
||||
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanStatus={configured:false,reachable:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanSpools=[];
|
||||
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
|
||||
|
||||
@@ -67,10 +67,12 @@ function _updateSpoolmanStatusDot(){
|
||||
var dot=document.getElementById('spoolman-status-dot');
|
||||
var lbl=document.getElementById('spoolman-status-lbl');
|
||||
if(!dot||!lbl)return;
|
||||
if(_spoolmanStatus.configured){
|
||||
if(!_spoolmanStatus.configured){
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
} else if(_spoolmanStatus.reachable){
|
||||
dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden';
|
||||
} else {
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
dot.style.color='var(--err)';lbl.textContent=(_spoolmanStatus.server||'')+' (nicht erreichbar)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +354,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);
|
||||
@@ -406,6 +416,16 @@ function applyLang(){
|
||||
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
|
||||
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
|
||||
setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)');
|
||||
setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)');
|
||||
var dashLbl=document.getElementById('dash-lbl-edit');
|
||||
if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
setText('dash-lbl-reset',T.dash_reset||'Reset');
|
||||
setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset');
|
||||
var dashPresetStd=document.getElementById('dash-preset-standard');
|
||||
if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard';
|
||||
var dashPresetWide=document.getElementById('dash-preset-wide89');
|
||||
if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop';
|
||||
if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar();
|
||||
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
|
||||
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
|
||||
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
|
||||
@@ -430,9 +450,15 @@ function applyLang(){
|
||||
setText('lbl-password',T.settings_password);
|
||||
setText('lbl-device-id',T.settings_device_id);
|
||||
setText('lbl-mode-id',T.settings_mode_id);
|
||||
setText('modal-sec-power',T.settings_power||'Power Switch');
|
||||
setText('lbl-power-on-url',T.settings_power_on_url||'Power-On URL');
|
||||
setText('lbl-power-off-url',T.settings_power_off_url||'Power-Off URL');
|
||||
setText('lbl-power-status-url',T.settings_power_status_url||'Status URL');
|
||||
setText('lbl-power-hint',T.settings_power_hint||'Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer\'s mains power. Leave empty to hide the power button.');
|
||||
setText('lbl-default-slot',T.settings_default_slot);
|
||||
setText('opt-slot-auto',T.settings_slot_auto);
|
||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
|
||||
setText('lbl-file-ready-mode',T.settings_file_ready_mode);
|
||||
setText('opt-file-ready-dialog',T.settings_file_ready_dialog);
|
||||
setText('opt-file-ready-banner',T.settings_file_ready_banner);
|
||||
@@ -578,6 +604,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;
|
||||
@@ -733,6 +770,17 @@ function applyState(){
|
||||
// connection error banner – nur wenn überhaupt ein Drucker konfiguriert ist
|
||||
var banner=document.getElementById('conn-error-banner');
|
||||
if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}}
|
||||
var pauseBanner=document.getElementById('pause-msg-banner');
|
||||
if(pauseBanner){
|
||||
if(s.pause_msg && s.print_state==='paused'){
|
||||
var codePart = (s.error_code==0) ? ' ' :
|
||||
' [<a href="https://wiki.anycubic.com/en/error-codes/'+s.error_code+'-code" target="_blank">'+s.error_code+'</a>] ';
|
||||
pauseBanner.innerHTML='⏸ '+tr('lbl_pause_reason')+codePart+s.pause_msg;
|
||||
pauseBanner.style.display='block';
|
||||
}else{
|
||||
pauseBanner.style.display='none';
|
||||
}
|
||||
}
|
||||
var bannerVisible=false;
|
||||
var frb=document.getElementById('file-ready-banner');
|
||||
if(frb){
|
||||
@@ -1084,8 +1132,12 @@ function openSettings(){
|
||||
document.getElementById('s-password').value=d.password||'';
|
||||
document.getElementById('s-device-id').value=d.device_id||'';
|
||||
document.getElementById('s-mode-id').value=d.mode_id||'';
|
||||
var pon=document.getElementById('s-power-on-url');if(pon)pon.value=d.power_on_url||'';
|
||||
var poff=document.getElementById('s-power-off-url');if(poff)poff.value=d.power_off_url||'';
|
||||
var pstat=document.getElementById('s-power-status-url');if(pstat)pstat.value=d.power_status_url||'';
|
||||
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
|
||||
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
|
||||
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
|
||||
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
|
||||
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
|
||||
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
|
||||
@@ -1095,6 +1147,7 @@ function openSettings(){
|
||||
var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3;
|
||||
pi.value=sec;
|
||||
}
|
||||
var vhl=document.getElementById('s-verbose-http-log');if(vhl)vhl.checked=!!d.verbose_http_log;
|
||||
renderFilamentMapping(d.filament_profiles||{});
|
||||
renderSpoolmanSlotCard();
|
||||
// Spoolman
|
||||
@@ -1850,12 +1903,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||'',
|
||||
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
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,
|
||||
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||'',
|
||||
spoolman_sync_rate: Math.max(0,parseInt((document.getElementById('s-spoolman-sync-rate')||{}).value||'30',10)),
|
||||
}).then(function(){
|
||||
@@ -1962,8 +2020,314 @@ var pollTimer;
|
||||
});
|
||||
}).catch(function(){});
|
||||
poll();pollTimer=setInterval(poll,ms);
|
||||
setInterval(_loadSpoolmanStatus,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).
|
||||
})();
|
||||
|
||||
// ── Dashboard Free Grid (Issue #89) ────────────────────────────────────────
|
||||
// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js).
|
||||
// Cards move + resize on a 12-column snap grid; layout persisted per browser
|
||||
// in localStorage. Built on documented APIs only:
|
||||
// - float:false (default) -> compact packing, no holes between cards
|
||||
// - staticGrid:true -> locked by default, setStatic(false) in edit mode
|
||||
// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id
|
||||
// - draggable.cancel -> inputs/buttons/img excluded from drag start
|
||||
// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware)
|
||||
var DASH_STORAGE_KEY='dashLayout';
|
||||
var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states
|
||||
var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams'];
|
||||
// Layout arrays in GridStack save()/load() format. cellHeight=60px.
|
||||
var DASH_DEFAULT_LAYOUT=[
|
||||
{id:'camera', x:0,y:0, w:12,h:7},
|
||||
{id:'progress',x:0,y:7, w:12,h:6},
|
||||
{id:'temps', x:0,y:13,w:6, h:7},
|
||||
{id:'motion', x:6,y:13,w:6, h:7},
|
||||
{id:'speed', x:0,y:20,w:6, h:3},
|
||||
{id:'fan', x:6,y:20,w:6, h:3},
|
||||
{id:'ams', x:0,y:23,w:12,h:4},
|
||||
];
|
||||
// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center,
|
||||
// motion right.
|
||||
var DASH_PRESET_WIDE=[
|
||||
{id:'progress',x:0, y:0, w:12,h:4},
|
||||
{id:'camera', x:0, y:4, w:6, h:11},
|
||||
{id:'temps', x:6, y:4, w:3, h:7},
|
||||
{id:'speed', x:6, y:11,w:3, h:2},
|
||||
{id:'fan', x:6, y:13,w:3, h:2},
|
||||
{id:'motion', x:9, y:4, w:3, h:11},
|
||||
{id:'ams', x:0, y:15,w:12,h:4},
|
||||
];
|
||||
var _dashGrid=null;
|
||||
var _dashEditing=false;
|
||||
var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show)
|
||||
|
||||
// GridStack requires .grid-stack-item > .grid-stack-item-content around each
|
||||
// card — wrap the existing .card elements in place (IDs/event handlers survive,
|
||||
// appendChild moves nodes without recreating them).
|
||||
function _dashWrapCards(){
|
||||
var grid=document.getElementById('dash-grid');
|
||||
if(!grid)return;
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
var card=grid.querySelector('[data-card="'+key+'"]');
|
||||
if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return;
|
||||
var item=document.createElement('div');
|
||||
item.className='grid-stack-item';
|
||||
item.setAttribute('gs-id',key);
|
||||
var content=document.createElement('div');
|
||||
content.className='grid-stack-item-content';
|
||||
grid.insertBefore(item,card);
|
||||
item.appendChild(content);
|
||||
content.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function _dashItem(key){
|
||||
return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]');
|
||||
}
|
||||
|
||||
function _dashLoadState(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_STORAGE_KEY);
|
||||
if(!raw)return null;
|
||||
var s=JSON.parse(raw);
|
||||
if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null;
|
||||
return s;
|
||||
}catch(e){return null;}
|
||||
}
|
||||
function _dashSaveState(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({
|
||||
version:DASH_LAYOUT_VERSION,
|
||||
layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only
|
||||
hidden:_dashHidden.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
function initDashGrid(){
|
||||
var el=document.getElementById('dash-grid');
|
||||
if(!el||typeof GridStack==='undefined')return;
|
||||
_dashWrapCards();
|
||||
_dashGrid=GridStack.init({
|
||||
cellHeight:60,
|
||||
margin:8,
|
||||
staticGrid:true, // locked by default; setStatic(false) enables drag+resize
|
||||
columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware)
|
||||
draggable:{cancel:'input,textarea,button,select,option,img,.slider'},
|
||||
},el);
|
||||
var state=_dashLoadState();
|
||||
if(state){
|
||||
_dashHidden=Array.isArray(state.hidden)?state.hidden:[];
|
||||
_dashGrid.load(state.layout,false); // update matching ids, no add/remove
|
||||
_dashHidden.forEach(function(n){
|
||||
var item=_dashItem(n.id);
|
||||
if(item){_dashGrid.removeWidget(item,false);item.style.display='none';}
|
||||
});
|
||||
}else{
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
}
|
||||
_dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();});
|
||||
_dashRefreshPresetDropdown();
|
||||
}
|
||||
|
||||
function toggleDashEdit(){
|
||||
if(!_dashGrid)return;
|
||||
_dashEditing=!_dashEditing;
|
||||
_dashGrid.setStatic(!_dashEditing);
|
||||
document.getElementById('dash-grid').classList.toggle('editing',_dashEditing);
|
||||
document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none';
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls(); else _dashRemoveControls();
|
||||
_dashRenderHiddenBar();
|
||||
if(!_dashEditing)_dashSaveState();
|
||||
}
|
||||
|
||||
// ── Custom presets (user-saved layouts) ────────────────────────────────────
|
||||
var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets';
|
||||
var DASH_BUILTIN_PRESET_IDS=['standard','wide89'];
|
||||
|
||||
function _dashLoadCustomPresets(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY);
|
||||
var obj=raw?JSON.parse(raw):{};
|
||||
return (obj&&typeof obj==='object')?obj:{};
|
||||
}catch(e){return {};}
|
||||
}
|
||||
function _dashSaveCustomPresets(presets){
|
||||
localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets));
|
||||
}
|
||||
|
||||
function _dashRefreshPresetDropdown(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel)return;
|
||||
var current=sel.value;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
// Remove previously-rendered custom <option>s, keep the two built-ins.
|
||||
Array.prototype.slice.call(sel.querySelectorAll('option')).forEach(function(o){
|
||||
if(DASH_BUILTIN_PRESET_IDS.indexOf(o.value)===-1)sel.removeChild(o);
|
||||
});
|
||||
Object.keys(customs).sort().forEach(function(name){
|
||||
var opt=document.createElement('option');
|
||||
opt.value='custom:'+name;
|
||||
opt.textContent=name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if(Array.prototype.some.call(sel.options,function(o){return o.value===current;}))sel.value=current;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function _dashUpdatePresetDeleteBtn(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
var delBtn=document.getElementById('dash-preset-delete-btn');
|
||||
if(!sel||!delBtn)return;
|
||||
var isCustom=sel.value.indexOf('custom:')===0;
|
||||
delBtn.style.display=(_dashEditing&&isCustom)?'':'none';
|
||||
}
|
||||
|
||||
function saveCurrentAsDashPreset(){
|
||||
if(!_dashGrid)return;
|
||||
var name=(prompt(T.dash_preset_name_prompt||'Preset name:')||'').trim();
|
||||
if(!name)return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
customs[name]=_dashGrid.save(false);
|
||||
_dashSaveCustomPresets(customs);
|
||||
_dashRefreshPresetDropdown();
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value='custom:'+name;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function deleteCurrentDashPreset(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel||sel.value.indexOf('custom:')!==0)return;
|
||||
var name=sel.value.slice(7);
|
||||
if(!confirm((T.dash_preset_delete_confirm||'Delete preset "{name}"?').replace('{name}',name)))return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
delete customs[name];
|
||||
_dashSaveCustomPresets(customs);
|
||||
sel.value='standard';
|
||||
_dashRefreshPresetDropdown();
|
||||
resetDashLayout();
|
||||
}
|
||||
|
||||
function _dashInjectControls(){
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var content=document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"] .grid-stack-item-content');
|
||||
if(!content||content.querySelector('.dash-card-controls'))return;
|
||||
var ctrl=document.createElement('div');
|
||||
ctrl.className='dash-card-controls';
|
||||
var hideBtn=document.createElement('button');
|
||||
hideBtn.className='dash-card-ctrl-btn';
|
||||
hideBtn.title=T.dash_hide||'Hide';
|
||||
hideBtn.textContent='👁';
|
||||
hideBtn.addEventListener('click',function(e){e.stopPropagation();dashHideCard(key);});
|
||||
ctrl.appendChild(hideBtn);
|
||||
content.appendChild(ctrl);
|
||||
});
|
||||
}
|
||||
function _dashRemoveControls(){
|
||||
var nodes=document.querySelectorAll('#dash-grid .dash-card-controls');
|
||||
Array.prototype.forEach.call(nodes,function(n){n.parentNode&&n.parentNode.removeChild(n);});
|
||||
}
|
||||
|
||||
function _dashCardLabel(key){
|
||||
var card=document.querySelector('[data-card="'+key+'"]');
|
||||
if(!card)return key;
|
||||
var t=card.querySelector('.card-title');
|
||||
return t?t.textContent.trim():key;
|
||||
}
|
||||
|
||||
function _dashRenderHiddenBar(){
|
||||
var bar=document.getElementById('dash-hidden-bar');
|
||||
if(!bar)return;
|
||||
bar.innerHTML='';
|
||||
if(!_dashEditing||!_dashHidden.length){bar.classList.remove('show');return;}
|
||||
bar.classList.add('show');
|
||||
var label=document.createElement('span');
|
||||
label.textContent=(T.dash_hidden_cards||'Hidden cards')+': ';
|
||||
bar.appendChild(label);
|
||||
_dashHidden.forEach(function(n){
|
||||
var chip=document.createElement('span');
|
||||
chip.className='dash-hidden-chip';
|
||||
chip.appendChild(document.createTextNode(_dashCardLabel(n.id)+' '));
|
||||
var btn=document.createElement('button');
|
||||
btn.textContent='👁';
|
||||
btn.title=T.dash_show||'Show';
|
||||
btn.addEventListener('click',function(){dashShowCard(n.id);});
|
||||
chip.appendChild(btn);
|
||||
bar.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function dashHideCard(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var item=_dashItem(key);
|
||||
if(!item||!_dashGrid)return;
|
||||
// Remember position/size so re-show can restore it.
|
||||
var n=item.gridstackNode||{};
|
||||
_dashHidden.push({id:key,x:n.x,y:n.y,w:n.w,h:n.h});
|
||||
_dashGrid.removeWidget(item,false); // keep DOM element, drop the widget
|
||||
item.style.display='none';
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function dashShowCard(key){
|
||||
var entry=null;
|
||||
_dashHidden=_dashHidden.filter(function(n){if(n.id===key){entry=n;return false;}return true;});
|
||||
var item=_dashItem(key);
|
||||
if(item&&_dashGrid){
|
||||
item.style.display='';
|
||||
_dashGrid.makeWidget(item);
|
||||
if(entry&&entry.w)_dashGrid.update(item,{x:entry.x,y:entry.y,w:entry.w,h:entry.h});
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
}
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
function _dashShowAllHidden(){
|
||||
_dashHidden.slice().forEach(function(n){dashShowCard(n.id);});
|
||||
_dashHidden=[];
|
||||
}
|
||||
|
||||
function resetDashLayout(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.removeItem(DASH_STORAGE_KEY);
|
||||
var sel=document.getElementById('dash-preset');if(sel)sel.value='standard';
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
if(_dashEditing){_dashInjectControls();_dashSaveState();}
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function applyDashPreset(name){
|
||||
if(name==='standard'){resetDashLayout();return;}
|
||||
if(!_dashGrid)return;
|
||||
var layout=null;
|
||||
if(name==='wide89'){
|
||||
layout=DASH_PRESET_WIDE;
|
||||
}else if(name.indexOf('custom:')===0){
|
||||
var customs=_dashLoadCustomPresets();
|
||||
layout=customs[name.slice(7)];
|
||||
}
|
||||
if(!layout)return;
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value=name; // keep the dropdown in sync even when called directly
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(layout,false);
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
// Init now — all DASH_* declarations above have run, and app.js loads at
|
||||
// </body>, so the DOM is ready.
|
||||
initDashGrid();
|
||||
// ── Print actions ──
|
||||
function printAction(a){
|
||||
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
||||
@@ -2422,10 +2786,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');
|
||||
@@ -2470,32 +2866,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;
|
||||
@@ -2528,22 +2899,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){
|
||||
@@ -2563,6 +2968,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){
|
||||
@@ -3362,6 +3995,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>'+
|
||||
@@ -3378,8 +4012,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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
<title>KX-Bridge</title>
|
||||
<link rel="stylesheet" href="/kx/ui/style.css">
|
||||
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
|
||||
<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">
|
||||
<script src="/kx/ui/lib/pickr.min.js"></script>
|
||||
<script src="/kx/ui/lib/gridstack-all.min.js"></script>
|
||||
<body>
|
||||
|
||||
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
|
||||
<div id="pause-msg-banner" style="display:none;background:#b8860b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:997;"></div>
|
||||
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
|
||||
<span>📄 <span id="file-ready-name"></span></span>
|
||||
<button id="file-ready-btn" onclick="startReadyFile()"
|
||||
@@ -137,9 +140,19 @@
|
||||
<main>
|
||||
<!-- ═══ DASHBOARD ═══ -->
|
||||
<div class="panel active" id="panel-dashboard">
|
||||
<div class="grid">
|
||||
<div id="dash-toolbar" style="display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-bottom:10px">
|
||||
<select id="dash-preset" onchange="applyDashPreset(this.value)" style="display:none;padding:4px 8px;font-size:12px;background:var(--raised);color:var(--txt);border:1px solid var(--border);border-radius:6px">
|
||||
<option value="standard" id="dash-preset-standard">Standard</option>
|
||||
<option value="wide89" id="dash-preset-wide89">Desktop breit</option>
|
||||
</select>
|
||||
<button id="dash-preset-delete-btn" onclick="deleteCurrentDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--err);border:1px solid var(--border);border-radius:6px;cursor:pointer">🗑</button>
|
||||
<button id="dash-preset-save-btn" onclick="saveCurrentAsDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">💾 <span id="dash-lbl-save-preset">Als Preset speichern</span></button>
|
||||
<button id="dash-reset-btn" onclick="resetDashLayout()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">↺ <span id="dash-lbl-reset">Zurücksetzen</span></button>
|
||||
<button id="dash-edit-btn" onclick="toggleDashEdit()" style="padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">🖉 <span id="dash-lbl-edit">Dashboard anpassen</span></button>
|
||||
</div>
|
||||
<div class="grid-stack" id="dash-grid">
|
||||
<!-- Kamera -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-camera" data-card="camera" gs-w="12" gs-h="7">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||||
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
@@ -154,7 +167,7 @@
|
||||
<div class="cam-wrap" id="cam-wrap">
|
||||
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
|
||||
<div class="cam-spinner" id="cam-spinner"></div>
|
||||
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
|
||||
<img id="cam-img" draggable="false" style="display:none;width:100%;height:auto" alt="Kamera">
|
||||
<div class="cam-overlay" id="cam-overlay" style="display:none">
|
||||
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
|
||||
</div>
|
||||
@@ -164,7 +177,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-progress" data-card="progress" gs-w="12" gs-h="6">
|
||||
<div class="card-title"><span>◉</span> <span id="d-card-progress">Fortschritt</span></div>
|
||||
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
|
||||
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
|
||||
@@ -210,7 +223,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Temperatursteuerung + Verlauf -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-temps" data-card="temps" gs-w="6" gs-h="7">
|
||||
<div class="card-title"><span>⊙</span> <span id="d-card-temps">Temperaturen</span></div>
|
||||
<div class="temp-card-inner">
|
||||
<div class="temp-block">
|
||||
@@ -253,7 +266,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Achsensteuerung -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-motion" data-card="motion" gs-w="6" gs-h="7">
|
||||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
||||
<!-- XY -->
|
||||
@@ -301,7 +314,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Print Speed -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-speed" data-card="speed" gs-w="6" gs-h="3">
|
||||
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
|
||||
<div style="display:flex;gap:8px;margin-top:4px">
|
||||
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
|
||||
@@ -323,7 +336,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Lüfter -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-fan" data-card="fan" gs-w="6" gs-h="3">
|
||||
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
|
||||
<div class="slider-row">
|
||||
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
|
||||
@@ -338,18 +351,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="d-ace-dry-wrap" style="display:none">
|
||||
<div id="d-ace-dry-grid" style="display:contents"></div>
|
||||
</div>
|
||||
|
||||
<!-- AMS -->
|
||||
<div class="card" style="grid-column:1/-1" id="d-ams-card">
|
||||
<div class="card" id="d-ams-card" data-card="ams" gs-w="12" gs-h="4">
|
||||
<div class="card-title"><span>◫</span> <span id="d-card-ams">Filament</span></div>
|
||||
<div class="ams-slots" id="ams-slots">
|
||||
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ACE-Trocknung: außerhalb des GridStack-Grids, per Drucker-State eingeblendet -->
|
||||
<div id="d-ace-dry-wrap" class="grid" style="display:none;margin-top:16px">
|
||||
<div id="d-ace-dry-grid" style="display:contents"></div>
|
||||
</div>
|
||||
<div id="dash-hidden-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CONSOLE ═══ -->
|
||||
@@ -374,36 +388,81 @@
|
||||
<span id="store-panel-title">🗂 Datei-Browser</span>
|
||||
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
|
||||
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<select id="store-filter" onchange="renderStore()"
|
||||
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<option value="all" id="sf-all">Alle</option>
|
||||
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
|
||||
<option value="failed" id="sf-err">✗ Fehler</option>
|
||||
<option value="never" id="sf-new">Neu</option>
|
||||
</select>
|
||||
<select id="store-sort" onchange="renderStore()"
|
||||
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
|
||||
<option value="date_desc" id="ss-date">↓ Datum</option>
|
||||
<option value="name_asc" id="ss-name">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>
|
||||
|
||||
@@ -489,6 +548,22 @@
|
||||
<input type="text" id="s-mode-id" placeholder="20030">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title"><span>🔌</span> <span id="modal-sec-power">Power Switch</span></div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-power-on-url">Power-On URL</label>
|
||||
<input type="text" id="s-power-on-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20on">
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-power-off-url">Power-Off URL</label>
|
||||
<input type="text" id="s-power-off-url" placeholder="http://192.168.x.x/cm?cmnd=Power%20off">
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-power-status-url">Status URL</label>
|
||||
<input type="text" id="s-power-status-url" placeholder="http://192.168.x.x/cm?cmnd=Power">
|
||||
<small id="lbl-power-hint" style="color:var(--txt2)"></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drucker -->
|
||||
@@ -509,6 +584,10 @@
|
||||
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
|
||||
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
|
||||
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance compensation before print</label>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
|
||||
<select id="s-file-ready-mode">
|
||||
@@ -550,6 +629,10 @@
|
||||
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
|
||||
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-verbose-http-log" style="width:auto;margin:0">
|
||||
<label id="lbl-verbose-http-log" style="margin:0;cursor:pointer" for="s-verbose-http-log">Log every HTTP request (verbose)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%}
|
||||
@@ -95,18 +95,63 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
|
||||
/* ── CARD ── */
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
padding:18px;transition:box-shadow .15s,transform .15s}
|
||||
padding:18px;transition:box-shadow .15s,transform .15s;container-type:inline-size}
|
||||
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
|
||||
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
|
||||
margin-bottom:14px;display:flex;align-items:center;gap:8px}
|
||||
.card-title span{font-size:14px}
|
||||
|
||||
/* ── DASHBOARD FREE GRID (GridStack) ── */
|
||||
/* .grid-stack-item-content is already position:absolute + fills the cell
|
||||
(GridStack's own CSS). The card inside just needs to fill that box — it
|
||||
must NOT be position:absolute itself, or it can sit above/intercept
|
||||
GridStack's resize-handle hit area and drag listeners. */
|
||||
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
|
||||
the cell smaller than the content needs. */
|
||||
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow-y:auto;overflow-x:hidden;box-sizing:border-box}
|
||||
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
|
||||
as the user starts dragging, throwing off GridStack's position math. Kill
|
||||
the hover transform for dashboard cards specifically. */
|
||||
.grid-stack-item-content>.card:hover{transform:none}
|
||||
/* Edit mode: dashed outline + grab cursor on each item */
|
||||
#dash-grid.editing .grid-stack-item-content>.card{cursor:grab;
|
||||
outline:1px dashed var(--accent);outline-offset:-1px;user-select:none}
|
||||
/* GridStack placeholder styled to theme */
|
||||
.grid-stack>.grid-stack-placeholder>.placeholder-content{
|
||||
background:rgba(120,150,255,.12);border:1px dashed var(--accent);border-radius:12px}
|
||||
/* Resize handles only visible in edit mode */
|
||||
#dash-grid:not(.editing) .ui-resizable-handle{display:none!important}
|
||||
/* Per-card controls (hide button) */
|
||||
.dash-card-controls{display:none;position:absolute;top:8px;right:8px;gap:4px;z-index:6}
|
||||
#dash-grid.editing .dash-card-controls{display:flex}
|
||||
.dash-card-ctrl-btn{width:24px;height:24px;border-radius:6px;border:1px solid var(--border);
|
||||
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:12px;
|
||||
display:flex;align-items:center;justify-content:center;line-height:1}
|
||||
.dash-card-ctrl-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||||
#dash-hidden-bar{display:none;flex-wrap:wrap;gap:8px;margin-top:12px;padding:10px;
|
||||
border:1px dashed var(--border);border-radius:10px}
|
||||
#dash-hidden-bar.show{display:flex}
|
||||
.dash-hidden-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px;
|
||||
background:var(--raised);border:1px solid var(--border);border-radius:20px;color:var(--txt2)}
|
||||
.dash-hidden-chip button{background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0}
|
||||
|
||||
@media(max-width:768px){
|
||||
#dash-toolbar{display:none}
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
|
||||
@media(max-width:900px){.hero{grid-template-columns:1fr}}
|
||||
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
|
||||
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
|
||||
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
|
||||
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain;
|
||||
-webkit-user-drag:none;user-select:none}
|
||||
/* Inside the dashboard grid the camera card can be resized taller than the
|
||||
default 320px cap — flex column: title row keeps its height, cam view
|
||||
flexes to fill whatever cell height the user chose. */
|
||||
.grid-stack-item-content>#card-camera{display:flex;flex-direction:column}
|
||||
.grid-stack-item-content .cam-wrap{flex:1;min-height:0;max-height:none}
|
||||
.grid-stack-item-content .cam-wrap img,.grid-stack-item-content .cam-wrap video{max-height:100%;height:100%}
|
||||
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
|
||||
@@ -119,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)}
|
||||
@@ -161,6 +211,12 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
/* ── TEMPS ── */
|
||||
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
@container (max-width:340px){
|
||||
.temp-card-inner{grid-template-columns:1fr}
|
||||
.temp-val{font-size:24px}
|
||||
.temp-edit{flex-wrap:wrap}
|
||||
.temp-input{width:100%}
|
||||
}
|
||||
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
|
||||
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
|
||||
.temp-row{display:flex;align-items:baseline;gap:6px}
|
||||
@@ -266,6 +322,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:
|
||||
.set-cat .nav-text{display:inline}
|
||||
}
|
||||
|
||||
/* ── BROWSER SUB-TABS (uploaded vs. on-printer files) ── */
|
||||
.browser-tab.active{color:var(--accent);border-bottom-color:var(--accent)!important}
|
||||
.browser-tab:hover{color:var(--txt)}
|
||||
.browser-group{display:none}
|
||||
.browser-group.active{display:block}
|
||||
|
||||
/* ── FILE BROWSER UPLOAD ZONE ── */
|
||||
#store-upload-zone{
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"apd_placeholder_name": "z.B. Kobra X Wohnzimmer",
|
||||
"apd_success": "Drucker hinzugefügt, Bridge startet neu…",
|
||||
"apd_title": "Drucker hinzufügen",
|
||||
"browser_tab_printer": "Auf dem Drucker",
|
||||
"browser_tab_uploaded": "Hochgeladen",
|
||||
"btn_cam_start": "▶ Kamera",
|
||||
"btn_cam_start2": "▶ Start",
|
||||
"btn_cam_stop": "◼ Kamera",
|
||||
@@ -68,6 +70,18 @@
|
||||
"card_speed": "Druckgeschwindigkeit",
|
||||
"card_temps": "Temperaturen",
|
||||
"confirm_cancel": "Druck wirklich abbrechen?",
|
||||
"dash_done": "Fertig",
|
||||
"dash_edit": "Dashboard anpassen",
|
||||
"dash_hidden_cards": "Ausgeblendete Karten",
|
||||
"dash_hide": "Ausblenden",
|
||||
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
|
||||
"dash_preset_name_prompt": "Preset-Name:",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Desktop breit",
|
||||
"dash_reset": "Zurücksetzen",
|
||||
"dash_save_preset": "Als Preset speichern",
|
||||
"dash_show": "Einblenden",
|
||||
"dash_toggle_width": "Breite umschalten",
|
||||
"fd_cancel": "Abbrechen",
|
||||
"fd_no_matching_material": "Kein passendes Material",
|
||||
"fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?",
|
||||
@@ -121,6 +135,7 @@
|
||||
"lbl_feed": "Einziehen",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Licht",
|
||||
"lbl_pause_reason": "Druck pausiert:",
|
||||
"lbl_remaining": "Restzeit:",
|
||||
"lbl_slicer_time": "Slicer-Schätzung:",
|
||||
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
|
||||
@@ -199,12 +214,20 @@
|
||||
"panel_temps_chart": "Verlauf (letzte 60 Messungen)",
|
||||
"panel_temps_nozzle": "Düse",
|
||||
"print_auto_leveling": "Auto-Leveling für diesen Druck",
|
||||
"printer_store_delete_confirm": "Datei vom Drucker löschen?",
|
||||
"printer_store_delete_selected_confirm": "{n} ausgewählte Dateien vom Drucker löschen?",
|
||||
"printer_store_empty": "Keine Dateien auf dem Drucker.",
|
||||
"printer_store_unreachable": "Drucker nicht erreichbar oder Abfrage fehlgeschlagen.",
|
||||
"printers_active": "● aktiv",
|
||||
"printers_current": "Aktueller Drucker",
|
||||
"printers_empty_hint": "Noch kein Drucker eingerichtet.",
|
||||
"printers_loading": "Lade…",
|
||||
"printers_none": "Keine Drucker konfiguriert.",
|
||||
"printers_remove": "Drucker entfernen",
|
||||
"printers_power": "Drucker-Stromversorgung schalten",
|
||||
"printers_power_on": "Strom: An",
|
||||
"printers_power_off": "Strom: Aus",
|
||||
"printers_power_off_confirm": "Drucker-Strom ausschalten? Stelle sicher, dass kein Druck läuft.",
|
||||
"printers_remove_confirm": "Drucker \"{name}\" entfernen? Die Bridge startet neu.",
|
||||
"printers_switch": "Wechseln →",
|
||||
"progress_action_clear": "Leeren",
|
||||
@@ -238,6 +261,11 @@
|
||||
"settings_language": "Sprache",
|
||||
"settings_mode_id": "Mode-ID",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Steckdose (Ein/Aus)",
|
||||
"settings_power_on_url": "Einschalt-URL",
|
||||
"settings_power_off_url": "Ausschalt-URL",
|
||||
"settings_power_status_url": "Status-URL",
|
||||
"settings_power_hint": "Optional: einfache HTTP-GET-URLs für eine Steckdose (z.B. Tasmota), die den Drucker per Netzstrom schaltet. Leer lassen blendet den Power-Button aus.",
|
||||
"settings_mqtt_port": "MQTT-Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Profile importieren",
|
||||
@@ -256,7 +284,9 @@
|
||||
"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)",
|
||||
@@ -278,6 +308,7 @@
|
||||
"skip_sending": "Sende …",
|
||||
"skip_success": "Objekte werden übersprungen.",
|
||||
"skip_title": "✂ Objekte überspringen",
|
||||
"slot_copy_from": "Farbe von Slot kopieren…",
|
||||
"slot_edit_color": "Farbe",
|
||||
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Einziehen",
|
||||
@@ -296,15 +327,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",
|
||||
@@ -324,6 +360,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",
|
||||
@@ -68,6 +70,18 @@
|
||||
"card_speed": "Print Speed",
|
||||
"card_temps": "Temperatures",
|
||||
"confirm_cancel": "Really cancel the print?",
|
||||
"dash_done": "Done",
|
||||
"dash_edit": "Customize dashboard",
|
||||
"dash_hidden_cards": "Hidden cards",
|
||||
"dash_hide": "Hide",
|
||||
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
|
||||
"dash_preset_name_prompt": "Preset name:",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Wide desktop",
|
||||
"dash_reset": "Reset",
|
||||
"dash_save_preset": "Save as preset",
|
||||
"dash_show": "Show",
|
||||
"dash_toggle_width": "Toggle width",
|
||||
"fd_cancel": "Cancel",
|
||||
"fd_no_matching_material": "No matching material",
|
||||
"fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?",
|
||||
@@ -121,6 +135,7 @@
|
||||
"lbl_feed": "Load",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Light",
|
||||
"lbl_pause_reason": "Print paused:",
|
||||
"lbl_remaining": "Remaining:",
|
||||
"lbl_slicer_time": "Slicer estimate:",
|
||||
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
|
||||
@@ -199,12 +214,20 @@
|
||||
"panel_temps_chart": "History (last 60 readings)",
|
||||
"panel_temps_nozzle": "Nozzle",
|
||||
"print_auto_leveling": "Auto-Leveling",
|
||||
"printer_store_delete_confirm": "Delete file from the printer?",
|
||||
"printer_store_delete_selected_confirm": "Delete {n} selected files from the printer?",
|
||||
"printer_store_empty": "No files on the printer.",
|
||||
"printer_store_unreachable": "Printer unreachable or query failed.",
|
||||
"printers_active": "● active",
|
||||
"printers_current": "Current printer",
|
||||
"printers_empty_hint": "No printer set up yet.",
|
||||
"printers_loading": "Loading…",
|
||||
"printers_none": "No printers configured.",
|
||||
"printers_remove": "Remove printer",
|
||||
"printers_power": "Toggle printer power",
|
||||
"printers_power_on": "Power: On",
|
||||
"printers_power_off": "Power: Off",
|
||||
"printers_power_off_confirm": "Turn printer power off? Make sure no print is running.",
|
||||
"printers_remove_confirm": "Remove printer \"{name}\"? The bridge will restart.",
|
||||
"printers_switch": "Switch →",
|
||||
"progress_action_clear": "Clear",
|
||||
@@ -238,6 +261,11 @@
|
||||
"settings_language": "Language",
|
||||
"settings_mode_id": "Mode ID",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Power Switch",
|
||||
"settings_power_on_url": "Power-On URL",
|
||||
"settings_power_off_url": "Power-Off URL",
|
||||
"settings_power_status_url": "Status URL",
|
||||
"settings_power_hint": "Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer's mains power. Leave empty to hide the power button.",
|
||||
"settings_mqtt_port": "MQTT Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Import profiles",
|
||||
@@ -256,7 +284,9 @@
|
||||
"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)",
|
||||
@@ -278,6 +308,7 @@
|
||||
"skip_sending": "Sending …",
|
||||
"skip_success": "Objects will be skipped.",
|
||||
"skip_title": "✂ Skip objects",
|
||||
"slot_copy_from": "Copy color from slot…",
|
||||
"slot_edit_color": "Color",
|
||||
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Load",
|
||||
@@ -296,15 +327,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",
|
||||
@@ -324,6 +360,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",
|
||||
@@ -68,6 +70,18 @@
|
||||
"card_speed": "Velocidad de impresión",
|
||||
"card_temps": "Temperaturas",
|
||||
"confirm_cancel": "¿Realmente cancelar la impresión?",
|
||||
"dash_done": "Listo",
|
||||
"dash_edit": "Personalizar panel",
|
||||
"dash_hidden_cards": "Tarjetas ocultas",
|
||||
"dash_hide": "Ocultar",
|
||||
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
|
||||
"dash_preset_name_prompt": "Nombre del preset:",
|
||||
"dash_preset_standard": "Estándar",
|
||||
"dash_preset_wide89": "Escritorio ancho",
|
||||
"dash_reset": "Restablecer",
|
||||
"dash_save_preset": "Guardar como preset",
|
||||
"dash_show": "Mostrar",
|
||||
"dash_toggle_width": "Cambiar ancho",
|
||||
"fd_cancel": "Cancelar",
|
||||
"fd_no_matching_material": "No hay material compatible",
|
||||
"fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?",
|
||||
@@ -121,6 +135,7 @@
|
||||
"lbl_feed": "Cargar",
|
||||
"lbl_layers": "Capa",
|
||||
"lbl_light": "💡 Luz",
|
||||
"lbl_pause_reason": "Impresión pausada:",
|
||||
"lbl_remaining": "Restante:",
|
||||
"lbl_slicer_time": "Estimación del slicer:",
|
||||
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
|
||||
@@ -199,12 +214,20 @@
|
||||
"panel_temps_chart": "Historial (últimas 60 lecturas)",
|
||||
"panel_temps_nozzle": "Boquilla",
|
||||
"print_auto_leveling": "Autonivelado para esta impresión",
|
||||
"printer_store_delete_confirm": "¿Eliminar archivo de la impresora?",
|
||||
"printer_store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados de la impresora?",
|
||||
"printer_store_empty": "No hay archivos en la impresora.",
|
||||
"printer_store_unreachable": "Impresora inaccesible o consulta fallida.",
|
||||
"printers_active": "● activa",
|
||||
"printers_current": "Impresora actual",
|
||||
"printers_empty_hint": "Aún no hay impresora configurada.",
|
||||
"printers_loading": "Cargando…",
|
||||
"printers_none": "No hay impresoras configuradas.",
|
||||
"printers_remove": "Eliminar impresora",
|
||||
"printers_power": "Alternar alimentación de la impresora",
|
||||
"printers_power_on": "Alimentación: Encendida",
|
||||
"printers_power_off": "Alimentación: Apagada",
|
||||
"printers_power_off_confirm": "¿Apagar la alimentación de la impresora? Asegúrate de que no haya ninguna impresión en curso.",
|
||||
"printers_remove_confirm": "¿Eliminar impresora \"{name}\"? El bridge se reiniciará.",
|
||||
"printers_switch": "Cambiar →",
|
||||
"progress_action_clear": "Vaciar",
|
||||
@@ -238,6 +261,11 @@
|
||||
"settings_language": "Idioma",
|
||||
"settings_mode_id": "ID de modo",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Enchufe inteligente",
|
||||
"settings_power_on_url": "URL de encendido",
|
||||
"settings_power_off_url": "URL de apagado",
|
||||
"settings_power_status_url": "URL de estado",
|
||||
"settings_power_hint": "Opcional: URLs HTTP GET para un enchufe inteligente (p.ej. Tasmota) que controla la alimentación de la impresora. Déjalo vacío para ocultar el botón de encendido.",
|
||||
"settings_mqtt_port": "MQTT Port",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importar perfiles",
|
||||
@@ -256,7 +284,9 @@
|
||||
"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)",
|
||||
@@ -278,6 +308,7 @@
|
||||
"skip_sending": "Enviando …",
|
||||
"skip_success": "Se omitirán los objetos.",
|
||||
"skip_title": "✂ Omitir objetos",
|
||||
"slot_copy_from": "Copiar color del slot…",
|
||||
"slot_edit_color": "Color",
|
||||
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Cargar",
|
||||
@@ -296,15 +327,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",
|
||||
@@ -324,6 +360,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",
|
||||
@@ -121,6 +123,7 @@
|
||||
"lbl_feed": "Charger",
|
||||
"lbl_layers": "Couche",
|
||||
"lbl_light": "💡 Lumière",
|
||||
"lbl_pause_reason": "Impression en pause :",
|
||||
"lbl_remaining": "Restant :",
|
||||
"lbl_slicer_time": "Estimation slicer :",
|
||||
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
|
||||
@@ -199,12 +202,20 @@
|
||||
"panel_temps_chart": "Historique (60 dernières valeurs)",
|
||||
"panel_temps_nozzle": "Buse",
|
||||
"print_auto_leveling": "Mise à niveau auto pour cette impression",
|
||||
"printer_store_delete_confirm": "Supprimer le fichier de l'imprimante ?",
|
||||
"printer_store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés de l'imprimante ?",
|
||||
"printer_store_empty": "Aucun fichier sur l'imprimante.",
|
||||
"printer_store_unreachable": "Imprimante injoignable ou requête échouée.",
|
||||
"printers_active": "● actif",
|
||||
"printers_current": "Imprimante actuelle",
|
||||
"printers_empty_hint": "Aucune imprimante configurée.",
|
||||
"printers_loading": "Chargement…",
|
||||
"printers_none": "Aucune imprimante configurée.",
|
||||
"printers_remove": "Supprimer l'imprimante",
|
||||
"printers_power": "Basculer l'alimentation de l'imprimante",
|
||||
"printers_power_on": "Alimentation : Allumée",
|
||||
"printers_power_off": "Alimentation : Éteinte",
|
||||
"printers_power_off_confirm": "Éteindre l'alimentation de l'imprimante ? Assurez-vous qu'aucune impression n'est en cours.",
|
||||
"printers_remove_confirm": "Supprimer l'imprimante \"{name}\" ? Le bridge va redémarrer.",
|
||||
"printers_switch": "Changer →",
|
||||
"progress_action_clear": "Vider",
|
||||
@@ -238,6 +249,11 @@
|
||||
"settings_language": "Langue",
|
||||
"settings_mode_id": "ID du mode",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Prise électrique",
|
||||
"settings_power_on_url": "URL d'allumage",
|
||||
"settings_power_off_url": "URL d'extinction",
|
||||
"settings_power_status_url": "URL de statut",
|
||||
"settings_power_hint": "Optionnel : URL HTTP GET pour une prise connectée (ex. Tasmota) contrôlant l'alimentation secteur de l'imprimante. Laisser vide masque le bouton d'alimentation.",
|
||||
"settings_mqtt_port": "Port MQTT",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importer des profils",
|
||||
@@ -278,6 +294,7 @@
|
||||
"skip_sending": "Envoi …",
|
||||
"skip_success": "Les objets seront ignorés.",
|
||||
"skip_title": "✂ Ignorer des objets",
|
||||
"slot_copy_from": "Copier la couleur du slot…",
|
||||
"slot_edit_color": "Couleur",
|
||||
"slot_edit_custom": "ex. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Charger",
|
||||
@@ -296,15 +313,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",
|
||||
@@ -324,6 +346,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",
|
||||
@@ -121,6 +123,7 @@
|
||||
"lbl_feed": "Carica",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Luce",
|
||||
"lbl_pause_reason": "Stampa in pausa:",
|
||||
"lbl_remaining": "Rimanente:",
|
||||
"lbl_slicer_time": "Stima slicer:",
|
||||
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
|
||||
@@ -199,12 +202,20 @@
|
||||
"panel_temps_chart": "Cronologia (ultime 60 letture)",
|
||||
"panel_temps_nozzle": "Ugello",
|
||||
"print_auto_leveling": "Livellamento automatico",
|
||||
"printer_store_delete_confirm": "Eliminare il file dalla stampante?",
|
||||
"printer_store_delete_selected_confirm": "Eliminare {n} file selezionati dalla stampante?",
|
||||
"printer_store_empty": "Nessun file sulla stampante.",
|
||||
"printer_store_unreachable": "Stampante non raggiungibile o richiesta fallita.",
|
||||
"printers_active": "● attiva",
|
||||
"printers_current": "Stampante corrente",
|
||||
"printers_empty_hint": "Nessuna stampante ancora configurata.",
|
||||
"printers_loading": "Caricamento in corso…",
|
||||
"printers_none": "Nessuna stampante configurata.",
|
||||
"printers_remove": "Rimuovi stampante",
|
||||
"printers_power": "Attiva/disattiva alimentazione stampante",
|
||||
"printers_power_on": "Alimentazione: Accesa",
|
||||
"printers_power_off": "Alimentazione: Spenta",
|
||||
"printers_power_off_confirm": "Spegnere l'alimentazione della stampante? Assicurati che non sia in corso alcuna stampa.",
|
||||
"printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
|
||||
"printers_switch": "Cambia →",
|
||||
"progress_action_clear": "Cancella",
|
||||
@@ -238,6 +249,11 @@
|
||||
"settings_language": "Lingua",
|
||||
"settings_mode_id": "ID modalità",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "Presa elettrica",
|
||||
"settings_power_on_url": "URL accensione",
|
||||
"settings_power_off_url": "URL spegnimento",
|
||||
"settings_power_status_url": "URL stato",
|
||||
"settings_power_hint": "Opzionale: URL HTTP GET per una presa smart (es. Tasmota) che controlla l'alimentazione della stampante. Lasciare vuoto per nascondere il pulsante di accensione.",
|
||||
"settings_mqtt_port": "Porta MQTT",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "Importa profili",
|
||||
@@ -278,6 +294,7 @@
|
||||
"skip_sending": "Invio in corso …",
|
||||
"skip_success": "Gli oggetti verranno saltati.",
|
||||
"skip_title": "✂ Salta oggetti",
|
||||
"slot_copy_from": "Copia colore dallo slot…",
|
||||
"slot_edit_color": "Colore",
|
||||
"slot_edit_custom": "es. PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ Carica",
|
||||
@@ -296,15 +313,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",
|
||||
@@ -324,6 +346,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": "◼ 相机",
|
||||
@@ -68,6 +70,18 @@
|
||||
"card_speed": "打印速度",
|
||||
"card_temps": "温度",
|
||||
"confirm_cancel": "确定要取消打印吗?",
|
||||
"dash_done": "完成",
|
||||
"dash_edit": "自定义仪表盘",
|
||||
"dash_hidden_cards": "隐藏的卡片",
|
||||
"dash_hide": "隐藏",
|
||||
"dash_preset_delete_confirm": "删除预设 \"{name}\"?",
|
||||
"dash_preset_name_prompt": "预设名称:",
|
||||
"dash_preset_standard": "标准",
|
||||
"dash_preset_wide89": "宽屏桌面",
|
||||
"dash_reset": "重置",
|
||||
"dash_save_preset": "另存为预设",
|
||||
"dash_show": "显示",
|
||||
"dash_toggle_width": "切换宽度",
|
||||
"fd_cancel": "取消",
|
||||
"fd_no_matching_material": "无匹配材料",
|
||||
"fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?",
|
||||
@@ -121,6 +135,7 @@
|
||||
"lbl_feed": "进料",
|
||||
"lbl_layers": "层",
|
||||
"lbl_light": "💡 灯光",
|
||||
"lbl_pause_reason": "打印已暂停:",
|
||||
"lbl_remaining": "剩余时间:",
|
||||
"lbl_slicer_time": "切片预估:",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=打印结束)",
|
||||
@@ -199,12 +214,20 @@
|
||||
"panel_temps_chart": "历史 (最近 60 次读数)",
|
||||
"panel_temps_nozzle": "喷嘴",
|
||||
"print_auto_leveling": "本次打印自动调平",
|
||||
"printer_store_delete_confirm": "从打印机删除文件?",
|
||||
"printer_store_delete_selected_confirm": "从打印机删除选中的 {n} 个文件?",
|
||||
"printer_store_empty": "打印机上没有文件。",
|
||||
"printer_store_unreachable": "无法连接打印机或查询失败。",
|
||||
"printers_active": "● 活动",
|
||||
"printers_current": "当前打印机",
|
||||
"printers_empty_hint": "尚未设置打印机。",
|
||||
"printers_loading": "加载中…",
|
||||
"printers_none": "未配置打印机。",
|
||||
"printers_remove": "移除打印机",
|
||||
"printers_power": "切换打印机电源",
|
||||
"printers_power_on": "电源:开",
|
||||
"printers_power_off": "电源:关",
|
||||
"printers_power_off_confirm": "关闭打印机电源?请确认当前没有正在进行的打印任务。",
|
||||
"printers_remove_confirm": "移除打印机 \"{name}\"? Bridge 将重启。",
|
||||
"printers_switch": "切换 →",
|
||||
"progress_action_clear": "清除",
|
||||
@@ -238,6 +261,11 @@
|
||||
"settings_language": "语言",
|
||||
"settings_mode_id": "模式 ID",
|
||||
"settings_mode_id_placeholder": "20030",
|
||||
"settings_power": "电源插座",
|
||||
"settings_power_on_url": "开机 URL",
|
||||
"settings_power_off_url": "关机 URL",
|
||||
"settings_power_status_url": "状态 URL",
|
||||
"settings_power_hint": "可选:智能插座(如 Tasmota)的 HTTP GET 控制 URL,用于控制打印机的电源。留空则隐藏电源按钮。",
|
||||
"settings_mqtt_port": "MQTT 端口",
|
||||
"settings_mqtt_username_placeholder": "userXXXXXXXX",
|
||||
"settings_orca_profiles_import": "导入配置文件",
|
||||
@@ -256,7 +284,9 @@
|
||||
"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": "可见厂商(配置下拉框)",
|
||||
@@ -278,6 +308,7 @@
|
||||
"skip_sending": "发送中 …",
|
||||
"skip_success": "对象将被跳过。",
|
||||
"skip_title": "✂ 跳过对象",
|
||||
"slot_copy_from": "从插槽复制颜色…",
|
||||
"slot_edit_color": "颜色",
|
||||
"slot_edit_custom": "例如 PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ 进料",
|
||||
@@ -296,15 +327,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": "浏览",
|
||||
@@ -324,6 +360,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