Compare commits
20 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e72346129 | |||
| 99bc1797c8 | |||
| e1b9480098 | |||
|
|
03089db6af | ||
|
|
cd4a8ce48e | ||
| bf3f043888 | |||
| b9594d4a22 | |||
|
|
bf94a8f563 | ||
| eda14db897 | |||
| dcf852db49 | |||
| d2c92c2deb | |||
| b541aafc74 | |||
| f2f4447809 | |||
| a3deb33b97 | |||
| 2e2061a269 | |||
| e4bf2c9b95 | |||
| aea6e457f3 | |||
| 0ae8ae59be | |||
| 0322ade606 | |||
| 786fa08ca0 |
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,7 +1,4 @@
|
||||
## Changes in this build
|
||||
|
||||
- Fix: Spoolman spool-slot assignment persistence was silently broken (NameError on config_loader swallowed by bare except) — now uses correct module reference and logs failures; per-printer isolation added so multi-printer setups no longer overwrite each other (Issue #80, PR #83 by @walterioo)
|
||||
- Fix: spool dropdown in print dialog showed "[object Object]" instead of vendor name (PR #83)
|
||||
- Fix: gate_spool_id in MMU object now populated from per-printer slot map instead of hardcoded -1 (PR #83)
|
||||
- Fix: PLA variants (PLA+, PLA Silk, PLA Matte) now recognized as their material family — correct tray_info_idx sent to printer, correct Generic profile synced to OrcaSlicer, and vendor profiles for the variant shown in slot editor dropdown (Issue #82)
|
||||
- UI: PLA+, PLA Silk and PLA Matte buttons added to slot material selector
|
||||
- Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs)
|
||||
- Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim)
|
||||
|
||||
122
config_loader.py
122
config_loader.py
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
config_loader.py – lädt Verbindungsparameter aus config/config.ini (primär)
|
||||
oder .env (Fallback / Migration).
|
||||
Umgebungsvariablen haben immer Vorrang.
|
||||
config_loader.py - loads connection parameters from config/config.ini (primary)
|
||||
or .env (fallback / migration).
|
||||
Environment variables always take precedence.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -34,7 +34,7 @@ def _find_env_file() -> pathlib.Path | None:
|
||||
|
||||
|
||||
def _load_env_file(path: pathlib.Path):
|
||||
"""Lädt .env-Datei als Fallback – setzt nur Keys die noch nicht in os.environ sind."""
|
||||
"""Loads the .env file as a fallback - only sets keys not yet in os.environ."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
@@ -47,28 +47,39 @@ def _load_env_file(path: pathlib.Path):
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
# Single source of truth for env-var <-> config.ini mapping. _restart_bridge()
|
||||
# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ
|
||||
# before restarting, so a value removed here or in the UI settings save can
|
||||
# never survive as a stale env var read by the new process. Add new settings
|
||||
# here ONLY - no second list to keep in sync.
|
||||
CONFIG_ENV_MAPPING = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
|
||||
"POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"),
|
||||
"VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
|
||||
|
||||
def _load_config_file(path: pathlib.Path):
|
||||
"""Lädt config.ini und setzt Keys in os.environ (nur wenn nicht bereits gesetzt)."""
|
||||
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
|
||||
mapping = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
for env_key, (section, option) in mapping.items():
|
||||
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
|
||||
if env_key not in os.environ:
|
||||
try:
|
||||
val = cfg.get(section, option)
|
||||
@@ -113,8 +124,9 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
}
|
||||
cfg[CONFIG_SECTION_PRINT] = {
|
||||
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
|
||||
}
|
||||
cfg[CONFIG_SECTION_BRIDGE] = {
|
||||
@@ -122,12 +134,12 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
}
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.write("# KX-Bridge Konfigurationsdatei\n")
|
||||
f.write("# Automatisch migriert aus .env\n\n")
|
||||
f.write("# Automatically migrated from .env\n\n")
|
||||
cfg.write(f)
|
||||
|
||||
|
||||
def find_config_path() -> pathlib.Path:
|
||||
"""Gibt den Pfad zur config.ini zurück (auch wenn sie noch nicht existiert)."""
|
||||
"""Returns the path to config.ini (even if it does not exist yet)."""
|
||||
for base in (_BASE, _BASE.parent):
|
||||
config_dir = base / "config"
|
||||
if config_dir.is_dir():
|
||||
@@ -143,7 +155,7 @@ _env_path = _find_env_file()
|
||||
if _config_path:
|
||||
_load_config_file(_config_path)
|
||||
elif _env_path:
|
||||
# Kein config.ini vorhanden → aus .env migrieren
|
||||
# No config.ini present -> migrate from .env
|
||||
_target = find_config_path()
|
||||
migrate_env_to_config(_env_path, _target)
|
||||
_load_config_file(_target)
|
||||
@@ -151,13 +163,13 @@ elif _env_path:
|
||||
|
||||
|
||||
def list_printers() -> list[dict]:
|
||||
"""Liest alle [printer_N]-Sektionen aus config.ini.
|
||||
"""Reads all [printer_N] sections from config.ini.
|
||||
|
||||
Jede Sektion kann folgende Keys haben:
|
||||
Each section may contain the following keys:
|
||||
name, printer_ip, mqtt_port, username, password, mode_id, device_id,
|
||||
bridge_url, default_ams_slot, auto_leveling
|
||||
|
||||
Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind
|
||||
Returns an empty list when no [printer_N] sections exist
|
||||
(Single-Printer-Betrieb via [connection]).
|
||||
"""
|
||||
path = _find_config_file()
|
||||
@@ -198,28 +210,28 @@ def _filament_section(printer_id: Optional[str] = None) -> str:
|
||||
|
||||
|
||||
def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
||||
"""Reads the [filament_profiles] section from config.ini.
|
||||
|
||||
With ``printer_id`` set, reads the per-printer ``[filament_profiles_<id>]``
|
||||
section and falls back to the legacy global ``[filament_profiles]`` while
|
||||
that printer has no own section yet.
|
||||
|
||||
Format pro AMS-Slot — primärer Selector ist (vendor, name), die `id` wird
|
||||
aus der orca_filaments.json beim Speichern nachgeschlagen und mitgeführt
|
||||
(als Hint für OrcaSlicer; das Orca-Datenmodell hat ~136 Profile mit
|
||||
derselben filament_id wie 'OGFL99', d.h. die ID ist nicht eindeutig):
|
||||
Format per AMS slot - the primary selector is (vendor, name); the `id` is
|
||||
looked up from orca_filaments.json on save and carried along
|
||||
(as a hint for OrcaSlicer; the Orca data model has ~136 profiles sharing
|
||||
the same filament_id like 'OGFL99', i.e. the ID is not unique):
|
||||
|
||||
[filament_profiles]
|
||||
slot_0_vendor = Polymaker
|
||||
slot_0_name = PolyTerra PLA
|
||||
slot_0_id = OGFL01
|
||||
|
||||
Gibt einen Dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}
|
||||
zurück. Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
|
||||
(per filament_type) in der Bridge bleibt dann aktiv.
|
||||
Returns a dict {slot_index: {"id": ..., "vendor": ..., "name": ...}}.
|
||||
Empty/missing slots are NOT included - the default mapping
|
||||
(per filament_type) in the bridge then stays active.
|
||||
|
||||
Backwards-Kompat: alte Configs mit nur (vendor, id) bleiben lesbar; `name`
|
||||
fehlt dann und der Aufrufer kann optional aus der orca_filaments.json
|
||||
Backwards compat: old configs with only (vendor, id) stay readable; `name`
|
||||
is then missing and the caller can optionally resolve it from orca_filaments.json
|
||||
rekonstruieren.
|
||||
"""
|
||||
path = _find_config_file()
|
||||
@@ -234,7 +246,7 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
return {}
|
||||
result: dict[int, dict] = {}
|
||||
for key, value in cfg.items(section):
|
||||
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor oder slot_<idx>_name
|
||||
# Expects: slot_<idx>_id or slot_<idx>_vendor or slot_<idx>_name
|
||||
if not key.startswith("slot_"):
|
||||
continue
|
||||
parts = key.split("_", 2)
|
||||
@@ -254,11 +266,11 @@ def list_filament_profiles(printer_id: Optional[str] = None) -> dict[int, dict]:
|
||||
|
||||
|
||||
def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str] = None) -> bool:
|
||||
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
||||
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
||||
"""Writes the given slot profiles into the [filament_profiles]
|
||||
section of config.ini. Existing entries are completely replaced.
|
||||
|
||||
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker", "name": "PolyTerra PLA"}}
|
||||
Mindestens vendor+name müssen gesetzt sein; id ist optional (Hint).
|
||||
At least vendor+name must be set; id is optional (hint).
|
||||
|
||||
With ``printer_id`` set, writes the per-printer ``[filament_profiles_<id>]``
|
||||
section only — other printers and the legacy global section are untouched.
|
||||
@@ -269,8 +281,8 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str]
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
section = _filament_section(printer_id)
|
||||
# visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der
|
||||
# Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren.
|
||||
# visible_vendors (Issue #41) is not a slot mapping - preserve it when
|
||||
# replacing the section, otherwise the vendor filter is lost on slot save.
|
||||
# First save of a per-printer section inherits the legacy global filter.
|
||||
preserved_vendors = None
|
||||
if cfg.has_option(section, "visible_vendors"):
|
||||
@@ -297,10 +309,10 @@ def save_filament_profiles(profiles: dict[int, dict], printer_id: Optional[str]
|
||||
|
||||
|
||||
def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
|
||||
"""Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini.
|
||||
"""Reads [filament_profiles] visible_vendors (comma-separated) from config.ini.
|
||||
|
||||
Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A).
|
||||
Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren).
|
||||
Vendor visibility filter for the slot profile dropdown (Issue #41 option A).
|
||||
Empty list = no restriction (backwards compatible: all vendors).
|
||||
|
||||
With ``printer_id`` set, reads the per-printer section and falls back to the
|
||||
legacy global ``[filament_profiles]`` filter.
|
||||
@@ -320,7 +332,7 @@ def list_visible_vendors(printer_id: Optional[str] = None) -> list[str]:
|
||||
|
||||
|
||||
def save_visible_vendors(vendors: list[str], printer_id: Optional[str] = None) -> bool:
|
||||
"""Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings
|
||||
"""Writes visible_vendors into [filament_profiles] without touching the
|
||||
(slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.
|
||||
|
||||
With ``printer_id`` set, writes the per-printer section. When that section is
|
||||
@@ -429,7 +441,7 @@ def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
# Häufig verwendete Shortcuts
|
||||
# Frequently used shortcuts
|
||||
PRINTER_IP = get("PRINTER_IP", "")
|
||||
MQTT_PORT = int(get("MQTT_PORT", "9883"))
|
||||
USERNAME = get("MQTT_USERNAME", "")
|
||||
@@ -437,9 +449,13 @@ PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
|
||||
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
|
||||
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0"))
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
- ./.env:/app/.env:ro
|
||||
ports:
|
||||
- "7125-7130:7125-7130"
|
||||
# environment:
|
||||
# - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige)
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
env_loader.py – lädt Verbindungsparameter aus .env (Repo-Root oder Arbeitsverzeichnis).
|
||||
Umgebungsvariablen haben Vorrang vor .env-Werten.
|
||||
env_loader.py - loads connection parameters from .env (repo root or working directory).
|
||||
Environment variables take precedence over .env values.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -39,7 +39,7 @@ def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
# Häufig verwendete Shortcuts
|
||||
# Frequently used shortcuts
|
||||
PRINTER_IP = get("PRINTER_IP", "")
|
||||
MQTT_PORT = int(get("MQTT_PORT", "9883"))
|
||||
USERNAME = get("MQTT_USERNAME", "")
|
||||
@@ -47,7 +47,9 @@ PASSWORD = get("MQTT_PASSWORD", "")
|
||||
MODE_ID = get("MODE_ID", "")
|
||||
DEVICE_ID = get("DEVICE_ID", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
|
||||
116
kobrax_client.py
116
kobrax_client.py
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
kobrax_client.py – Anycubic Kobra X LAN-MQTT-Client
|
||||
|
||||
Protokoll vollständig rekonstruiert via Sniffer 2026-04-17 (953 Nachrichten).
|
||||
Protocol fully reconstructed via sniffer 2026-04-17 (953 messages).
|
||||
|
||||
Voraussetzungen:
|
||||
- /tmp/anycubic_slicer.crt und .key (aus cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
|
||||
- /tmp/anycubic_slicer.crt and .key (from cloud_mqtt.dll @ 0x2ed5b0 / 0x2edce0)
|
||||
- Drucker im LAN-Modus erreichbar auf Port 9883
|
||||
|
||||
Verwendung:
|
||||
@@ -121,9 +121,9 @@ class KobraXClient:
|
||||
self._buf = b""
|
||||
self._pid = 1
|
||||
self._lock = threading.Lock()
|
||||
# Generations-Marker: wird bei jedem Socket-Swap/Close erhöht, damit der
|
||||
# Reader-Thread erkennt wenn _reconnect/_do_connect den Socket unter ihm
|
||||
# ersetzt hat (Issue #53). Schützt gegen recv auf einem stale fd.
|
||||
# Generation marker: incremented on every socket swap/close so the
|
||||
# reader thread notices when _reconnect/_do_connect swapped the socket
|
||||
# underneath it (Issue #53). Protects against recv on a stale fd.
|
||||
self._sock_gen = 0
|
||||
self._running = False
|
||||
|
||||
@@ -162,9 +162,9 @@ class KobraXClient:
|
||||
if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE):
|
||||
raise FileNotFoundError(
|
||||
f"TLS-Zertifikate fehlen: anycubic_slicer.crt + anycubic_slicer.key "
|
||||
f"müssen neben der kx-bridge Binary liegen ({_SCRIPT_DIR}/). "
|
||||
f"Lade anycubic-certs.zip vom Gitea-Release herunter und entpacke "
|
||||
f"die Dateien dorthin."
|
||||
f"must sit next to the kx-bridge binary ({_SCRIPT_DIR}/). "
|
||||
f"Download anycubic-certs.zip from the Gitea release and extract "
|
||||
f"the files there."
|
||||
)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
@@ -172,9 +172,9 @@ class KobraXClient:
|
||||
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
|
||||
ctx.load_cert_chain(CERT_FILE, KEY_FILE)
|
||||
|
||||
# Socket als lokale Variable aufbauen — der Handshake (Connect + CONNACK)
|
||||
# läuft OHNE gehaltenes Lock, damit ein langsamer Connect die Sender nicht
|
||||
# einfriert. Erst der fertige Socket wird unter Lock eingeschwenkt (#53).
|
||||
# Build the socket as a local variable - the handshake (connect + CONNACK)
|
||||
# runs WITHOUT holding the lock so a slow connect does not freeze
|
||||
# senders. Only the finished socket is swapped in under the lock (#53).
|
||||
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw = socket.create_connection(_ai[0][4], timeout=5)
|
||||
new_sock = ctx.wrap_socket(raw)
|
||||
@@ -196,7 +196,7 @@ class KobraXClient:
|
||||
self._sock = new_sock
|
||||
self._sock_gen += 1
|
||||
self._buf = b""
|
||||
self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln
|
||||
self._subscribe(self._sub_topic()) # takes the lock itself - do not nest
|
||||
log.debug("MQTT connected to %s:%s", self.host, self.port)
|
||||
|
||||
def connect(self):
|
||||
@@ -206,10 +206,10 @@ class KobraXClient:
|
||||
time.sleep(0.3)
|
||||
|
||||
def _ensure_reader(self):
|
||||
"""Stellt sicher dass der Reader-Thread lebt. Wenn der Reader nach einer
|
||||
früheren disconnect/reconnect-Sequenz oder einem unbehandelten Fehler
|
||||
gestorben ist, würden empfangene Replies sonst nie ankommen — publish()
|
||||
würde dann zwar senden, aber auf Antworten ewig warten."""
|
||||
"""Ensures the reader thread is alive. If the reader died after a
|
||||
previous disconnect/reconnect sequence or an unhandled error,
|
||||
received replies would never arrive - publish()
|
||||
would still send but wait for replies forever."""
|
||||
if not self._running:
|
||||
return # gewollter disconnect
|
||||
t = getattr(self, "_reader_thread", None)
|
||||
@@ -232,13 +232,13 @@ class KobraXClient:
|
||||
self._sock_gen += 1
|
||||
|
||||
def _reconnect(self):
|
||||
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
|
||||
antwortet oder disconnect() gerufen wurde. Backoff cappt bei 60 s. Die
|
||||
ersten 5 Versuche loggen als WARNING (akute Verbindungsstörung), danach
|
||||
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
|
||||
"""Persistent reconnect: keeps retrying forever until the printer is
|
||||
responds or disconnect() was called. Backoff caps at 60 s. The
|
||||
first 5 attempts log as WARNING (acute connection issue), afterwards
|
||||
only DEBUG to avoid log spam during long printer outages (e.g. switched
|
||||
ausgeschaltet) zu vermeiden."""
|
||||
log.warning("Verbindung verloren – reconnect…")
|
||||
# Close + Invalidierung unter Lock, damit kein Sender mitten im sendall
|
||||
log.warning("Connection lost - reconnecting...")
|
||||
# Close + invalidation under the lock so no sender is mid-sendall
|
||||
# auf den gerade geschlossenen Socket trifft (Issue #53).
|
||||
with self._lock:
|
||||
try:
|
||||
@@ -254,18 +254,18 @@ class KobraXClient:
|
||||
delay = delays[min(attempt, len(delays) - 1)]
|
||||
try:
|
||||
self._do_connect()
|
||||
log.info("Reconnect erfolgreich (nach %d Versuchen)", attempt + 1)
|
||||
log.info("Reconnect successful (after %d attempts)", attempt + 1)
|
||||
return True
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
lvl = log.warning if attempt <= 5 else log.debug
|
||||
lvl("Reconnect fehlgeschlagen (%s, Versuch %d), warte %ss…", e, attempt, delay)
|
||||
# Geteiltes Sleep damit disconnect() den Loop schneller bricht.
|
||||
# Split sleep so disconnect() breaks the loop faster.
|
||||
slept = 0.0
|
||||
while slept < delay and self._running:
|
||||
time.sleep(min(0.5, delay - slept))
|
||||
slept += 0.5
|
||||
return False # nur wenn disconnect() gerufen wurde
|
||||
return False # only when disconnect() was called
|
||||
|
||||
def _subscribe(self, topic: str):
|
||||
with self._lock:
|
||||
@@ -290,15 +290,15 @@ class KobraXClient:
|
||||
ping_ok = True
|
||||
except Exception:
|
||||
ping_ok = False
|
||||
# _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock
|
||||
# selbst, und threading.Lock ist nicht reentrant (sonst Deadlock).
|
||||
# Call _reconnect() OUTSIDE the lock - it takes the lock
|
||||
# itself, and threading.Lock is not reentrant (deadlock otherwise).
|
||||
if not ping_ok:
|
||||
if self._running and not self._reconnect():
|
||||
break
|
||||
last_ping = time.time()
|
||||
# Aktuellen Socket + Generation unter Lock greifen, damit ein
|
||||
# paralleler _reconnect/_do_connect-Swap uns nicht auf einem stale
|
||||
# fd pollen lässt (Issue #53).
|
||||
# Grab the current socket + generation under the lock so a
|
||||
# parallel _reconnect/_do_connect swap does not leave us polling
|
||||
# a stale fd (Issue #53).
|
||||
with self._lock:
|
||||
sock = self._sock
|
||||
gen = self._sock_gen
|
||||
@@ -306,37 +306,37 @@ class KobraXClient:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
# Idle-Wartezeit OHNE Lock — select probt nur die Bereitschaft, so
|
||||
# blockiert der Reader während Leerlauf nie das gemeinsame Lock.
|
||||
# Idle wait WITHOUT the lock - select only probes readiness, so
|
||||
# the reader never blocks the shared lock while idle.
|
||||
try:
|
||||
ready, _, _ = select.select([sock], [], [], 0.2)
|
||||
except (OSError, ValueError):
|
||||
# fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select)
|
||||
# fd closed/invalid (reconnect or disconnect mid-select)
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if not ready:
|
||||
continue # Leerlauf, kein Lock gehalten
|
||||
continue # idle, no lock held
|
||||
|
||||
# Daten liegen an: Lock kurz greifen für das eine recv, serialisiert
|
||||
# gegen alle sendall-Caller. recv blockiert nicht lange (select sagte
|
||||
# ready, Socket-Timeout ist 0.2s).
|
||||
# Data pending: briefly take the lock for the single recv, serialized
|
||||
# against all sendall callers. recv does not block long (select said
|
||||
# ready, socket timeout is 0.2s).
|
||||
try:
|
||||
with self._lock:
|
||||
# Socket könnte zwischen select und hier ersetzt worden sein.
|
||||
# The socket could have been swapped between select and here.
|
||||
if self._sock_gen != gen or self._sock is not sock:
|
||||
continue
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
# Windows SSL kann kurzzeitig b"" liefern ohne echten EOF
|
||||
# Windows SSL can briefly return b"" without a real EOF
|
||||
_empty_count += 1
|
||||
if _empty_count >= 5:
|
||||
raise ConnectionResetError("EOF")
|
||||
continue
|
||||
_empty_count = 0
|
||||
self._buf += data
|
||||
self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt
|
||||
self._drain() # outside the lock - dispatch/event.set() stays prompt
|
||||
except ssl.SSLWantReadError:
|
||||
continue
|
||||
except socket.timeout:
|
||||
@@ -445,8 +445,8 @@ class KobraXClient:
|
||||
# -- Publish + request/response ------------------------------------------
|
||||
|
||||
def publish(self, msg_type: str, action: str, data=None, timeout: float = 5.0) -> dict | None:
|
||||
# Falls Reader-Thread aus historischen Gründen tot ist, wiederbeleben —
|
||||
# sonst würden Replies nie ankommen und event.wait() läuft ins Timeout.
|
||||
# If the reader thread is dead for historical reasons, revive it -
|
||||
# otherwise replies would never arrive and event.wait() would time out.
|
||||
self._ensure_reader()
|
||||
msgid = str(uuid.uuid4())
|
||||
payload = json.dumps({
|
||||
@@ -471,7 +471,7 @@ class KobraXClient:
|
||||
report_registered = True
|
||||
|
||||
topic = self._pub_topic(msg_type)
|
||||
# Status-Poll-TX (query/getInfo) ist reines Rauschen (alle paar Sekunden) →
|
||||
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
|
||||
# auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar.
|
||||
_tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO
|
||||
log.log(_tx_level, "TX %-25s action=%-12s data=%s",
|
||||
@@ -531,8 +531,8 @@ class KobraXClient:
|
||||
self._sock.sendall(_build_publish(topic, payload))
|
||||
except Exception as e:
|
||||
log.error("web send error: %s, reconnecting…", e)
|
||||
# Reconnect triggern (analog zu publish()); ohne Retry weil
|
||||
# fire-and-forget — der nächste Aufruf wird auf den frischen Socket
|
||||
# Trigger a reconnect (like publish()); no retry because it is
|
||||
# fire-and-forget - the next call will hit the fresh socket
|
||||
# treffen.
|
||||
try:
|
||||
self._reconnect()
|
||||
@@ -579,13 +579,13 @@ class KobraXClient:
|
||||
# -- Part-Skip ("Exclude Object") ---------------------------------------
|
||||
|
||||
def query_skip_objects(self) -> dict | None:
|
||||
"""Fragt den Drucker nach der aktuellen Objekt-/Skip-Liste."""
|
||||
"""Asks the printer for the current object/skip list."""
|
||||
return self.publish("skip", "query_obj")
|
||||
|
||||
def skip_objects(self, names: list[str]) -> dict | None:
|
||||
"""Überspringt die genannten Objekte – auch mid-print möglich.
|
||||
"""Skips the named objects - also possible mid-print.
|
||||
|
||||
Namen entsprechen den EXCLUDE_OBJECT_DEFINE NAME=… Einträgen
|
||||
Names correspond to the EXCLUDE_OBJECT_DEFINE NAME=... entries
|
||||
im GCode-Header bzw. file_details.objects_skip_parts.
|
||||
"""
|
||||
return self.publish("skip", "start", {"objects_skip_parts": list(names)})
|
||||
@@ -653,14 +653,14 @@ class KobraXClient:
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode()
|
||||
|
||||
# Connect-Timeout kurz (LAN). Während sendall() darf der Socket so
|
||||
# lange brauchen wie nötig — bei großen Dateien (>100 MB) und
|
||||
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
|
||||
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
|
||||
# generös (Drucker verarbeitet die Datei bevor er antwortet).
|
||||
# Short connect timeout (LAN). During sendall() the socket may take
|
||||
# as long as needed - with large files (>100 MB) and slower WiFi
|
||||
# at the printer, pushing otherwise takes >30 s and would falsely
|
||||
# trip the connect timeout. The read timeout afterwards is generous
|
||||
# (the printer processes the file before replying).
|
||||
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock = socket.create_connection(_ai[0][4], timeout=10)
|
||||
sock.settimeout(None) # blocking während Send
|
||||
sock.settimeout(None) # blocking during send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
@@ -717,7 +717,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
|
||||
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
|
||||
parser.add_argument("--monitor", action="store_true",
|
||||
help="Dauerhaft mithören und alle Reports ausgeben")
|
||||
help="Listen continuously and print all reports")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = KobraXClient(
|
||||
@@ -741,7 +741,7 @@ if __name__ == "__main__":
|
||||
|
||||
client.callbacks["*"] = on_msg
|
||||
client.connect()
|
||||
print("[kobrax] Monitor-Modus aktiv (Ctrl-C zum Beenden)")
|
||||
print("[kobrax] Monitor mode active (Ctrl-C to stop)")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
@@ -755,7 +755,7 @@ if __name__ == "__main__":
|
||||
info = client.query_info()
|
||||
if info:
|
||||
d = info.get("data", {})
|
||||
print(f" Drucker: {d.get('printerName')} FW {d.get('version')}")
|
||||
print(f" Printer: {d.get('printerName')} FW {d.get('version')}")
|
||||
print(f" Status: {d.get('state')}")
|
||||
t = d.get("temp", {})
|
||||
print(f" Nozzle: {t.get('curr_nozzle_temp')}°C → {t.get('target_nozzle_temp')}°C")
|
||||
@@ -764,6 +764,6 @@ if __name__ == "__main__":
|
||||
print(f" Upload: {urls.get('fileUploadurl')}")
|
||||
print(f" Kamera: {urls.get('rtspUrl')}")
|
||||
else:
|
||||
print(" Keine Antwort")
|
||||
print(" No response")
|
||||
|
||||
client.disconnect()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
"""OrcaSlicer Filament-Profil Parser.
|
||||
|
||||
Geteilt zwischen dem Generator (tools/gen_orca_filament_list.py) und dem
|
||||
Shared between the generator (tools/gen_orca_filament_list.py) and the
|
||||
Custom-Profile-Import-Endpoint (bridge/kobrax_moonraker_bridge.py).
|
||||
|
||||
Liest Orca-Filament-JSON-Dateien (System- oder User-Profile) und gibt
|
||||
sie als normalisierte Liste mit (id, name, vendor, type, color) zurück.
|
||||
Reads Orca filament JSON files (system or user profiles) and returns
|
||||
them as a normalized list with (id, name, vendor, type, color).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,8 +13,8 @@ import re
|
||||
|
||||
|
||||
def first_str(value, default: str = "") -> str:
|
||||
"""Orca-Profile speichern manche Felder als ['wert']. Liefert erstes
|
||||
Element als String."""
|
||||
"""Orca profiles store some fields as ['value']. Returns the first
|
||||
element as a string."""
|
||||
if isinstance(value, list):
|
||||
return str(value[0]) if value else default
|
||||
if isinstance(value, str):
|
||||
@@ -37,34 +37,34 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
path_vendor: str | None = None,
|
||||
source_path: str = "",
|
||||
system_index: list | None = None) -> dict | None:
|
||||
"""Parsed ein einzelnes Orca-Filament-Profil zum Bridge-Schema.
|
||||
"""Parses a single Orca filament profile into the bridge schema.
|
||||
|
||||
`by_name` ist optional ein {name: [profile, …]}-Index für Inherits-Resolve
|
||||
aus dem rohen Source-Tree (Generator). Bei Single-File-Import (User-Datei
|
||||
aus OrcaSlicer-User-Dir) reichen wir stattdessen `system_index` rein —
|
||||
die fertige System-Profile-Liste aus orca_filaments.json. Damit können
|
||||
wir filament_id/vendor/type/color über die `inherits`-Kette aus dem
|
||||
System-Parent ableiten, auch wenn das User-Profil diese Felder nicht
|
||||
selbst setzt (typisch: User-Override-Profile haben nur Tweaks).
|
||||
`by_name` is optionally a {name: [profile, ...]} index for inherits resolution
|
||||
from the raw source tree (generator). For single-file imports (user file
|
||||
from the OrcaSlicer user dir) we pass `system_index` instead -
|
||||
the finished system profile list from orca_filaments.json. This lets
|
||||
us derive filament_id/vendor/type/color via the `inherits` chain from
|
||||
the system parent even when the user profile does not set these
|
||||
fields itself (typically: user override profiles only contain tweaks).
|
||||
|
||||
Liefert {id, name, vendor, type, color} oder None wenn das Profil
|
||||
keine filament_id hat (z.B. abstrakte @base-Templates).
|
||||
Returns {id, name, vendor, type, color} or None when the profile
|
||||
has no filament_id (e.g. abstract @base templates).
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
# User-Profile aus dem OrcaSlicer-User-Dir setzen oft KEIN "type"-Feld —
|
||||
# das kommt vom System-Parent. Wir akzeptieren das wenn entweder "type"
|
||||
# explizit "filament" ist ODER ein "inherits" auf ein anderes Profil zeigt.
|
||||
# User profiles from the OrcaSlicer user dir often set NO "type" field -
|
||||
# it comes from the system parent. We accept that when either "type"
|
||||
# is explicitly "filament" OR an "inherits" points to another profile.
|
||||
if data.get("type") not in (None, "filament") and not data.get("inherits"):
|
||||
return None
|
||||
if data.get("type") == "filament" and data.get("inherits") is None and not data.get("filament_id"):
|
||||
# type=filament aber kein parent + keine ID → wertloses Stub
|
||||
# type=filament but no parent + no ID -> worthless stub
|
||||
return None
|
||||
inst = data.get("instantiation", "true")
|
||||
if isinstance(inst, str) and inst.lower() == "false":
|
||||
return None
|
||||
|
||||
# Build system-name-Index für den fallback-Lookup wenn system_index gesetzt.
|
||||
# Build the system name index for the fallback lookup when system_index is set.
|
||||
sys_by_name: dict[str, dict] = {}
|
||||
if system_index:
|
||||
for p in system_index:
|
||||
@@ -92,7 +92,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
return None
|
||||
|
||||
def _resolve_via_system_index(key: str):
|
||||
"""Inherits-Kette über system_index (clean_name-Match)."""
|
||||
"""Inherits chain via system_index (clean_name match)."""
|
||||
parent_raw = data.get("inherits")
|
||||
if not parent_raw or not sys_by_name:
|
||||
return None
|
||||
@@ -100,7 +100,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
sys_p = sys_by_name.get(parent_clean)
|
||||
if not sys_p:
|
||||
return None
|
||||
# System-JSON benutzt schon das normalisierte Schema
|
||||
# The system JSON already uses the normalized schema
|
||||
mapping = {
|
||||
"filament_id": "id",
|
||||
"filament_vendor": "vendor",
|
||||
@@ -136,10 +136,10 @@ def parse_profile(data: dict, by_name: dict | None = None,
|
||||
|
||||
def parse_profile_bytes(blob: bytes, source_name: str = "",
|
||||
system_index: list | None = None) -> dict | None:
|
||||
"""Liest ein einzelnes Profil aus JSON-Bytes. Für File-Upload-Pfad.
|
||||
`system_index` ist optional die fertige Liste aus orca_filaments.json —
|
||||
wird für die Inherits-Resolve von User-Profilen genutzt die das volle
|
||||
Schema vom System-Parent erben."""
|
||||
"""Reads a single profile from JSON bytes. For the file upload path.
|
||||
`system_index` is optionally the finished list from orca_filaments.json -
|
||||
used for the inherits resolution of user profiles that do not carry the full
|
||||
schema from the system parent."""
|
||||
try:
|
||||
data = json.loads(blob.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
|
||||
64
start.sh
64
start.sh
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# start.sh – KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
|
||||
# start.sh – KX-Bridge starten (zieht das fertige Image aus der Registry)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge"
|
||||
|
||||
# .env anlegen falls nicht vorhanden
|
||||
if [[ ! -f .env ]]; then
|
||||
if [[ -f .env.example ]]; then
|
||||
@@ -30,38 +32,38 @@ if ! docker info > /dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prüfen ob Build nötig ist
|
||||
NEEDS_BUILD=0
|
||||
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
|
||||
echo "[start] Image nicht vorhanden – baue kx-bridge:latest ..."
|
||||
NEEDS_BUILD=1
|
||||
# Release-Kanal abfragen
|
||||
CHANNEL=""
|
||||
if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then
|
||||
CHANNEL="$1"
|
||||
else
|
||||
# Image-Erstellungszeit in Unix-Sekunden
|
||||
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
|
||||
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
|
||||
s=s[:26].rstrip('Z').replace('T',' '); \
|
||||
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
|
||||
|
||||
for f in Dockerfile \
|
||||
bridge/kobrax_moonraker_bridge.py \
|
||||
bridge/kobrax_client.py \
|
||||
bridge/env_loader.py \
|
||||
bridge/requirements.txt \
|
||||
bridge/anycubic_slicer.crt \
|
||||
bridge/anycubic_slicer.key; do
|
||||
if [[ -f "$f" ]]; then
|
||||
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
|
||||
if [[ $FILE_TS -gt $IMAGE_TS ]]; then
|
||||
echo "[start] '$f' ist neuer als das Image – baue neu ..."
|
||||
NEEDS_BUILD=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " Welchen Release-Kanal möchtest du starten?"
|
||||
echo " 1) stable (empfohlen)"
|
||||
echo " 2) nightly (getestete Vorabversion)"
|
||||
echo -n " Auswahl [1]: "
|
||||
read -r CHOICE
|
||||
case "$CHOICE" in
|
||||
2) CHANNEL="nightly" ;;
|
||||
*) CHANNEL="stable" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ $NEEDS_BUILD -eq 1 ]]; then
|
||||
docker build -t kx-bridge:latest .
|
||||
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||
IMAGE_TAG="nightly"
|
||||
else
|
||||
IMAGE_TAG="latest"
|
||||
fi
|
||||
IMAGE="$IMAGE_BASE:$IMAGE_TAG"
|
||||
|
||||
echo "[start] Kanal: $CHANNEL → Image: $IMAGE"
|
||||
echo "[start] Ziehe aktuelles Image ..."
|
||||
docker pull "$IMAGE"
|
||||
|
||||
# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile)
|
||||
if [[ -f docker-compose.yml ]]; then
|
||||
sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml
|
||||
rm -f docker-compose.yml.bak
|
||||
fi
|
||||
|
||||
# Container starten
|
||||
@@ -70,7 +72,7 @@ docker-compose down 2>/dev/null || true
|
||||
docker-compose up -d
|
||||
|
||||
echo ""
|
||||
echo " ✓ KX-Bridge läuft"
|
||||
echo " ✓ KX-Bridge läuft ($CHANNEL)"
|
||||
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
|
||||
echo " Logs : docker-compose logs -f"
|
||||
echo " Stop : docker-compose down"
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
Shared fixtures für KX-Bridge Tests.
|
||||
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
|
||||
"""
|
||||
import sys, types, argparse, pytest, pytest_asyncio
|
||||
import sys, types, argparse, tempfile, pytest, pytest_asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
# ── Pfad ──────────────────────────────────────────────────────────────────────
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
|
||||
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
|
||||
|
||||
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
|
||||
env_mod = types.ModuleType("env_loader")
|
||||
@@ -41,6 +42,7 @@ def make_args(**overrides):
|
||||
device_id = "",
|
||||
host = "127.0.0.1",
|
||||
port = 7125,
|
||||
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
|
||||
)
|
||||
for k, v in overrides.items():
|
||||
setattr(args, k, v)
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
@@ -45,7 +45,7 @@ var ACE_DRY_PRESETS={
|
||||
};
|
||||
|
||||
// Spoolman state
|
||||
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanStatus={configured:false,reachable:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanSpools=[];
|
||||
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
|
||||
|
||||
@@ -67,10 +67,12 @@ function _updateSpoolmanStatusDot(){
|
||||
var dot=document.getElementById('spoolman-status-dot');
|
||||
var lbl=document.getElementById('spoolman-status-lbl');
|
||||
if(!dot||!lbl)return;
|
||||
if(_spoolmanStatus.configured){
|
||||
if(!_spoolmanStatus.configured){
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
} else if(_spoolmanStatus.reachable){
|
||||
dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden';
|
||||
} else {
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
dot.style.color='var(--err)';lbl.textContent=(_spoolmanStatus.server||'')+' (nicht erreichbar)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +354,9 @@ 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');
|
||||
// Dashboard card titles
|
||||
setText('d-card-progress',T.card_progress);
|
||||
setText('d-card-temps',T.card_temps);
|
||||
@@ -406,6 +411,16 @@ function applyLang(){
|
||||
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
|
||||
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
|
||||
setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)');
|
||||
setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)');
|
||||
var dashLbl=document.getElementById('dash-lbl-edit');
|
||||
if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
setText('dash-lbl-reset',T.dash_reset||'Reset');
|
||||
setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset');
|
||||
var dashPresetStd=document.getElementById('dash-preset-standard');
|
||||
if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard';
|
||||
var dashPresetWide=document.getElementById('dash-preset-wide89');
|
||||
if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop';
|
||||
if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar();
|
||||
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
|
||||
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
|
||||
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
|
||||
@@ -433,6 +448,7 @@ function applyLang(){
|
||||
setText('lbl-default-slot',T.settings_default_slot);
|
||||
setText('opt-slot-auto',T.settings_slot_auto);
|
||||
setText('lbl-auto-leveling',T.settings_auto_leveling);
|
||||
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
|
||||
setText('lbl-file-ready-mode',T.settings_file_ready_mode);
|
||||
setText('opt-file-ready-dialog',T.settings_file_ready_dialog);
|
||||
setText('opt-file-ready-banner',T.settings_file_ready_banner);
|
||||
@@ -733,6 +749,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){
|
||||
@@ -1086,6 +1113,7 @@ function openSettings(){
|
||||
document.getElementById('s-mode-id').value=d.mode_id||'';
|
||||
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
|
||||
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
|
||||
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
|
||||
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
|
||||
var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0));
|
||||
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
|
||||
@@ -1095,6 +1123,7 @@ function openSettings(){
|
||||
var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3;
|
||||
pi.value=sec;
|
||||
}
|
||||
var vhl=document.getElementById('s-verbose-http-log');if(vhl)vhl.checked=!!d.verbose_http_log;
|
||||
renderFilamentMapping(d.filament_profiles||{});
|
||||
renderSpoolmanSlotCard();
|
||||
// Spoolman
|
||||
@@ -1851,11 +1880,13 @@ function saveSettings(){
|
||||
device_id: document.getElementById('s-device-id').value,
|
||||
mode_id: document.getElementById('s-mode-id').value,
|
||||
default_ams_slot: document.getElementById('s-default-slot').value,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
|
||||
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
|
||||
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
|
||||
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
|
||||
web_upload_warning:webUploadWarning,
|
||||
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
|
||||
verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0,
|
||||
spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'',
|
||||
spoolman_sync_rate: Math.max(0,parseInt((document.getElementById('s-spoolman-sync-rate')||{}).value||'30',10)),
|
||||
}).then(function(){
|
||||
@@ -1962,8 +1993,314 @@ var pollTimer;
|
||||
});
|
||||
}).catch(function(){});
|
||||
poll();pollTimer=setInterval(poll,ms);
|
||||
setInterval(_loadSpoolmanStatus,30000);
|
||||
// initDashGrid() is called at the very end of the file, after all the
|
||||
// DASH_* var declarations below have executed (they are hoisted but not yet
|
||||
// assigned at this point in the IIFE).
|
||||
})();
|
||||
|
||||
// ── Dashboard Free Grid (Issue #89) ────────────────────────────────────────
|
||||
// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js).
|
||||
// Cards move + resize on a 12-column snap grid; layout persisted per browser
|
||||
// in localStorage. Built on documented APIs only:
|
||||
// - float:false (default) -> compact packing, no holes between cards
|
||||
// - staticGrid:true -> locked by default, setStatic(false) in edit mode
|
||||
// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id
|
||||
// - draggable.cancel -> inputs/buttons/img excluded from drag start
|
||||
// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware)
|
||||
var DASH_STORAGE_KEY='dashLayout';
|
||||
var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states
|
||||
var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams'];
|
||||
// Layout arrays in GridStack save()/load() format. cellHeight=60px.
|
||||
var DASH_DEFAULT_LAYOUT=[
|
||||
{id:'camera', x:0,y:0, w:12,h:7},
|
||||
{id:'progress',x:0,y:7, w:12,h:6},
|
||||
{id:'temps', x:0,y:13,w:6, h:7},
|
||||
{id:'motion', x:6,y:13,w:6, h:7},
|
||||
{id:'speed', x:0,y:20,w:6, h:3},
|
||||
{id:'fan', x:6,y:20,w:6, h:3},
|
||||
{id:'ams', x:0,y:23,w:12,h:4},
|
||||
];
|
||||
// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center,
|
||||
// motion right.
|
||||
var DASH_PRESET_WIDE=[
|
||||
{id:'progress',x:0, y:0, w:12,h:4},
|
||||
{id:'camera', x:0, y:4, w:6, h:11},
|
||||
{id:'temps', x:6, y:4, w:3, h:7},
|
||||
{id:'speed', x:6, y:11,w:3, h:2},
|
||||
{id:'fan', x:6, y:13,w:3, h:2},
|
||||
{id:'motion', x:9, y:4, w:3, h:11},
|
||||
{id:'ams', x:0, y:15,w:12,h:4},
|
||||
];
|
||||
var _dashGrid=null;
|
||||
var _dashEditing=false;
|
||||
var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show)
|
||||
|
||||
// GridStack requires .grid-stack-item > .grid-stack-item-content around each
|
||||
// card — wrap the existing .card elements in place (IDs/event handlers survive,
|
||||
// appendChild moves nodes without recreating them).
|
||||
function _dashWrapCards(){
|
||||
var grid=document.getElementById('dash-grid');
|
||||
if(!grid)return;
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
var card=grid.querySelector('[data-card="'+key+'"]');
|
||||
if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return;
|
||||
var item=document.createElement('div');
|
||||
item.className='grid-stack-item';
|
||||
item.setAttribute('gs-id',key);
|
||||
var content=document.createElement('div');
|
||||
content.className='grid-stack-item-content';
|
||||
grid.insertBefore(item,card);
|
||||
item.appendChild(content);
|
||||
content.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function _dashItem(key){
|
||||
return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]');
|
||||
}
|
||||
|
||||
function _dashLoadState(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_STORAGE_KEY);
|
||||
if(!raw)return null;
|
||||
var s=JSON.parse(raw);
|
||||
if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null;
|
||||
return s;
|
||||
}catch(e){return null;}
|
||||
}
|
||||
function _dashSaveState(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({
|
||||
version:DASH_LAYOUT_VERSION,
|
||||
layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only
|
||||
hidden:_dashHidden.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
function initDashGrid(){
|
||||
var el=document.getElementById('dash-grid');
|
||||
if(!el||typeof GridStack==='undefined')return;
|
||||
_dashWrapCards();
|
||||
_dashGrid=GridStack.init({
|
||||
cellHeight:60,
|
||||
margin:8,
|
||||
staticGrid:true, // locked by default; setStatic(false) enables drag+resize
|
||||
columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware)
|
||||
draggable:{cancel:'input,textarea,button,select,option,img,.slider'},
|
||||
},el);
|
||||
var state=_dashLoadState();
|
||||
if(state){
|
||||
_dashHidden=Array.isArray(state.hidden)?state.hidden:[];
|
||||
_dashGrid.load(state.layout,false); // update matching ids, no add/remove
|
||||
_dashHidden.forEach(function(n){
|
||||
var item=_dashItem(n.id);
|
||||
if(item){_dashGrid.removeWidget(item,false);item.style.display='none';}
|
||||
});
|
||||
}else{
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
}
|
||||
_dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();});
|
||||
_dashRefreshPresetDropdown();
|
||||
}
|
||||
|
||||
function toggleDashEdit(){
|
||||
if(!_dashGrid)return;
|
||||
_dashEditing=!_dashEditing;
|
||||
_dashGrid.setStatic(!_dashEditing);
|
||||
document.getElementById('dash-grid').classList.toggle('editing',_dashEditing);
|
||||
document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none';
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls(); else _dashRemoveControls();
|
||||
_dashRenderHiddenBar();
|
||||
if(!_dashEditing)_dashSaveState();
|
||||
}
|
||||
|
||||
// ── Custom presets (user-saved layouts) ────────────────────────────────────
|
||||
var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets';
|
||||
var DASH_BUILTIN_PRESET_IDS=['standard','wide89'];
|
||||
|
||||
function _dashLoadCustomPresets(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY);
|
||||
var obj=raw?JSON.parse(raw):{};
|
||||
return (obj&&typeof obj==='object')?obj:{};
|
||||
}catch(e){return {};}
|
||||
}
|
||||
function _dashSaveCustomPresets(presets){
|
||||
localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets));
|
||||
}
|
||||
|
||||
function _dashRefreshPresetDropdown(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel)return;
|
||||
var current=sel.value;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
// Remove previously-rendered custom <option>s, keep the two built-ins.
|
||||
Array.prototype.slice.call(sel.querySelectorAll('option')).forEach(function(o){
|
||||
if(DASH_BUILTIN_PRESET_IDS.indexOf(o.value)===-1)sel.removeChild(o);
|
||||
});
|
||||
Object.keys(customs).sort().forEach(function(name){
|
||||
var opt=document.createElement('option');
|
||||
opt.value='custom:'+name;
|
||||
opt.textContent=name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if(Array.prototype.some.call(sel.options,function(o){return o.value===current;}))sel.value=current;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function _dashUpdatePresetDeleteBtn(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
var delBtn=document.getElementById('dash-preset-delete-btn');
|
||||
if(!sel||!delBtn)return;
|
||||
var isCustom=sel.value.indexOf('custom:')===0;
|
||||
delBtn.style.display=(_dashEditing&&isCustom)?'':'none';
|
||||
}
|
||||
|
||||
function saveCurrentAsDashPreset(){
|
||||
if(!_dashGrid)return;
|
||||
var name=(prompt(T.dash_preset_name_prompt||'Preset name:')||'').trim();
|
||||
if(!name)return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
customs[name]=_dashGrid.save(false);
|
||||
_dashSaveCustomPresets(customs);
|
||||
_dashRefreshPresetDropdown();
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value='custom:'+name;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function deleteCurrentDashPreset(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel||sel.value.indexOf('custom:')!==0)return;
|
||||
var name=sel.value.slice(7);
|
||||
if(!confirm((T.dash_preset_delete_confirm||'Delete preset "{name}"?').replace('{name}',name)))return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
delete customs[name];
|
||||
_dashSaveCustomPresets(customs);
|
||||
sel.value='standard';
|
||||
_dashRefreshPresetDropdown();
|
||||
resetDashLayout();
|
||||
}
|
||||
|
||||
function _dashInjectControls(){
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var content=document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"] .grid-stack-item-content');
|
||||
if(!content||content.querySelector('.dash-card-controls'))return;
|
||||
var ctrl=document.createElement('div');
|
||||
ctrl.className='dash-card-controls';
|
||||
var hideBtn=document.createElement('button');
|
||||
hideBtn.className='dash-card-ctrl-btn';
|
||||
hideBtn.title=T.dash_hide||'Hide';
|
||||
hideBtn.textContent='👁';
|
||||
hideBtn.addEventListener('click',function(e){e.stopPropagation();dashHideCard(key);});
|
||||
ctrl.appendChild(hideBtn);
|
||||
content.appendChild(ctrl);
|
||||
});
|
||||
}
|
||||
function _dashRemoveControls(){
|
||||
var nodes=document.querySelectorAll('#dash-grid .dash-card-controls');
|
||||
Array.prototype.forEach.call(nodes,function(n){n.parentNode&&n.parentNode.removeChild(n);});
|
||||
}
|
||||
|
||||
function _dashCardLabel(key){
|
||||
var card=document.querySelector('[data-card="'+key+'"]');
|
||||
if(!card)return key;
|
||||
var t=card.querySelector('.card-title');
|
||||
return t?t.textContent.trim():key;
|
||||
}
|
||||
|
||||
function _dashRenderHiddenBar(){
|
||||
var bar=document.getElementById('dash-hidden-bar');
|
||||
if(!bar)return;
|
||||
bar.innerHTML='';
|
||||
if(!_dashEditing||!_dashHidden.length){bar.classList.remove('show');return;}
|
||||
bar.classList.add('show');
|
||||
var label=document.createElement('span');
|
||||
label.textContent=(T.dash_hidden_cards||'Hidden cards')+': ';
|
||||
bar.appendChild(label);
|
||||
_dashHidden.forEach(function(n){
|
||||
var chip=document.createElement('span');
|
||||
chip.className='dash-hidden-chip';
|
||||
chip.appendChild(document.createTextNode(_dashCardLabel(n.id)+' '));
|
||||
var btn=document.createElement('button');
|
||||
btn.textContent='👁';
|
||||
btn.title=T.dash_show||'Show';
|
||||
btn.addEventListener('click',function(){dashShowCard(n.id);});
|
||||
chip.appendChild(btn);
|
||||
bar.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function dashHideCard(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var item=_dashItem(key);
|
||||
if(!item||!_dashGrid)return;
|
||||
// Remember position/size so re-show can restore it.
|
||||
var n=item.gridstackNode||{};
|
||||
_dashHidden.push({id:key,x:n.x,y:n.y,w:n.w,h:n.h});
|
||||
_dashGrid.removeWidget(item,false); // keep DOM element, drop the widget
|
||||
item.style.display='none';
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function dashShowCard(key){
|
||||
var entry=null;
|
||||
_dashHidden=_dashHidden.filter(function(n){if(n.id===key){entry=n;return false;}return true;});
|
||||
var item=_dashItem(key);
|
||||
if(item&&_dashGrid){
|
||||
item.style.display='';
|
||||
_dashGrid.makeWidget(item);
|
||||
if(entry&&entry.w)_dashGrid.update(item,{x:entry.x,y:entry.y,w:entry.w,h:entry.h});
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
}
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
function _dashShowAllHidden(){
|
||||
_dashHidden.slice().forEach(function(n){dashShowCard(n.id);});
|
||||
_dashHidden=[];
|
||||
}
|
||||
|
||||
function resetDashLayout(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.removeItem(DASH_STORAGE_KEY);
|
||||
var sel=document.getElementById('dash-preset');if(sel)sel.value='standard';
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
if(_dashEditing){_dashInjectControls();_dashSaveState();}
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function applyDashPreset(name){
|
||||
if(name==='standard'){resetDashLayout();return;}
|
||||
if(!_dashGrid)return;
|
||||
var layout=null;
|
||||
if(name==='wide89'){
|
||||
layout=DASH_PRESET_WIDE;
|
||||
}else if(name.indexOf('custom:')===0){
|
||||
var customs=_dashLoadCustomPresets();
|
||||
layout=customs[name.slice(7)];
|
||||
}
|
||||
if(!layout)return;
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value=name; // keep the dropdown in sync even when called directly
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(layout,false);
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
// Init now — all DASH_* declarations above have run, and app.js loads at
|
||||
// </body>, so the DOM is ready.
|
||||
initDashGrid();
|
||||
// ── Print actions ──
|
||||
function printAction(a){
|
||||
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
||||
@@ -2422,10 +2759,42 @@ function aceDryStop(aceId){
|
||||
function loadStore(){
|
||||
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
|
||||
storeFiles=d.result||[];
|
||||
// Reset any pending selection - stale ids from a deleted/renamed file
|
||||
// should never carry over into a fresh file list.
|
||||
storeExitSelectMode();
|
||||
renderStore();
|
||||
}).catch(function(e){clog('Store-Fehler: '+e,'msg-err')});
|
||||
}
|
||||
|
||||
// Applies the store's search/filter/sort controls to storeFiles. Shared by
|
||||
// renderStore() and storeToggleSelectAll() so "Select All" only selects what
|
||||
// the user can currently see, not the entire (possibly filtered-out) list.
|
||||
function _storeFilteredFiles(){
|
||||
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
|
||||
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
|
||||
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
|
||||
|
||||
var files=storeFiles.filter(function(f){
|
||||
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
|
||||
if(filter==='completed'&&f.last_print_status!=='completed') return false;
|
||||
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
|
||||
if(filter==='never'&&f.last_print_status) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
files.sort(function(a,b){
|
||||
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
|
||||
if(sort==='duration_asc'){
|
||||
var da=a.last_print_duration||a.est_print_time_sec||0;
|
||||
var db=b.last_print_duration||b.est_print_time_sec||0;
|
||||
return da-db;
|
||||
}
|
||||
// date_desc (default)
|
||||
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
function uploadGcode(file){
|
||||
if(!file) return;
|
||||
var zone=document.getElementById('store-upload-zone');
|
||||
@@ -2470,32 +2839,7 @@ function uploadGcode(file){
|
||||
function renderStore(){
|
||||
var grid=document.getElementById('store-grid');
|
||||
var empty=document.getElementById('store-empty');
|
||||
|
||||
// Suche
|
||||
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
|
||||
// Filter
|
||||
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
|
||||
// Sortierung
|
||||
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
|
||||
|
||||
var files=storeFiles.filter(function(f){
|
||||
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
|
||||
if(filter==='completed'&&f.last_print_status!=='completed') return false;
|
||||
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
|
||||
if(filter==='never'&&f.last_print_status) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
files.sort(function(a,b){
|
||||
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
|
||||
if(sort==='duration_asc'){
|
||||
var da=a.last_print_duration||a.est_print_time_sec||0;
|
||||
var db=b.last_print_duration||b.est_print_time_sec||0;
|
||||
return da-db;
|
||||
}
|
||||
// date_desc (default)
|
||||
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
|
||||
});
|
||||
var files=_storeFilteredFiles();
|
||||
|
||||
if(!storeFiles.length){
|
||||
empty.textContent=T.store_empty;
|
||||
@@ -2528,22 +2872,56 @@ function renderStore(){
|
||||
} else if(!f.last_print_status){
|
||||
lastInfo='<div style="font-size:11px;color:var(--txt2);margin-bottom:2px;opacity:.6">'+T.store_never+'</div>';
|
||||
}
|
||||
return '<div style="background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:10px;display:flex;flex-direction:column">'+
|
||||
var isSelected=!!_storeSelected[f.id];
|
||||
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
|
||||
// Always visible (not just once select mode is on) - otherwise there is
|
||||
// no way to ever enter select mode, since it's normally entered by
|
||||
// clicking this very checkbox. White circle behind it so it stays
|
||||
// legible against any thumbnail.
|
||||
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
|
||||
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
|
||||
'align-items:center;justify-content:center">'+
|
||||
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
|
||||
' onclick="event.stopPropagation();storeToggleSelect(\''+f.id+'\')" '+
|
||||
'style="width:16px;height:16px;margin:0"></span>';
|
||||
var cardClick=_storeSelectMode?'onclick="storeToggleSelect(\''+f.id+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
|
||||
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
|
||||
return '<div '+cardClick+'>'+
|
||||
checkbox+
|
||||
thumb+
|
||||
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+statusBadge+'</div>'+
|
||||
lastInfo+
|
||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">⏱ '+T.store_estimate+': '+est+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
|
||||
'<div style="display:flex;gap:6px;margin-top:auto">'+
|
||||
'<button onclick="storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
|
||||
'<button onclick="event.stopPropagation();storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
|
||||
'style="flex:1;font-size:12px;padding:5px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer">'+T.store_print+'</button>'+
|
||||
'<button onclick="storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
|
||||
'<button onclick="event.stopPropagation();storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
|
||||
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">⬇</button>'+
|
||||
'<button onclick="storeDelete(\''+f.id+'\')" '+
|
||||
'<button onclick="event.stopPropagation();storeDelete(\''+f.id+'\')" '+
|
||||
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
_storeUpdateSelectBar(files);
|
||||
}
|
||||
|
||||
// Reflects the current selection onto the select-all checkbox (incl.
|
||||
// indeterminate state), the selected-count label, and the delete button's
|
||||
// disabled state. `files` is the currently-filtered/visible list.
|
||||
function _storeUpdateSelectBar(files){
|
||||
var selectAll=document.getElementById('store-select-all');
|
||||
var countEl=document.getElementById('store-selected-count');
|
||||
var delBtn=document.getElementById('store-delete-selected-btn');
|
||||
if(!selectAll||!countEl||!delBtn)return;
|
||||
var visibleIds=files.map(function(f){return f.id;});
|
||||
var selectedVisible=visibleIds.filter(function(id){return _storeSelected[id];});
|
||||
var n=selectedVisible.length;
|
||||
selectAll.checked=n>0&&n===visibleIds.length;
|
||||
selectAll.indeterminate=n>0&&n<visibleIds.length;
|
||||
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
|
||||
delBtn.disabled=n===0;
|
||||
}
|
||||
|
||||
function formatDur(sec){
|
||||
@@ -2563,6 +2941,52 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
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 ═══ -->
|
||||
@@ -403,6 +417,19 @@
|
||||
</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>
|
||||
@@ -509,6 +536,10 @@
|
||||
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
|
||||
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
|
||||
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance compensation before print</label>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
|
||||
<select id="s-file-ready-mode">
|
||||
@@ -550,6 +581,10 @@
|
||||
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
|
||||
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-verbose-http-log" style="width:auto;margin:0">
|
||||
<label id="lbl-verbose-http-log" style="margin:0;cursor:pointer" for="s-verbose-http-log">Log every HTTP request (verbose)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%}
|
||||
@@ -95,18 +95,63 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
|
||||
/* ── CARD ── */
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
padding:18px;transition:box-shadow .15s,transform .15s}
|
||||
padding:18px;transition:box-shadow .15s,transform .15s;container-type:inline-size}
|
||||
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
|
||||
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
|
||||
margin-bottom:14px;display:flex;align-items:center;gap:8px}
|
||||
.card-title span{font-size:14px}
|
||||
|
||||
/* ── DASHBOARD FREE GRID (GridStack) ── */
|
||||
/* .grid-stack-item-content is already position:absolute + fills the cell
|
||||
(GridStack's own CSS). The card inside just needs to fill that box — it
|
||||
must NOT be position:absolute itself, or it can sit above/intercept
|
||||
GridStack's resize-handle hit area and drag listeners. */
|
||||
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
|
||||
the cell smaller than the content needs. */
|
||||
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;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);
|
||||
@@ -161,6 +206,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}
|
||||
|
||||
@@ -121,6 +121,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)",
|
||||
@@ -212,6 +213,7 @@
|
||||
"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",
|
||||
@@ -245,6 +247,19 @@
|
||||
"settings_password": "MQTT-Passwort",
|
||||
"settings_poll": "Poll-Intervall (Sekunden)",
|
||||
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
|
||||
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
|
||||
"dash_edit": "Dashboard anpassen",
|
||||
"dash_done": "Fertig",
|
||||
"dash_reset": "Zurücksetzen",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Desktop breit",
|
||||
"dash_hidden_cards": "Ausgeblendete Karten",
|
||||
"dash_show": "Einblenden",
|
||||
"dash_hide": "Ausblenden",
|
||||
"dash_toggle_width": "Breite umschalten",
|
||||
"dash_save_preset": "Als Preset speichern",
|
||||
"dash_preset_name_prompt": "Preset-Name:",
|
||||
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
|
||||
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
|
||||
"settings_print": "Druckeinstellungen",
|
||||
"settings_printer_ip": "Drucker-IP",
|
||||
@@ -296,6 +311,8 @@
|
||||
"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",
|
||||
@@ -304,6 +321,9 @@
|
||||
"store_print": "▶ Drucken",
|
||||
"store_print_confirm": "Datei drucken?",
|
||||
"store_refresh": "↻ Aktualisieren",
|
||||
"store_select_all": "Alle auswählen",
|
||||
"store_selected_count": "{n} ausgewählt",
|
||||
"store_exit_select": "Abbrechen",
|
||||
"store_search_placeholder": "🔍 Suche…",
|
||||
"store_upload_busy": "⏳ Hochladen…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
|
||||
@@ -121,6 +121,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)",
|
||||
@@ -212,6 +213,7 @@
|
||||
"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",
|
||||
@@ -245,6 +247,19 @@
|
||||
"settings_password": "MQTT Password",
|
||||
"settings_poll": "Poll Interval (seconds)",
|
||||
"settings_poll_interval_hint": "How often the bridge queries printer status",
|
||||
"settings_verbose_http_log": "Log every HTTP request (verbose)",
|
||||
"dash_edit": "Customize dashboard",
|
||||
"dash_done": "Done",
|
||||
"dash_reset": "Reset",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Wide desktop",
|
||||
"dash_hidden_cards": "Hidden cards",
|
||||
"dash_show": "Show",
|
||||
"dash_hide": "Hide",
|
||||
"dash_toggle_width": "Toggle width",
|
||||
"dash_save_preset": "Save as preset",
|
||||
"dash_preset_name_prompt": "Preset name:",
|
||||
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Poll Interval (seconds)",
|
||||
"settings_print": "Print Settings",
|
||||
"settings_printer_ip": "Printer IP",
|
||||
@@ -296,6 +311,8 @@
|
||||
"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",
|
||||
@@ -304,6 +321,9 @@
|
||||
"store_print": "▶ Print",
|
||||
"store_print_confirm": "Print file?",
|
||||
"store_refresh": "↻ Refresh",
|
||||
"store_select_all": "Select All",
|
||||
"store_selected_count": "{n} selected",
|
||||
"store_exit_select": "Cancel",
|
||||
"store_search_placeholder": "🔍 Search…",
|
||||
"store_upload_busy": "⏳ Uploading…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
|
||||
@@ -121,6 +121,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)",
|
||||
@@ -212,6 +213,7 @@
|
||||
"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",
|
||||
@@ -245,6 +247,19 @@
|
||||
"settings_password": "Contraseña MQTT",
|
||||
"settings_poll": "Intervalo de sondeo (segundos)",
|
||||
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
|
||||
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
|
||||
"dash_edit": "Personalizar panel",
|
||||
"dash_done": "Listo",
|
||||
"dash_reset": "Restablecer",
|
||||
"dash_preset_standard": "Estándar",
|
||||
"dash_preset_wide89": "Escritorio ancho",
|
||||
"dash_hidden_cards": "Tarjetas ocultas",
|
||||
"dash_show": "Mostrar",
|
||||
"dash_hide": "Ocultar",
|
||||
"dash_toggle_width": "Cambiar ancho",
|
||||
"dash_save_preset": "Guardar como preset",
|
||||
"dash_preset_name_prompt": "Nombre del preset:",
|
||||
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
|
||||
"settings_print": "Ajustes de impresión",
|
||||
"settings_printer_ip": "IP de impresora",
|
||||
@@ -296,6 +311,8 @@
|
||||
"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",
|
||||
@@ -304,6 +321,9 @@
|
||||
"store_print": "▶ Imprimir",
|
||||
"store_print_confirm": "¿Imprimir archivo?",
|
||||
"store_refresh": "↻ Actualizar",
|
||||
"store_select_all": "Seleccionar todo",
|
||||
"store_selected_count": "{n} seleccionados",
|
||||
"store_exit_select": "Cancelar",
|
||||
"store_search_placeholder": "🔍 Buscar…",
|
||||
"store_upload_busy": "⏳ Subiendo…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
|
||||
@@ -121,6 +121,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)",
|
||||
@@ -296,6 +297,8 @@
|
||||
"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",
|
||||
@@ -304,6 +307,9 @@
|
||||
"store_print": "▶ Imprimer",
|
||||
"store_print_confirm": "Imprimer le fichier ?",
|
||||
"store_refresh": "↻ Actualiser",
|
||||
"store_select_all": "Tout sélectionner",
|
||||
"store_selected_count": "{n} sélectionné(s)",
|
||||
"store_exit_select": "Annuler",
|
||||
"store_search_placeholder": "🔍 Rechercher…",
|
||||
"store_upload_busy": "⏳ Envoi en cours…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
|
||||
@@ -121,6 +121,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)",
|
||||
@@ -296,6 +297,8 @@
|
||||
"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",
|
||||
@@ -304,6 +307,9 @@
|
||||
"store_print": "▶ Stampa",
|
||||
"store_print_confirm": "Stampare il file?",
|
||||
"store_refresh": "↻ Aggiorna",
|
||||
"store_select_all": "Seleziona tutto",
|
||||
"store_selected_count": "{n} selezionati",
|
||||
"store_exit_select": "Annulla",
|
||||
"store_search_placeholder": "🔍 Cerca…",
|
||||
"store_upload_busy": "⏳ Caricamento in corso…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "进料",
|
||||
"lbl_layers": "层",
|
||||
"lbl_light": "💡 灯光",
|
||||
"lbl_pause_reason": "打印已暂停:",
|
||||
"lbl_remaining": "剩余时间:",
|
||||
"lbl_slicer_time": "切片预估:",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=打印结束)",
|
||||
@@ -212,6 +213,7 @@
|
||||
"progress_action_slots": "分配槽位",
|
||||
"settings_auto_leveling": "打印前自动调平",
|
||||
"settings_auto_leveling_label": "打印前自动调平",
|
||||
"settings_vibration_compensation": "打印前共振补偿",
|
||||
"settings_btn_tooltip": "设置",
|
||||
"settings_camera_on_print": "打印开始时开启相机",
|
||||
"settings_cat_connection": "连接",
|
||||
@@ -245,6 +247,19 @@
|
||||
"settings_password": "MQTT 密码",
|
||||
"settings_poll": "轮询间隔(秒)",
|
||||
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
|
||||
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
|
||||
"dash_edit": "自定义仪表盘",
|
||||
"dash_done": "完成",
|
||||
"dash_reset": "重置",
|
||||
"dash_preset_standard": "标准",
|
||||
"dash_preset_wide89": "宽屏桌面",
|
||||
"dash_hidden_cards": "隐藏的卡片",
|
||||
"dash_show": "显示",
|
||||
"dash_hide": "隐藏",
|
||||
"dash_toggle_width": "切换宽度",
|
||||
"dash_save_preset": "另存为预设",
|
||||
"dash_preset_name_prompt": "预设名称:",
|
||||
"dash_preset_delete_confirm": "删除预设 \"{name}\"?",
|
||||
"settings_poll_interval_label": "轮询间隔(秒)",
|
||||
"settings_print": "打印设置",
|
||||
"settings_printer_ip": "打印机 IP",
|
||||
@@ -296,6 +311,8 @@
|
||||
"ss_dur": "⏱ 打印时间",
|
||||
"ss_name": "A–Z 名称",
|
||||
"store_delete_confirm": "删除文件?",
|
||||
"store_delete_selected": "删除所选",
|
||||
"store_delete_selected_confirm": "删除已选择的 {n} 个文件?",
|
||||
"store_download": "⬇ 下载",
|
||||
"store_empty": "尚未上传文件。",
|
||||
"store_estimate": "估算",
|
||||
@@ -304,6 +321,9 @@
|
||||
"store_print": "▶ 打印",
|
||||
"store_print_confirm": "打印文件?",
|
||||
"store_refresh": "↻ 刷新",
|
||||
"store_select_all": "全选",
|
||||
"store_selected_count": "已选择 {n} 个",
|
||||
"store_exit_select": "取消",
|
||||
"store_search_placeholder": "🔍 搜索…",
|
||||
"store_upload_busy": "⏳ 上传中…",
|
||||
"store_upload_error": "✗ {error}",
|
||||
|
||||
Reference in New Issue
Block a user