Compare commits
25 Commits
nightly-0.
...
v0.9.29
| Author | SHA1 | Date | |
|---|---|---|---|
| 86adde0a45 | |||
|
|
c4f321c9b0 | ||
| 1f1d60d571 | |||
|
|
9f76d28622 | ||
| cbcb17f45a | |||
|
|
4e7f851799 | ||
| 1d5ac8dc4e | |||
| 64c8bc32d7 | |||
| 0ca7618c85 | |||
| 6d6df59ff2 | |||
| 33b42e64cc | |||
| 16c1a8ee73 | |||
| 884bdfc0c3 | |||
| 8f14580e30 | |||
| 20daa6b6b8 | |||
|
|
2f222a0b93 | ||
| 6e72346129 | |||
| 99bc1797c8 | |||
| e1b9480098 | |||
|
|
03089db6af | ||
|
|
cd4a8ce48e | ||
| bf3f043888 | |||
| b9594d4a22 | |||
|
|
bf94a8f563 | ||
| eda14db897 |
@@ -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
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -3,6 +3,17 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Slot kept showing/printing a stale filament type after a spool swap.** The
|
||||
per-slot profile override (config.ini `[filament_profiles]`) stores only
|
||||
vendor+name and was sticky: swapping the physical filament updated the AMS
|
||||
colour and type live, but the saved profile persisted, so a slot that held
|
||||
e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the
|
||||
OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts.
|
||||
The override is now applied only while its material *family* still matches the
|
||||
loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so
|
||||
within-family swaps never invalidate a valid profile). On a family change the
|
||||
slot falls back to the generic default; the override is not deleted, so
|
||||
reloading the original material reactivates it.
|
||||
- **Filament profiles not isolated between printers in a multi-printer bridge**
|
||||
(issue #74). The slot→profile mapping and `visible_vendors` were stored in a
|
||||
single global `[filament_profiles]` section, so configuring one printer
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -29,9 +29,7 @@ Feedback willkommen.</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.
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
64
start.sh
64
start.sh
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# start.sh – KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
|
||||
# start.sh – KX-Bridge starten (zieht das fertige Image aus der Registry)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge"
|
||||
|
||||
# .env anlegen falls nicht vorhanden
|
||||
if [[ ! -f .env ]]; then
|
||||
if [[ -f .env.example ]]; then
|
||||
@@ -30,38 +32,38 @@ if ! docker info > /dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prüfen ob Build nötig ist
|
||||
NEEDS_BUILD=0
|
||||
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
|
||||
echo "[start] Image nicht vorhanden – baue kx-bridge:latest ..."
|
||||
NEEDS_BUILD=1
|
||||
# Release-Kanal abfragen
|
||||
CHANNEL=""
|
||||
if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then
|
||||
CHANNEL="$1"
|
||||
else
|
||||
# Image-Erstellungszeit in Unix-Sekunden
|
||||
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
|
||||
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
|
||||
s=s[:26].rstrip('Z').replace('T',' '); \
|
||||
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
|
||||
|
||||
for f in Dockerfile \
|
||||
bridge/kobrax_moonraker_bridge.py \
|
||||
bridge/kobrax_client.py \
|
||||
bridge/env_loader.py \
|
||||
bridge/requirements.txt \
|
||||
bridge/anycubic_slicer.crt \
|
||||
bridge/anycubic_slicer.key; do
|
||||
if [[ -f "$f" ]]; then
|
||||
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
|
||||
if [[ $FILE_TS -gt $IMAGE_TS ]]; then
|
||||
echo "[start] '$f' ist neuer als das Image – baue neu ..."
|
||||
NEEDS_BUILD=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " Welchen Release-Kanal möchtest du starten?"
|
||||
echo " 1) stable (empfohlen)"
|
||||
echo " 2) nightly (getestete Vorabversion)"
|
||||
echo -n " Auswahl [1]: "
|
||||
read -r CHOICE
|
||||
case "$CHOICE" in
|
||||
2) CHANNEL="nightly" ;;
|
||||
*) CHANNEL="stable" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ $NEEDS_BUILD -eq 1 ]]; then
|
||||
docker build -t kx-bridge:latest .
|
||||
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||
IMAGE_TAG="nightly"
|
||||
else
|
||||
IMAGE_TAG="latest"
|
||||
fi
|
||||
IMAGE="$IMAGE_BASE:$IMAGE_TAG"
|
||||
|
||||
echo "[start] Kanal: $CHANNEL → Image: $IMAGE"
|
||||
echo "[start] Ziehe aktuelles Image ..."
|
||||
docker pull "$IMAGE"
|
||||
|
||||
# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile)
|
||||
if [[ -f docker-compose.yml ]]; then
|
||||
sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml
|
||||
rm -f docker-compose.yml.bak
|
||||
fi
|
||||
|
||||
# Container starten
|
||||
@@ -70,7 +72,7 @@ docker-compose down 2>/dev/null || true
|
||||
docker-compose up -d
|
||||
|
||||
echo ""
|
||||
echo " ✓ KX-Bridge läuft"
|
||||
echo " ✓ KX-Bridge läuft ($CHANNEL)"
|
||||
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
|
||||
echo " Logs : docker-compose logs -f"
|
||||
echo " Stop : docker-compose down"
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
Shared fixtures für KX-Bridge Tests.
|
||||
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
|
||||
"""
|
||||
import sys, types, argparse, pytest, pytest_asyncio
|
||||
import sys, types, argparse, tempfile, pytest, pytest_asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
# ── Pfad ──────────────────────────────────────────────────────────────────────
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
|
||||
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
|
||||
|
||||
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
|
||||
env_mod = types.ModuleType("env_loader")
|
||||
@@ -41,6 +42,7 @@ def make_args(**overrides):
|
||||
device_id = "",
|
||||
host = "127.0.0.1",
|
||||
port = 7125,
|
||||
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
|
||||
)
|
||||
for k, v in overrides.items():
|
||||
setattr(args, k, v)
|
||||
|
||||
111
tests/test_ace_rfid_vendor_matching.py
Normal file
111
tests/test_ace_rfid_vendor_matching.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Auto-matching for custom ACE-RFID filament tags (Issue #101).
|
||||
|
||||
Anycubic's ACE RFID system concatenates vendor + material + a truncated
|
||||
serial into one `type` string for custom (third-party) tags, e.g.
|
||||
"GEEETECH PLA Bas" (vendor "Geeetech", material "PLA", serial "Bas" for
|
||||
"Basic"). Previously the bridge treated this whole string as an unknown
|
||||
material and fell back to a neutral "Generic <type>" profile, even though
|
||||
the user had already imported a matching OrcaSlicer profile via the ZIP
|
||||
import feature (Issue #41) - requiring a manual per-slot reassignment every
|
||||
time that spool was loaded.
|
||||
|
||||
_parse_combined_rfid_type() + _match_profile_by_vendor_family() resolve this
|
||||
automatically against the merged system+user filament library.
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
USER_PROFILES = [
|
||||
{"id": "OGFL99", "name": "Generic PLA", "vendor": "Generic", "type": "PLA", "color": ""},
|
||||
{"id": "GTPLA01", "name": "Geeetech PLA Basic", "vendor": "Geeetech", "type": "PLA", "color": "", "is_user": True},
|
||||
]
|
||||
|
||||
|
||||
def _bridge(profiles=USER_PROFILES):
|
||||
c = MagicMock(); c.callbacks = {}; c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxrfid-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._orca_filaments_cache = profiles
|
||||
return b
|
||||
|
||||
|
||||
def test_combined_rfid_type_parses_known_vendor_and_family():
|
||||
b = _bridge()
|
||||
vendor, family = b._parse_combined_rfid_type("GEEETECH PLA Bas")
|
||||
assert vendor == "Geeetech"
|
||||
assert family == "PLA"
|
||||
|
||||
|
||||
def test_plain_type_string_is_unaffected():
|
||||
"""Regression guard: a normal type="PLA" report (no vendor prefix) must
|
||||
not be mistaken for a combined RFID string."""
|
||||
b = _bridge()
|
||||
vendor, family = b._parse_combined_rfid_type("PLA")
|
||||
assert vendor == ""
|
||||
assert family == ""
|
||||
|
||||
|
||||
def test_unknown_vendor_prefix_returns_no_match():
|
||||
b = _bridge()
|
||||
vendor, family = b._parse_combined_rfid_type("TOTALLYUNKNOWNBRAND PLA Bas")
|
||||
assert vendor == ""
|
||||
assert family == ""
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_finds_imported_profile():
|
||||
b = _bridge()
|
||||
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
|
||||
assert profile.get("name") == "Geeetech PLA Basic"
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_no_match_returns_empty():
|
||||
b = _bridge()
|
||||
profile = b._match_profile_by_vendor_family("Geeetech", "PETG")
|
||||
assert profile == {}
|
||||
|
||||
|
||||
def test_match_profile_by_vendor_family_ambiguous_picks_first_without_crashing():
|
||||
profiles = USER_PROFILES + [
|
||||
{"id": "GTPLA02", "name": "Geeetech PLA Silk", "vendor": "Geeetech", "type": "PLA SILK", "color": "", "is_user": True},
|
||||
]
|
||||
b = _bridge(profiles)
|
||||
profile = b._match_profile_by_vendor_family("Geeetech", "PLA")
|
||||
assert profile.get("name") in ("Geeetech PLA Basic", "Geeetech PLA Silk")
|
||||
|
||||
|
||||
def test_build_lane_data_auto_resolves_combined_rfid_slot():
|
||||
"""End-to-end: a slot reporting the combined RFID string should surface
|
||||
the imported Geeetech profile in lane_data instead of the Generic
|
||||
fallback."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {} # no manual per-slot override - isolate the auto-match path
|
||||
b._filament_mode = "ace_hub"
|
||||
b._ams_slots = [
|
||||
{"global_index": 0, "box_id": 0, "status": 5, "type": "GEEETECH PLA Bas", "color": [238, 190, 152]},
|
||||
]
|
||||
lane = b._build_lane_data()
|
||||
tray = lane["ams"][0]["tray"][0]
|
||||
assert tray["vendor_name"] == "Geeetech"
|
||||
assert tray["name"] == "Geeetech PLA Basic"
|
||||
|
||||
|
||||
def test_build_lane_data_plain_type_still_uses_generic_fallback():
|
||||
"""Regression guard: everyday type="PLA" slots must keep using the
|
||||
existing Generic-library fallback, unaffected by the new matching path."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {} # no manual per-slot override - isolate the fallback path
|
||||
b._filament_mode = "ace_hub"
|
||||
b._ams_slots = [
|
||||
{"global_index": 0, "box_id": 0, "status": 5, "type": "PLA", "color": [255, 255, 255]},
|
||||
]
|
||||
lane = b._build_lane_data()
|
||||
tray = lane["ams"][0]["tray"][0]
|
||||
assert tray["name"] == "Generic PLA"
|
||||
assert tray["vendor_name"] == "Generic"
|
||||
85
tests/test_auto_ams_box_mapping_empty_slot.py
Normal file
85
tests/test_auto_ams_box_mapping_empty_slot.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Empty-tray placeholder bug in the OrcaSlicer "upload and print" path.
|
||||
|
||||
Real KX1 bug (confirmed 2026-07-22): printing Filament 4 with the slot below it
|
||||
EMPTY fails; with all slots full it works. `_start_print` -> `_build_auto_ams_box_mapping`
|
||||
inserts a positional placeholder at each gap whose `ams_index` points at the
|
||||
gap's own (physically EMPTY) tray. The printer rejects a mapping entry that
|
||||
references an empty tray, even for a tool the GCode never calls.
|
||||
|
||||
Invariant the fix must hold: EVERY entry's ams_index references a LOADED tray.
|
||||
Positional alignment (entry N = TN, from a16062f) must be preserved.
|
||||
"""
|
||||
import argparse
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
# Real KX1 AMS state: slot 2 (idx 2) EMPTY, below the used Filament 4 (idx 3).
|
||||
AMS_SLOTS = [
|
||||
{"global_index": 0, "box_id": -1, "status": 5, "type": "PLA", "color": [101, 88, 177]},
|
||||
{"global_index": 1, "box_id": -1, "status": 5, "type": "PLA SILK", "color": [239, 96, 163]},
|
||||
{"global_index": 2, "box_id": -1, "status": 4, "type": "PLA", "color": [223, 221, 220]}, # EMPTY
|
||||
{"global_index": 3, "box_id": -1, "status": 5, "type": "PLA", "color": [117, 120, 123]},
|
||||
]
|
||||
|
||||
|
||||
def _bridge(slots=AMS_SLOTS):
|
||||
c = MagicMock(); c.callbacks = {}; c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxauto-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._filament_mode = "toolhead"
|
||||
b._ams_slots = [dict(s) for s in slots]
|
||||
return b
|
||||
|
||||
|
||||
def _loaded_ams_indices(b):
|
||||
return {b._slot_to_print_ams_index(int(s["global_index"]))
|
||||
for s in b._ams_slots if s["status"] == 5}
|
||||
|
||||
|
||||
def test_no_entry_points_at_an_empty_tray_when_only_used_slot_loaded():
|
||||
"""_start_print filters loaded to the used slot only: loaded=[(3, slot3)].
|
||||
Positions 0..2 are placeholders and must NOT reference empty tray idx 2."""
|
||||
b = _bridge()
|
||||
loaded = [(3, b._ams_slots[3])]
|
||||
|
||||
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
|
||||
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3] # positional alignment kept
|
||||
loaded_ams = _loaded_ams_indices(b)
|
||||
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
|
||||
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
|
||||
|
||||
|
||||
def test_no_entry_points_at_an_empty_tray_with_gap_in_loaded_set():
|
||||
"""All occupied slots mapped (0,1,3 loaded, 2 empty). The placeholder at
|
||||
position 2 must not reference the empty tray idx 2."""
|
||||
b = _bridge()
|
||||
loaded = [(0, b._ams_slots[0]), (1, b._ams_slots[1]), (3, b._ams_slots[3])]
|
||||
|
||||
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
|
||||
assert [e["paint_index"] for e in mapping] == [0, 1, 2, 3]
|
||||
loaded_ams = _loaded_ams_indices(b)
|
||||
bad = [e for e in mapping if e["ams_index"] not in loaded_ams]
|
||||
assert not bad, f"entries reference an empty/non-loaded tray: {bad}"
|
||||
# Real loaded slots keep their own ams_index.
|
||||
assert mapping[0]["ams_index"] == 0
|
||||
assert mapping[1]["ams_index"] == 1
|
||||
assert mapping[3]["ams_index"] == 3
|
||||
|
||||
|
||||
def test_all_full_is_unchanged():
|
||||
"""All slots loaded -> no placeholders, identity mapping (the working case)."""
|
||||
slots = [dict(s, status=5) for s in AMS_SLOTS]
|
||||
b = _bridge(slots)
|
||||
loaded = [(i, b._ams_slots[i]) for i in range(4)]
|
||||
|
||||
mapping = b._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
|
||||
assert [e["ams_index"] for e in mapping] == [0, 1, 2, 3]
|
||||
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
|
||||
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)
|
||||
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
|
||||
@@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_writes_env(client):
|
||||
"""POST /api/settings schreibt Werte in .env-Datei."""
|
||||
async def test_settings_post_writes_config_ini(client):
|
||||
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process
|
||||
|
||||
resp = await c.post("/api/settings", json={
|
||||
"printer_ip": "10.0.0.5",
|
||||
@@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client):
|
||||
})
|
||||
assert resp.status == 200
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "PRINTER_IP=10.0.0.5" in content
|
||||
assert "MQTT_USERNAME=userABCD" in content
|
||||
assert "DEVICE_ID=deadbeef01234567" in content
|
||||
content = config_path.read_text()
|
||||
assert "printer_ip = 10.0.0.5" in content
|
||||
assert "username = userABCD" in content
|
||||
assert "device_id = deadbeef01234567" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_preserves_existing_keys(client):
|
||||
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
|
||||
"""POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server)."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
config_path.write_text(
|
||||
"[spoolman]\nserver = http://192.168.1.50:7912\n\n"
|
||||
"[connection]\nprinter_ip = old\n"
|
||||
)
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None
|
||||
|
||||
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "GITEA_TOKEN=mytoken" in content
|
||||
assert "PRINTER_IP=10.0.0.99" in content
|
||||
content = config_path.read_text()
|
||||
assert "server = http://192.168.1.50:7912" in content
|
||||
assert "printer_ip = 10.0.0.99" in content
|
||||
|
||||
157
tests/test_slot_profile_material_guard.py
Normal file
157
tests/test_slot_profile_material_guard.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Stale slot-profile guard: suppress a saved per-slot filament profile when the
|
||||
AMS now reports a *different material family* than the profile was assigned for.
|
||||
|
||||
Real-world bug (KX1): slot 1 held a PETG spool and got the profile
|
||||
"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the
|
||||
colour (live) but the saved profile stuck on PETG, so the panel + the slicer
|
||||
hint kept showing/using PETG. Restarting did not help — the override lives in
|
||||
config.ini.
|
||||
|
||||
Fix = non-destructive suppression (Option A): resolve the effective profile as
|
||||
"the saved override only if its material *family* matches the current AMS
|
||||
material; otherwise none (fall back to the generic default)". The override is
|
||||
never deleted, so putting the original material back reactivates it.
|
||||
|
||||
Comparison must be by *family*, never strict string equality — PLA / PLA+ /
|
||||
PLA SILK / PLA MATTE are the same family and must NOT invalidate each other
|
||||
(regression guard for the earlier over-strict material compare).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader.
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type).
|
||||
LIBRARY = [
|
||||
{"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"},
|
||||
{"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"},
|
||||
{"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"},
|
||||
]
|
||||
|
||||
PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"}
|
||||
SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"}
|
||||
UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"}
|
||||
|
||||
|
||||
def _bridge():
|
||||
c = MagicMock()
|
||||
c.callbacks = {}
|
||||
c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxguard-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is
|
||||
return b
|
||||
|
||||
|
||||
# ── _material_family ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_material_family_collapses_pla_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") == "PLA"
|
||||
assert fam("PLA+") == "PLA"
|
||||
assert fam("PLA SILK") == "PLA"
|
||||
assert fam("PLA MATTE") == "PLA"
|
||||
assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction
|
||||
|
||||
|
||||
def test_material_family_collapses_petg_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PETG") == "PETG"
|
||||
assert fam("PETG+") == "PETG"
|
||||
|
||||
|
||||
def test_material_family_distinguishes_pla_from_petg():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") != fam("PETG")
|
||||
assert fam("PLA SILK") != fam("PETG")
|
||||
|
||||
|
||||
def test_material_family_empty_for_empty_input():
|
||||
assert KobraXBridge._material_family("") == ""
|
||||
assert KobraXBridge._material_family(None) == ""
|
||||
|
||||
|
||||
# ── _effective_slot_profile ───────────────────────────────────────────────────
|
||||
|
||||
def test_suppressed_when_family_changes_petg_profile_pla_loaded():
|
||||
"""The exact KX1 bug: PETG profile, AMS now reports PLA → suppress."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PLA") == {}
|
||||
|
||||
|
||||
def test_kept_when_family_matches_petg_profile_petg_loaded():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE
|
||||
|
||||
|
||||
def test_kept_for_pla_variant_no_false_positive():
|
||||
"""PLA+ profile with a PLA SILK spool loaded is the same family → keep."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {1: dict(SILK_PROFILE)}
|
||||
assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE
|
||||
|
||||
|
||||
def test_kept_when_profile_material_unknown_failsafe():
|
||||
"""If the profile is not in the library we cannot know its family → never
|
||||
suppress on uncertainty (fail-safe keeps the user's choice)."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {2: dict(UNKNOWN_PROFILE)}
|
||||
assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE
|
||||
|
||||
|
||||
def test_empty_when_no_override():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {}
|
||||
assert b._effective_slot_profile(3, "PLA") == {}
|
||||
|
||||
|
||||
# ── Integration: display endpoint (the visible panel) ─────────────────────────
|
||||
|
||||
async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded():
|
||||
"""/kx/filament/slots must not show the stale PETG identity once PLA loads."""
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["material"] == "PLA" # AMS truth, always
|
||||
assert row["filament_name"] == "" # stale PETG identity gone
|
||||
assert row["filament_vendor"] == ""
|
||||
|
||||
|
||||
async def test_display_endpoint_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["filament_name"] == "KINGROON PETG Basic"
|
||||
assert row["filament_vendor"] == "KINGROON"
|
||||
|
||||
|
||||
# ── Integration: print path (lane_data sent to OrcaSlicer) ────────────────────
|
||||
|
||||
async def test_lane_data_does_not_leak_stale_petg_identity():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["tray_type"] == "PLA"
|
||||
assert "PETG" not in tray["name"].upper()
|
||||
assert tray["vendor_name"] != "KINGROON"
|
||||
|
||||
|
||||
async def test_lane_data_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["name"] == "KINGROON PETG Basic"
|
||||
assert tray["vendor_name"] == "KINGROON"
|
||||
@@ -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)');
|
||||
@@ -582,6 +599,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 +765,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){
|
||||
@@ -1971,8 +2010,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 +2775,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 +2855,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 +2888,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 +2957,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){
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
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,6 +214,10 @@
|
||||
"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.",
|
||||
@@ -212,7 +231,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",
|
||||
@@ -246,7 +264,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 +275,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 +299,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 +318,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 +351,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,6 +214,10 @@
|
||||
"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.",
|
||||
@@ -212,7 +231,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",
|
||||
@@ -246,7 +264,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 +275,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 +299,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 +318,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 +351,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,6 +214,10 @@
|
||||
"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.",
|
||||
@@ -212,7 +231,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",
|
||||
@@ -246,7 +264,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 +275,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 +299,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 +318,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 +351,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,6 +202,10 @@
|
||||
"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.",
|
||||
@@ -278,6 +285,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 +304,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 +337,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,6 +202,10 @@
|
||||
"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.",
|
||||
@@ -278,6 +285,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 +304,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 +337,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,6 +214,10 @@
|
||||
"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": "尚未设置打印机。",
|
||||
@@ -212,7 +231,6 @@
|
||||
"progress_action_slots": "分配槽位",
|
||||
"settings_auto_leveling": "打印前自动调平",
|
||||
"settings_auto_leveling_label": "打印前自动调平",
|
||||
"settings_vibration_compensation": "打印前共振补偿",
|
||||
"settings_btn_tooltip": "设置",
|
||||
"settings_camera_on_print": "打印开始时开启相机",
|
||||
"settings_cat_connection": "连接",
|
||||
@@ -246,7 +264,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 +275,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 +299,7 @@
|
||||
"skip_sending": "发送中 …",
|
||||
"skip_success": "对象将被跳过。",
|
||||
"skip_title": "✂ 跳过对象",
|
||||
"slot_copy_from": "从插槽复制颜色…",
|
||||
"slot_edit_color": "颜色",
|
||||
"slot_edit_custom": "例如 PLA, PETG, ABS…",
|
||||
"slot_edit_load": "⬇ 进料",
|
||||
@@ -298,15 +318,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 +351,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