Compare commits
30 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 |
@@ -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.
|
||||
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,8 +1,2 @@
|
||||
## Changes in this build
|
||||
|
||||
- Fix: the poll interval setting was saved to config.ini but never actually applied — the poll loop always used a hardcoded 3s wait
|
||||
- Fix: Spoolman status showed a green "connected" dot even when the server was unreachable — reachability is now rechecked periodically and shown accurately (green/red)
|
||||
- Fix: **settings could silently revert after saving** — the bridge restart that applies settings didn't clean up all the relevant environment variables, so the old value could win over the one just saved (affected poll interval, resonance compensation, Docker host IP, and the new HTTP log toggle below). Fixed at the root so this class of bug can't recur for future settings.
|
||||
- Feat: new "Log every HTTP request (verbose)" toggle in Settings — off by default, since aiohttp's per-request access log was drowning out the bridge's own logs with the frontend's 2s polling
|
||||
|
||||
**A stable release is coming soon** with the fixes and features from the last several nightly builds. As usual it will be published as ready-to-run binaries (Linux amd64/arm64, Windows) alongside the Docker image.
|
||||
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ CONFIG_ENV_MAPPING = {
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"POWER_ON_URL": (CONFIG_SECTION_CONNECTION, "power_on_url"),
|
||||
"POWER_OFF_URL": (CONFIG_SECTION_CONNECTION, "power_off_url"),
|
||||
"POWER_STATUS_URL": (CONFIG_SECTION_CONNECTION, "power_status_url"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
@@ -76,7 +79,7 @@ CONFIG_ENV_MAPPING = {
|
||||
|
||||
def _load_config_file(path: pathlib.Path):
|
||||
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.read(path, encoding="utf-8")
|
||||
|
||||
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
|
||||
@@ -113,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"),
|
||||
@@ -175,7 +178,7 @@ def list_printers() -> list[dict]:
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return []
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.read(path, encoding="utf-8")
|
||||
printers: list[dict] = []
|
||||
idx = 1
|
||||
@@ -237,7 +240,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return {}
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
if not cfg.has_section(section):
|
||||
@@ -278,7 +281,7 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str]
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
|
||||
@@ -320,7 +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"):
|
||||
@@ -342,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):
|
||||
@@ -402,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"):
|
||||
@@ -422,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}
|
||||
@@ -448,6 +451,9 @@ USERNAME = get("MQTT_USERNAME", "")
|
||||
PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
POWER_ON_URL = get("POWER_ON_URL", "")
|
||||
POWER_OFF_URL = get("POWER_OFF_URL", "")
|
||||
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,9 @@ USERNAME = get("MQTT_USERNAME", "")
|
||||
PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
POWER_ON_URL = get("POWER_ON_URL", "")
|
||||
POWER_OFF_URL = get("POWER_OFF_URL", "")
|
||||
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
|
||||
@@ -126,6 +126,13 @@ class KobraXClient:
|
||||
# underneath it (Issue #53). Protects against recv on a stale fd.
|
||||
self._sock_gen = 0
|
||||
self._running = False
|
||||
# Guards _reconnect() against concurrent invocation - both the reader
|
||||
# thread (keepalive ping failure) and publish()/publish_web() (send
|
||||
# failure) can trigger a reconnect independently. Without this, two
|
||||
# threads could race into _do_connect() at once, each opening its own
|
||||
# competing TLS handshake to a printer that likely only accepts one
|
||||
# mTLS session at a time (Issue #105).
|
||||
self._reconnect_lock = threading.Lock()
|
||||
|
||||
# Pending requests by msgid (for response ACK)
|
||||
self._pending_msgid: dict[str, dict] = {}
|
||||
@@ -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",
|
||||
@@ -231,41 +243,63 @@ class KobraXClient:
|
||||
self._sock = None
|
||||
self._sock_gen += 1
|
||||
|
||||
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:
|
||||
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."""
|
||||
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
|
||||
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:
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
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)"
|
||||
)
|
||||
@@ -354,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);
|
||||
@@ -409,6 +417,15 @@ function applyLang(){
|
||||
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)');
|
||||
@@ -433,6 +450,11 @@ function applyLang(){
|
||||
setText('lbl-password',T.settings_password);
|
||||
setText('lbl-device-id',T.settings_device_id);
|
||||
setText('lbl-mode-id',T.settings_mode_id);
|
||||
setText('modal-sec-power',T.settings_power||'Power Switch');
|
||||
setText('lbl-power-on-url',T.settings_power_on_url||'Power-On URL');
|
||||
setText('lbl-power-off-url',T.settings_power_off_url||'Power-Off URL');
|
||||
setText('lbl-power-status-url',T.settings_power_status_url||'Status URL');
|
||||
setText('lbl-power-hint',T.settings_power_hint||'Optional: plain HTTP GET URLs for a smart plug (e.g. Tasmota) controlling the printer\'s mains power. Leave empty to hide the power button.');
|
||||
setText('lbl-default-slot',T.settings_default_slot);
|
||||
setText('opt-slot-auto',T.settings_slot_auto);
|
||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||
@@ -582,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;
|
||||
@@ -737,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){
|
||||
@@ -1088,6 +1132,9 @@ 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;
|
||||
@@ -1856,6 +1903,9 @@ function saveSettings(){
|
||||
password: document.getElementById('s-password').value,
|
||||
device_id: document.getElementById('s-device-id').value,
|
||||
mode_id: document.getElementById('s-mode-id').value,
|
||||
power_on_url: (document.getElementById('s-power-on-url')||{}).value||'',
|
||||
power_off_url: (document.getElementById('s-power-off-url')||{}).value||'',
|
||||
power_status_url: (document.getElementById('s-power-status-url')||{}).value||'',
|
||||
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
||||
@@ -1971,8 +2021,313 @@ 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()})
|
||||
@@ -2431,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');
|
||||
@@ -2479,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;
|
||||
@@ -2537,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){
|
||||
@@ -2572,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){
|
||||
@@ -3371,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>'+
|
||||
@@ -3387,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 -->
|
||||
|
||||
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",
|
||||
@@ -212,7 +235,6 @@
|
||||
"progress_action_slots": "Slots zuordnen",
|
||||
"settings_auto_leveling": "Auto-Leveling vor Druck",
|
||||
"settings_auto_leveling_label": "Auto-Leveling vor dem Druck",
|
||||
"settings_vibration_compensation": "Resonanzkompensation",
|
||||
"settings_btn_tooltip": "Einstellungen",
|
||||
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
|
||||
"settings_cat_connection": "Verbindung",
|
||||
@@ -239,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",
|
||||
@@ -246,7 +273,6 @@
|
||||
"settings_password": "MQTT-Passwort",
|
||||
"settings_poll": "Poll-Intervall (Sekunden)",
|
||||
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
|
||||
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
|
||||
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
|
||||
"settings_print": "Druckeinstellungen",
|
||||
"settings_printer_ip": "Drucker-IP",
|
||||
@@ -258,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)",
|
||||
@@ -280,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",
|
||||
@@ -298,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",
|
||||
@@ -326,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",
|
||||
@@ -212,7 +235,6 @@
|
||||
"progress_action_slots": "Map slots",
|
||||
"settings_auto_leveling": "Auto-Leveling Default",
|
||||
"settings_auto_leveling_label": "Auto-Leveling before print",
|
||||
"settings_vibration_compensation": "Resonance Compensation",
|
||||
"settings_btn_tooltip": "Settings",
|
||||
"settings_camera_on_print": "Turn camera on at print start",
|
||||
"settings_cat_connection": "Connection",
|
||||
@@ -239,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",
|
||||
@@ -246,7 +273,6 @@
|
||||
"settings_password": "MQTT Password",
|
||||
"settings_poll": "Poll Interval (seconds)",
|
||||
"settings_poll_interval_hint": "How often the bridge queries printer status",
|
||||
"settings_verbose_http_log": "Log every HTTP request (verbose)",
|
||||
"settings_poll_interval_label": "Poll Interval (seconds)",
|
||||
"settings_print": "Print Settings",
|
||||
"settings_printer_ip": "Printer IP",
|
||||
@@ -258,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)",
|
||||
@@ -280,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",
|
||||
@@ -298,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",
|
||||
@@ -326,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",
|
||||
@@ -212,7 +235,6 @@
|
||||
"progress_action_slots": "Asignar ranuras",
|
||||
"settings_auto_leveling": "Autonivelado antes de imprimir",
|
||||
"settings_auto_leveling_label": "Autonivelado antes de imprimir",
|
||||
"settings_vibration_compensation": "Compensación de resonancia",
|
||||
"settings_btn_tooltip": "Ajustes",
|
||||
"settings_camera_on_print": "Encender cámara al iniciar impresión",
|
||||
"settings_cat_connection": "Conexión",
|
||||
@@ -239,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",
|
||||
@@ -246,7 +273,6 @@
|
||||
"settings_password": "Contraseña MQTT",
|
||||
"settings_poll": "Intervalo de sondeo (segundos)",
|
||||
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
|
||||
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
|
||||
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
|
||||
"settings_print": "Ajustes de impresión",
|
||||
"settings_printer_ip": "IP de impresora",
|
||||
@@ -258,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)",
|
||||
@@ -280,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",
|
||||
@@ -298,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",
|
||||
@@ -326,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": "清除",
|
||||
@@ -212,7 +235,6 @@
|
||||
"progress_action_slots": "分配槽位",
|
||||
"settings_auto_leveling": "打印前自动调平",
|
||||
"settings_auto_leveling_label": "打印前自动调平",
|
||||
"settings_vibration_compensation": "打印前共振补偿",
|
||||
"settings_btn_tooltip": "设置",
|
||||
"settings_camera_on_print": "打印开始时开启相机",
|
||||
"settings_cat_connection": "连接",
|
||||
@@ -239,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": "导入配置文件",
|
||||
@@ -246,7 +273,6 @@
|
||||
"settings_password": "MQTT 密码",
|
||||
"settings_poll": "轮询间隔(秒)",
|
||||
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
|
||||
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
|
||||
"settings_poll_interval_label": "轮询间隔(秒)",
|
||||
"settings_print": "打印设置",
|
||||
"settings_printer_ip": "打印机 IP",
|
||||
@@ -258,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": "可见厂商(配置下拉框)",
|
||||
@@ -280,6 +308,7 @@
|
||||
"skip_sending": "发送中 …",
|
||||
"skip_success": "对象将被跳过。",
|
||||
"skip_title": "✂ 跳过对象",
|
||||
"slot_copy_from": "从插槽复制颜色…",
|
||||
"slot_edit_color": "颜色",
|
||||
"slot_edit_custom": "例如 PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ 进料",
|
||||
@@ -298,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": "浏览",
|
||||
@@ -326,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