Compare commits

..

6 Commits

17 changed files with 1163 additions and 145 deletions

View File

@@ -1,5 +1,40 @@
# Changelog
## [0.9.26] 2026-06-21
### Neu
- **Italienische Sprachunterstützung** (PR #66, @Alex_M). Die Bridge-UI ist jetzt vollständig auf Italienisch verfügbar.
### Behoben
- **Kamera startete immer beim Druckbeginn** (Issue #50). `camera_on_print` fehlte in der `/api/state`-Antwort — JavaScript las `undefined` und startete die Kamera unabhängig vom Setting. Jetzt korrekt im State enthalten.
- **Auto-Leveling-Setting wurde im Moonraker-Druckpfad ignoriert** (Issue #57). `handle_print_start` las den Wert nur aus den Bridge-Args, nicht aus dem Request-Body — Dialog-Checkbox und Per-Print-Override hatten keine Wirkung. Verhält sich jetzt identisch zum direkten Druckpfad.
- **Filament-Mapping: Freitext-Felder durch Dropdowns ersetzt** (Issue #57). Falsch getippte Vendor/Name-Kombination brach das Profil-Matching ohne Fehlermeldung; Felder sind jetzt Dropdowns (Vendor → Profil, vendor-gefiltert), sodass nur gültige Kombinationen gespeichert werden können.
- **Dashboard zeigte generischen Materialtyp statt Profilname** (Issue #57). AMS-Slot-Karten zeigen jetzt den gemappten Profilnamen (z.B. „eSUN PLA-Basic") statt nur „PLA". Fallback auf generischen Typ wenn kein Profil gemappt ist.
- **Ghost-Profil auf leerem Slot** (Issue #57). Verwaiste Mappings für leere Slots wurden weiterhin angezeigt; leere Slots zeigen jetzt korrekt „–".
- **Skip-Objects-Panel fehlte im Orca-Upload-Flow** (Issue #57). Panel erscheint jetzt in allen Druckflows; bei frischem Upload fragt die Bridge `fileDetails` beim Drucker nach und pollt die Objektliste bis zu 6 Sekunden nach.
- **Banner und Dialog erschienen gleichzeitig** (Issue #57). Settings-Save setzt jetzt den Dialog-Cancel-State zurück, sodass der Slot-Mapper nach Wechsel des Start-Print-Verhaltens zuverlässig öffnet.
- **„Leeren" lud idle-Datei beim nächsten Poll nach** (Issue #57). Leeren setzt jetzt den lokalen State sofort zurück (`file_ready`, `filename`, `thumbnail`) und löscht alle Dialog-Sperren — Vorschaubild und Aktions-Buttons verschwinden sofort und kommen nicht zurück.
- **Material-Matching für „PLA Silk", „Matte PLA" etc.** (PR #64, @p2l). Modifier+Basis-Muster in beliebiger Wortreihenfolge werden jetzt auf den Basis-Typ normalisiert; Dash-Varianten (PLA-CF) bleiben weiterhin korrekt inkompatibel mit ihrem Basis-Typ.
## [0.9.25] 2026-06-17
### Behoben
- **Zufällige Abstürze / Container-Restarts — Segfault in `libcrypto.so.3`
(Issue #53).** Der MQTT-über-TLS-Client teilte einen einzelnen SSL-Socket
zwischen dem Reader-Thread (`recv`) und den Sender-Threads (`sendall`), ohne sie
zu serialisieren. CPythons `ssl`-Modul erlaubt kein gleichzeitiges Lesen und
Schreiben auf demselben Socket — die Überlappung korrumpierte den internen
OpenSSL-Zustand und löste eine Heap-Corruption + Segfault aus, die auf manchen
Hosts timing-bedingt zuverlässig auftrat. Sämtliche Socket-Zugriffe (recv /
sendall / close / reconnect) werden nun unter einem einzigen Lock serialisiert;
der Reader prüft die Bereitschaft mit `select()` außerhalb des Locks, damit die
Sender nie ausgehungert werden. Reconnect und Disconnect tauschen den Socket
jetzt atomar. Dank an @BasK für den detaillierten Fault-Handler-Trace.
- **File-Browser akzeptierte Nicht-GCode-Uploads (Issue #59).** Drag & Drop umging
den `accept`-Filter des Dateidialogs, sodass z.B. ein JPG hochgeladen werden
konnte. Uploads werden jetzt client- und serverseitig validiert; nur `.gcode`,
`.gcode.3mf`, `.3mf` und `.bgcode` werden akzeptiert. Dank an @gangoke.
## [0.9.24] 2026-06-16
### Neu

View File

@@ -1,5 +1,58 @@
# Changelog
## [0.9.26] 2026-06-21
### New
- **Italian language support** (PR #66, @Alex_M). The bridge UI is now fully
available in Italian.
### Fixed
- **Camera always started at print begin** (issue #50). `camera_on_print` was
missing from the `/api/state` response — JavaScript read `undefined` and started
the camera regardless of the setting. Now correctly exposed in state.
- **Auto-leveling setting ignored in Moonraker print path** (issue #57).
`handle_print_start` read the value only from bridge args, not from the request
body, so the dialog checkbox and the per-print override had no effect. Now
behaves identically to the direct print path.
- **Filament mapping free-text fields replaced by dropdowns** (issue #57). A
mistyped vendor/name broke profile matching silently; fields are now dropdowns
(vendor → profile, vendor-filtered) so only valid combinations can be saved.
- **Dashboard showed generic material type instead of profile name** (issue #57).
AMS slot cards now display the mapped profile name (e.g. "eSUN PLA-Basic")
instead of just "PLA". Falls back to the generic type when no profile is mapped.
- **Ghost profile shown on empty slot** (issue #57). Stale mappings for empty
slots were still rendered; empty slots now correctly show "".
- **Skip-Objects panel missing in Orca upload flow** (issue #57). Panel now
appears in all print flows; on fresh upload the bridge requests `fileDetails`
from the printer and retries the object list for up to 6 s.
- **Banner and dialog appeared simultaneously** (issue #57). Settings save now
resets the dialog cancel state so the slot mapper reliably opens after toggling
Start Print Behavior.
- **"Clear" reloaded idle file on next poll** (issue #57). Clear now immediately
resets local state (`file_ready`, `filename`, `thumbnail`) and clears all dialog
locks — the preview and action buttons disappear instantly and do not return.
- **Material matching for "PLA Silk", "Matte PLA" etc.** (PR #64, @p2l).
Modifier+base patterns in any word order are now normalised to the base type;
dash-suffix variants (PLA-CF) remain correctly incompatible with their base.
## [0.9.25] 2026-06-17
### Fixed
- **Random crashes / container restarts — segfault in `libcrypto.so.3` (issue #53).**
The MQTT-over-TLS client shared a single SSL socket between the reader thread
(`recv`) and the sender threads (`sendall`) without serializing them. CPython's
`ssl` module does not allow concurrent read and write on the same socket — the
overlap corrupted OpenSSL's internal state, causing a heap corruption and a
segfault that manifested reliably on some hosts (timing-dependent). All socket
access (recv / sendall / close / reconnect) is now serialized under a single
lock; the reader probes readiness with `select()` outside the lock so senders
are never starved. Reconnect and disconnect now swap the socket atomically.
Thanks to @BasK for the detailed fault-handler trace that pinpointed this.
- **File browser accepted non-GCode uploads (issue #59).** Drag & drop bypassed
the file picker's `accept` filter, so e.g. a JPG could be uploaded. Uploads are
now validated both client- and server-side; only `.gcode`, `.gcode.3mf`, `.3mf`
and `.bgcode` are accepted. Thanks @gangoke.
## [0.9.24] 2026-06-16
### New

View File

@@ -1 +1 @@
0.9.24
0.9.27-nightly1

View File

@@ -13,6 +13,7 @@ _BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) els
CONFIG_SECTION_CONNECTION = "connection"
CONFIG_SECTION_PRINT = "print"
CONFIG_SECTION_BRIDGE = "bridge"
CONFIG_SECTION_SPOOLMAN = "spoolman"
def _find_config_file() -> pathlib.Path | None:
@@ -63,6 +64,8 @@ def _load_config_file(path: pathlib.Path):
"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():
if env_key not in os.environ:
@@ -317,3 +320,5 @@ AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
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"))

View File

@@ -0,0 +1,3 @@
services:
kx-bridge:
image: gitea.it-drui.de/viewit/kx-bridge:nightly

View File

@@ -0,0 +1,25 @@
# KX-Bridge Nightly — Portainer Stack
#
# Paste this into Portainer → Stacks → Add stack → Web editor
#
# Uses the nightly build — may be unstable, for testing new features early.
# For production use, see docker-compose.portainer.yml (:latest).
services:
kx-bridge:
image: gitea.it-drui.de/viewit/kx-bridge:nightly
volumes:
- /mnt/dockerdata/kx-nightly/config:/app/config
- /mnt/dockerdata/kx-nightly/data:/app/data
ports:
# Port 7125 = first printer. Add 7126, 7127, … for each additional printer.
- "7125:7125"
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Verzeichnisse müssen auf dem Host existieren:
# mkdir -p /mnt/dockerdata/kx-nightly/config /mnt/dockerdata/kx-nightly/data

View File

@@ -48,4 +48,6 @@ 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"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))

View File

@@ -27,6 +27,7 @@ import hashlib
import json
import logging
import os
import select
import socket
import ssl
import sys
@@ -120,6 +121,10 @@ 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.
self._sock_gen = 0
self._running = False
# Pending requests by msgid (for response ACK)
@@ -167,21 +172,31 @@ class KobraXClient:
ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
ctx.load_cert_chain(CERT_FILE, KEY_FILE)
_ai = socket.getaddrinfo(self.host, self.port, socket.AF_INET, socket.SOCK_STREAM)
raw = socket.create_connection(_ai[0][4], timeout=5)
self._sock = ctx.wrap_socket(raw)
log.info("TLS connected cipher=%s", self._sock.cipher()[0])
# 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).
_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)
log.info("TLS connected cipher=%s", new_sock.cipher()[0])
self._sock.sendall(_build_connect(self.client_id, self.username, self.password))
self._sock.settimeout(3)
r = self._sock.recv(64)
new_sock.sendall(_build_connect(self.client_id, self.username, self.password))
new_sock.settimeout(3)
r = new_sock.recv(64)
if len(r) < 4 or r[0] != 0x20 or r[3] != 0:
try:
new_sock.close()
except Exception:
pass
raise RuntimeError(f"CONNACK failed: {r.hex()}")
log.info("CONNACK rc=0")
self._sock.settimeout(0.2)
self._buf = b""
self._subscribe(self._sub_topic())
new_sock.settimeout(0.2)
with self._lock:
self._sock = new_sock
self._sock_gen += 1
self._buf = b""
self._subscribe(self._sub_topic()) # nimmt das Lock selbst — nicht verschachteln
log.debug("MQTT connected to %s:%s", self.host, self.port)
def connect(self):
@@ -207,10 +222,14 @@ class KobraXClient:
def disconnect(self):
self._running = False
try:
self._sock.close()
except Exception:
pass
with self._lock:
try:
if self._sock is not None:
self._sock.close()
except Exception:
pass
self._sock = None
self._sock_gen += 1
def _reconnect(self):
"""Persistenter Reconnect: versucht endlos weiter bis der Drucker wieder
@@ -219,10 +238,16 @@ class KobraXClient:
nur DEBUG um Log-Spam bei langem Drucker-Ausfall (z.B. über Nacht
ausgeschaltet) zu vermeiden."""
log.warning("Verbindung verloren reconnect…")
try:
self._sock.close()
except Exception:
pass
# Close + Invalidierung unter Lock, damit kein Sender mitten im sendall
# auf den gerade geschlossenen Socket trifft (Issue #53).
with self._lock:
try:
if self._sock is not None:
self._sock.close()
except Exception:
pass
self._sock = None
self._sock_gen += 1
delays = [2, 4, 8, 15, 30, 60]
attempt = 0
while self._running:
@@ -246,7 +271,8 @@ class KobraXClient:
with self._lock:
pid = self._pid
self._pid += 1
self._sock.sendall(_build_subscribe(topic, pid))
if self._sock is not None:
self._sock.sendall(_build_subscribe(topic, pid))
log.info("SUB %s", topic)
# -- Read loop -----------------------------------------------------------
@@ -256,17 +282,52 @@ class KobraXClient:
_empty_count = 0
while self._running:
if time.time() - last_ping > 30:
ping_ok = False
with self._lock:
try:
self._sock.sendall(_build_pingreq())
if self._sock is not None:
self._sock.sendall(_build_pingreq())
ping_ok = True
except Exception:
if self._running and not self._reconnect():
break
last_ping = time.time()
continue
ping_ok = False
# _reconnect() AUSSERHALB des Locks aufrufen — es nimmt das Lock
# selbst, und threading.Lock ist nicht reentrant (sonst Deadlock).
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).
with self._lock:
sock = self._sock
gen = self._sock_gen
if sock is None:
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.
try:
data = self._sock.recv(65536)
ready, _, _ = select.select([sock], [], [], 0.2)
except (OSError, ValueError):
# fd geschlossen/ungültig (Reconnect oder Disconnect mitten im select)
if not self._running:
break
time.sleep(0.05)
continue
if not ready:
continue # Leerlauf, kein Lock gehalten
# 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).
try:
with self._lock:
# Socket könnte zwischen select und hier ersetzt worden sein.
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
_empty_count += 1
@@ -275,7 +336,7 @@ class KobraXClient:
continue
_empty_count = 0
self._buf += data
self._drain()
self._drain() # außerhalb des Locks — Dispatch/event.set() bleibt prompt
except ssl.SSLWantReadError:
continue
except socket.timeout:

View File

@@ -733,6 +733,40 @@ class CameraCache:
await asyncio.sleep(2.0)
class SpoolmanClient:
"""Thin synchronous HTTP client for Spoolman filament tracking.
Designed to be called from daemon threads (poll loop, _on_print callbacks).
Uses requests (already in requirements) so no event-loop dependency.
"""
def __init__(self, server_url: str, sync_rate: int = 0):
self.server_url = server_url.rstrip("/")
self.sync_rate = sync_rate
def _req(self, method: str, path: str, **kwargs):
import requests
r = requests.request(method, f"{self.server_url}{path}", timeout=5, **kwargs)
r.raise_for_status()
return r.json()
def health_check(self) -> bool:
try:
self._req("GET", "/api/v1/health")
return True
except Exception:
return False
def list_spools(self) -> list:
return self._req("GET", "/api/v1/spool")
def use_filament(self, spool_id: int, use_length_mm: float) -> None:
"""Report consumed filament length in mm. Spoolman converts to weight
using the spool's filament profile density."""
self._req("PUT", f"/api/v1/spool/{spool_id}/use",
json={"use_length": round(use_length_mm, 2)})
class KobraXBridge:
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
self.client = client
@@ -793,6 +827,7 @@ class KobraXBridge:
"file_ready": "",
"print_start_dialog": getattr(args, "print_start_dialog", 1),
"filament_mode": "toolhead",
"supplies_usage": 0,
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
}
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
@@ -819,6 +854,18 @@ class KobraXBridge:
self._pending_preprint_skip: list[str] = []
self._pending_preprint_skip_deadline: float = 0.0
# Spoolman filament tracking
_sm_url = (getattr(args, "spoolman_server", "") or "").strip()
self._spoolman: SpoolmanClient | None = (
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
if _sm_url else None
)
self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id}
self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print
self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman
self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick
self._spoolman_last_sync: float = 0.0
# Theme-Name prüfen (keine Sonderzeichen oder Umlaute)
raw_theme = (getattr(args, "ui_theme", None) or "default").strip()
if not _UI_THEME_NAME_RE.match(raw_theme):
@@ -837,6 +884,139 @@ class KobraXBridge:
client.callbacks["light/report"] = self._on_light
client.callbacks["skip/report"] = self._on_skip
if self._spoolman:
threading.Thread(
target=lambda: log.info(
f"Spoolman: {'OK' if self._spoolman.health_check() else 'unreachable'} "
f"at {self._spoolman.server_url}"
),
daemon=True, name="spoolman-health",
).start()
# ── Spoolman helpers ──────────────────────────────────────────────────────
def _spoolman_filament_mm(self) -> float:
"""Total filament_used_mm for the current print file from the GCode DB."""
filename = self._state.get("filename", "")
if not filename:
return 0.0
try:
gf = self._store.get_file_by_name(filename)
return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0
except Exception:
return 0.0
def _spoolman_attribute_tick(self, activity_map: dict) -> None:
"""Attribute the supplies_usage delta since last tick to the active slot.
Skips attribution during loading/unloading transitions (tool changes +
purges) to avoid charging the wrong spool for purge material."""
if not self._spoolman or not self._spoolman_slot_spools:
return
if self._state.get("print_state") != "printing":
return
current = self._state.get("supplies_usage", 0)
delta = current - self._spoolman_last_usage
self._spoolman_last_usage = current
if delta <= 0:
return
loaded = self._ams_loaded_slot
if loaded < 0:
return
if activity_map.get(loaded):
return
self._spoolman_slot_usage[loaded] = self._spoolman_slot_usage.get(loaded, 0.0) + delta
def _spoolman_unreported(self) -> dict[int, float]:
"""Return {slot_idx: mm} of usage not yet reported to Spoolman.
Falls back to equal split of total supplies_usage when per-slot
attribution data is absent (e.g. single-extruder with no AMS)."""
total_used = self._state.get("supplies_usage", 0)
if self._spoolman_slot_usage:
return {
slot: self._spoolman_slot_usage.get(slot, 0.0)
- self._spoolman_slot_reported.get(slot, 0.0)
for slot in self._spoolman_slot_spools
}
n = len(self._spoolman_slot_spools)
already = sum(self._spoolman_slot_reported.values())
per = (total_used - already) / n if n else 0.0
return {slot: per for slot in self._spoolman_slot_spools}
def _spoolman_report(self, unreported: dict[int, float], min_mm: float = 0.1) -> None:
"""Fire-and-forget report of unreported mm to each mapped spool."""
sm = self._spoolman
for slot_idx, mm in unreported.items():
if mm < min_mm:
continue
spool_id = self._spoolman_slot_spools.get(slot_idx)
if not spool_id:
continue
self._spoolman_slot_reported[slot_idx] = (
self._spoolman_slot_reported.get(slot_idx, 0.0) + mm
)
def _send(sid=spool_id, length=mm):
try:
sm.use_filament(sid, length)
log.info(f"Spoolman: {length:.1f} mm → spool {sid}")
except Exception as e:
log.warning(f"Spoolman: report failed (spool {sid}): {e}")
threading.Thread(target=_send, daemon=True, name="spoolman-report").start()
def _spoolman_notify_end(self):
"""Report remaining filament on print end."""
if not self._spoolman or not self._spoolman_slot_spools:
return
self._spoolman_report(self._spoolman_unreported())
def _spoolman_sync_midprint(self):
"""Report incremental filament usage during a print (sync_rate interval)."""
if not self._spoolman or not self._spoolman_slot_spools:
return
self._spoolman_report(self._spoolman_unreported(), min_mm=10.0)
# ── Spoolman API handlers ─────────────────────────────────────────────────
async def handle_kx_spoolman_status(self, request):
"""GET /kx/spoolman/status"""
return self._json_cors({
"configured": bool(self._spoolman),
"server": self._spoolman.server_url if self._spoolman else "",
"sync_rate": self._spoolman.sync_rate if self._spoolman else 0,
"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()},
})
async def handle_kx_spoolman_spools(self, request):
"""GET /kx/spoolman/spools — proxied from Spoolman."""
if not self._spoolman:
return self._json_cors({"error": "Spoolman not configured"}, status=503)
try:
spools = await asyncio.get_event_loop().run_in_executor(
None, self._spoolman.list_spools
)
return self._json_cors({"spools": spools})
except Exception as e:
log.warning(f"Spoolman: list_spools failed: {e}")
return self._json_cors({"error": str(e)}, status=502)
async def handle_kx_spoolman_set_active(self, request):
"""POST /kx/spoolman/active-spool
Body: {"slot_map": {"0": 42, "2": 17}} — AMS slot index → Spoolman spool ID."""
try:
data = await request.json()
except Exception:
return self._json_cors({"error": "invalid JSON"}, status=400)
slot_map = data.get("slot_map") or {}
self._spoolman_slot_spools = {
int(k): int(v) for k, v in slot_map.items()
if str(v).isdigit() and int(v) > 0
}
self._spoolman_slot_usage = {}
self._spoolman_slot_reported = {}
self._spoolman_last_usage = 0.0
return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}})
def _default_ace_dry_presets(self) -> dict[str, dict]:
return {
"pla": {"temp": 45, "duration_sec": 4 * 3600},
@@ -951,15 +1131,21 @@ class KobraXBridge:
printer_id=self._printer_id,
)
log.info(f"Job started: {self._current_job_id} for {filename}")
self._spoolman_slot_usage = {}
self._spoolman_slot_reported = {}
self._spoolman_last_usage = 0.0
self._spoolman_last_sync = 0.0
# Job-History: Druckende erkennen
if kobra_state in ("finished",) and self._current_job_id:
self._store.finish_job(self._current_job_id, status="completed")
log.info(f"Job abgeschlossen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
elif kobra_state in ("stoped", "canceled") and self._current_job_id:
self._store.finish_job(self._current_job_id, status="cancelled")
log.info(f"Job abgebrochen: {self._current_job_id}")
self._spoolman_notify_end()
self._current_job_id = ""
# Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der
@@ -976,6 +1162,7 @@ class KobraXBridge:
self._state["slicer_time"] = 0
self._state["layer_height"] = 0.0
self._state["first_layer_height"] = 0.0
self._state["supplies_usage"] = 0
self._thumbnail_b64 = ""
self._state["filename"] = d.get("filename", self._state["filename"])
if "progress" in d:
@@ -990,6 +1177,8 @@ class KobraXBridge:
self._state["total_layers"] = d["total_layers"]
if "taskid" in d:
self._state["taskid"] = str(d["taskid"])
if "supplies_usage" in d:
self._state["supplies_usage"] = int(d["supplies_usage"])
settings = d.get("settings") or {}
if "print_speed_mode" in settings:
self._state["print_speed_mode"] = int(settings["print_speed_mode"])
@@ -1077,6 +1266,25 @@ class KobraXBridge:
if (not skipped and self._pending_preprint_skip
and now <= self._pending_preprint_skip_deadline):
return
# Während eines aktiven Drucks sind Skip-Zustände effektiv monoton.
# Manche Firmware-Reports kommen zwischenzeitlich leer/teilweise zurück;
# diese dürfen bereits bestätigte Skip-Objekte nicht aus der UI löschen.
existing_skipped = [str(n) for n in (self._skip_state.get("skipped") or []) if n]
existing_set = set(existing_skipped)
incoming_skipped = [str(n) for n in (skipped or []) if n]
incoming_set = set(incoming_skipped)
active_print = self._state.get("print_state") in ("printing", "paused")
if active_print and existing_set:
if not incoming_set:
skipped = list(existing_skipped)
elif not incoming_set.issuperset(existing_set):
merged = list(existing_skipped)
for n in incoming_skipped:
if n not in existing_set:
merged.append(n)
skipped = merged
# Pending-Lock aufheben sobald Drucker die gewünschten Objekte bestätigt
if self._pending_preprint_skip and set(skipped) >= set(self._pending_preprint_skip):
self._pending_preprint_skip = []
@@ -1123,7 +1331,7 @@ class KobraXBridge:
return False
for i in range(max(1, int(retries))):
try:
if self._state.get("kobra_state") != "printing":
if self._state.get("print_state") not in ("printing", "paused"):
time.sleep(max(0.1, float(delay_s)))
continue
resp = self.client.skip_objects(wanted)
@@ -2453,6 +2661,18 @@ class KobraXBridge:
names = json.loads(f.get("objects_skip_parts") or "[]")
except Exception:
names = []
# Noch keine Objekte im Store (frischer Orca-/Web-Upload): einmal aktiv
# file/fileDetails beim Drucker anfragen. _on_file() füllt den Store nach,
# das Frontend pollt diesen Endpoint und bekommt die Liste beim nächsten
# Versuch (Issue #57 — Skip-Parität auch außerhalb des File-Browsers).
if not names:
fn = f.get("filename") or ""
if fn:
try:
self.client.publish("file", "fileDetails",
{"root": "local", "filename": fn}, timeout=0)
except Exception as e:
log.debug(f"fileDetails-Nachfrage fehlgeschlagen: {e}")
return self._json_cors({
"result": {
"names": names,
@@ -2479,29 +2699,8 @@ class KobraXBridge:
return self._json_cors({"error": str(e)}, status=502)
return self._json_cors({"result": "ok", "names": names})
async def handle_kx_skip_query(self, request):
"""Druck-Objektliste vom Drucker neu abfragen.
POST /kx/skip/query → triggert skip/query_obj, gibt zuletzt bekannten
Stand zurück (skip/report kommt async, Frontend pollt /kx/skip/state).
"""
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: self.client.query_skip_objects())
except Exception as e:
return self._json_cors({"error": str(e)}, status=502)
return self._json_cors({"result": self._skip_state})
async def handle_kx_skip_state(self, request):
"""Aktueller Skip-State.
Kombiniert:
- Gesamt-Objektliste: aus dem GCode-Store, gematcht über den aktuell
laufenden filename (file/report beim Druckstart hat die Liste gefüllt).
skip/query_obj liefert nämlich NUR die bereits geskippten zurück,
nicht die Gesamtliste.
- Geskippt: aus self._skip_state (von skip/report aktualisiert).
"""
def _build_skip_state_result(self) -> dict:
"""Baut den kombinierten Skip-State für UI-Endpunkte."""
filename = self._state.get("filename", "")
all_objects: list[str] = []
svg = ""
@@ -2513,14 +2712,46 @@ class KobraXBridge:
svg = f.get("svg_image") or ""
except Exception as e:
log.warning(f"skip_state lookup failed: {e}")
result = {
return {
"objects": all_objects,
"skipped": list(self._skip_state.get("skipped", [])),
"svg_b64": svg,
"ts": self._skip_state.get("ts", 0),
"filename": filename,
}
return self._json_cors({"result": result})
async def handle_kx_skip_query(self, request):
"""Druck-Objektliste vom Drucker neu abfragen.
POST /kx/skip/query → triggert skip/query_obj, wartet kurz auf den
async skip/report und gibt den zusammengeführten Skip-State zurück.
"""
prev_ts = int(self._skip_state.get("ts", 0) or 0)
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: self.client.query_skip_objects())
except Exception as e:
return self._json_cors({"error": str(e)}, status=502)
deadline = time.time() + 1.5
while time.time() < deadline:
if int(self._skip_state.get("ts", 0) or 0) > prev_ts:
break
await asyncio.sleep(0.1)
return self._json_cors({"result": self._build_skip_state_result()})
async def handle_kx_skip_state(self, request):
"""Aktueller Skip-State.
Kombiniert:
- Gesamt-Objektliste: aus dem GCode-Store, gematcht über den aktuell
laufenden filename (file/report beim Druckstart hat die Liste gefüllt).
skip/query_obj liefert nämlich NUR die bereits geskippten zurück,
nicht die Gesamtliste.
- Geskippt: aus self._skip_state (von skip/report aktualisiert).
"""
return self._json_cors({"result": self._build_skip_state_result()})
async def handle_kx_printers(self, request):
# Aktive Drucker (mit IP) sammeln
@@ -2615,8 +2846,8 @@ class KobraXBridge:
},
}
# Pre-Print-Skip sofort im UI-Status spiegeln
self._skip_state = {"skipped": list(excluded_objects), "ts": int(time.time())}
# UI erst nach echter Drucker-Bestätigung als "geskippt" markieren.
self._skip_state = {"skipped": [], "ts": int(time.time())}
if excluded_objects:
self._pending_preprint_skip = [str(n) for n in excluded_objects if isinstance(n, str) and n]
self._pending_preprint_skip_deadline = time.time() + 12.0
@@ -2925,6 +3156,18 @@ class KobraXBridge:
if not file_data:
return web.json_response({"error": "no file received"}, status=400)
# Nur druckbare Dateien zulassen (Issue #59) — der Kobra X akzeptiert
# ausschließlich .gcode und .bgcode; .3mf-Uploads werden vom Drucker
# nicht verarbeitet und daher abgelehnt (Issue #59, @gangoke).
_allowed_ext = (".gcode", ".bgcode")
_fn_lower = (remote_filename or "").lower()
if not _fn_lower.endswith(_allowed_ext):
log.warning(f"Upload abgelehnt (kein GCode): {remote_filename}")
return web.json_response(
{"error": f"only GCode files allowed ({', '.join(_allowed_ext)})"},
status=400,
)
file_md5 = hashlib.md5(file_data).hexdigest()
file_size = len(file_data)
@@ -3127,7 +3370,8 @@ class KobraXBridge:
ams_box_mapping = self._build_auto_ams_box_mapping()
use_ams = len(ams_box_mapping) > 0
auto_leveling = getattr(self._args, "auto_leveling", 1)
# Dialog-Checkbox (body) hat Vorrang, sonst Setting-Default (wie handle_kx_print).
auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))
url = self._state.get("last_upload_url", "")
filesize = self._state.get("last_upload_size", 0)
md5 = self._state.get("last_upload_md5", "")
@@ -3157,6 +3401,15 @@ class KobraXBridge:
},
}
# UI erst nach echter Drucker-Bestätigung als "geskippt" markieren.
self._skip_state = {"skipped": [], "ts": int(time.time())}
if excluded_objects:
self._pending_preprint_skip = [str(n) for n in excluded_objects if isinstance(n, str) and n]
self._pending_preprint_skip_deadline = time.time() + 12.0
else:
self._pending_preprint_skip = []
self._pending_preprint_skip_deadline = 0.0
log.info(
f"print/start api=1 mode={self._filament_mode} "
f"ams={len(ams_box_mapping)} slots assignments={filament_assignments is not None}"
@@ -3169,6 +3422,9 @@ class KobraXBridge:
if result is None:
return web.json_response({"error": "Keine Antwort vom Drucker"}, status=504)
if excluded_objects:
loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects))
return web.json_response({"result": "ok"})
async def handle_print_pause(self, request):
@@ -3803,6 +4059,8 @@ class KobraXBridge:
"camera_url": s["camera_url"],
"fan_speed": s["fan_speed"],
"print_speed_mode": s["print_speed_mode"],
"auto_leveling": getattr(self._args, "auto_leveling", 1),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"light_on": s["light_on"],
"light_brightness": s["light_brightness"],
@@ -4735,6 +4993,14 @@ class KobraXBridge:
print_r = self.client.publish("print", "query", timeout=3.0)
if print_r:
self._on_print(print_r)
# Spoolman mid-print sync
if (self._spoolman and self._spoolman.sync_rate > 0
and self._spoolman_slot_spools
and self._state.get("print_state") == "printing"):
now = time.time()
if now - self._spoolman_last_sync >= self._spoolman.sync_rate:
self._spoolman_sync_midprint()
self._spoolman_last_sync = now
box = self.client.query_multicolor_box()
if box:
data = box.get("data") or {}
@@ -4751,6 +5017,10 @@ class KobraXBridge:
if global_slots:
self._ams_slots = global_slots
self._ams_loaded_slot = global_loaded
self._spoolman_attribute_tick(activity_map)
else:
# No multiColorBox data — still attribute (no transitions to skip)
self._spoolman_attribute_tick({})
except Exception as e:
log.warning(f"Poll-Fehler: {e}")
# Prüfen ob Drucker wirklich weg ist
@@ -4885,6 +5155,9 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_post("/kx/skip", bridge.handle_kx_skip)
r.add_post("/kx/skip/query", bridge.handle_kx_skip_query)
r.add_get("/kx/skip/state", bridge.handle_kx_skip_state)
r.add_get("/kx/spoolman/status", bridge.handle_kx_spoolman_status)
r.add_get("/kx/spoolman/spools", bridge.handle_kx_spoolman_spools)
r.add_post("/kx/spoolman/active-spool", bridge.handle_kx_spoolman_set_active)
r.add_route("OPTIONS", "/kx/{path:.*}", bridge.handle_kx_options)
# Root + Printer-Routen (Single-Page, JS liest Pathname)
@@ -5050,6 +5323,10 @@ def main():
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int)
parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER,
help="Spoolman URL (e.g. http://192.168.x.x:7912); leave empty to disable")
parser.add_argument("--spoolman-sync-rate", type=int, default=env_loader.SPOOLMAN_SYNC_RATE,
help="Mid-print filament sync interval in seconds (0 = only on print end)")
parser.add_argument("--host", default="0.0.0.0",
help="Bind-Adresse für den Bridge-Server")

View File

@@ -9,9 +9,10 @@ var camOn=false;
var camUserStopped=false; // user stopped camera manually — suppress auto-restart for this print
var _camPollInterval=null; // snapshot-polling interval for Android (no MJPEG support)
var _lastLoadedFile=null; // zuletzt geladene/gedruckte Datei für Progress-Karten-Aktionen (Issue #55)
var _idleCleared=false; // User hat idle-Datei explizit „geleert" → kein Nachladen von s.filename (Issue #57)
var _fdDialogOpen=false; // Dialog ist gerade offen
var _fdAutoOpenedFile=null; // Dateiname für den der Dialog auto-geöffnet wurde
var _fdUserCancelled=false; // User hat den Auto-Open-Dialog abgebrochen
var _fdAutoOpenedFile=sessionStorage.getItem('fdAutoOpenedFile')||null;
var _fdUserCancelled=sessionStorage.getItem('fdUserCancelled')==='1';
var currentStep=1;
var currentPanel='dashboard';
var aceAutoRefillPrefs=(function(){
@@ -43,6 +44,62 @@ var ACE_DRY_PRESETS={
custom_3:{name:'Custom 3',temp:45,duration_sec:4*3600}
};
// Spoolman state
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
var _spoolmanSpools=[];
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
function _loadSpoolmanStatus(){
fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){
_spoolmanStatus=d;
_slotSpoolMap=d.slot_spools||{};
}).catch(function(){});
}
function _buildSpoolmanSection(){
var sec=document.getElementById('fd-spoolman-section');
var rows=document.getElementById('fd-spoolman-rows');
var loading=document.getElementById('fd-spoolman-loading');
if(!sec||!rows)return;
if(!_spoolmanStatus.configured){sec.style.display='none';return;}
sec.style.display='';
rows.innerHTML='';
if(loading)loading.style.display='';
var usedSlots={};
document.querySelectorAll('#fd-slots select').forEach(function(sel){
var idx=parseInt(sel.value);
if(idx>=0){
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
}
});
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
if(loading)loading.style.display='none';
_spoolmanSpools=d.spools||[];
var slotKeys=Object.keys(usedSlots).map(Number).sort(function(a,b){return a-b;});
if(!slotKeys.length){rows.innerHTML='<span style="font-size:11px;color:var(--txt2)"></span>';return;}
rows.innerHTML=slotKeys.map(function(idx){
var slot=usedSlots[idx];
var col=(slot.color_hex||'#888');
var currentSpool=_slotSpoolMap[String(idx)]||'';
var opts='<option value=""></option>'+_spoolmanSpools.map(function(sp){
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor+' ':'';
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
}).join('');
return '<div style="display:flex;align-items:center;gap:8px;font-size:12px">'+
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:'+col+';border:1px solid var(--border);flex-shrink:0"></span>'+
'<span style="color:var(--txt2);min-width:46px">Slot '+(idx+1)+'</span>'+
'<select data-spool-slot="'+idx+'" style="flex:1;padding:3px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+
opts+'</select></div>';
}).join('');
}).catch(function(){if(loading)loading.style.display='none';});
}
function _aceAutoRefillGet(aceId){return !!aceAutoRefillPrefs[String(aceId)];}
function _aceAutoRefillSet(aceId,on){
aceAutoRefillPrefs[String(aceId)]=!!on;
@@ -108,6 +165,7 @@ function _langToggleLabel(lang){
if(lang==='de')return 'Deutsch';
if(lang==='en')return 'English';
if(lang==='fr')return 'Français';
if(lang==='it')return 'Italiano';
if(lang==='zh-cn')return '简体中文';
return 'Espanol';
}
@@ -115,10 +173,10 @@ function _langToggleLabel(lang){
function _mapSupportedLang(lang){
if(!lang)return '';
var l=String(lang).toLowerCase().replace(/_/g,'-').trim();
if(l==='de'||l==='en'||l==='es'||l==='fr'||l==='zh-cn')return l;
if(l==='de'||l==='en'||l==='es'||l==='fr'||l==='it'||l==='zh-cn')return l;
var base=l.split('-')[0];
if(base==='de'||base==='en'||base==='es'||base==='fr')return base;
if(base==='de'||base==='en'||base==='es'||base==='fr'||base==='it')return base;
if(base==='zh'){
if(l.indexOf('cn')>=0||l.indexOf('hans')>=0||l==='zh')return 'zh-cn';
@@ -664,10 +722,18 @@ function applyState(){
if(s.file_ready&&s.print_state==='standby'){
document.getElementById('file-ready-name').textContent=s.file_ready;
// Neue Datei → Abbruch-Sperre aufheben
if(_fdAutoOpenedFile&&_fdAutoOpenedFile!==s.file_ready) _fdUserCancelled=false;
if(shouldAutoOpen&&!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
_fdAutoOpenedFile=s.file_ready;
startReadyFileWithSlots(s.file_ready,true);
if(_fdAutoOpenedFile&&_fdAutoOpenedFile!==s.file_ready){
_fdUserCancelled=false;
sessionStorage.removeItem('fdUserCancelled');
sessionStorage.removeItem('fdAutoOpenedFile');
}
if(shouldAutoOpen){
// Dialog-Modus: Banner niemals anzeigen.
frb.style.display='none';
if(!_fdDialogOpen&&!_fdUserCancelled&&_fdAutoOpenedFile!==s.file_ready){
_fdAutoOpenedFile=s.file_ready;
startReadyFileWithSlots(s.file_ready,true);
}
} else {
frb.style.display='flex';
bannerVisible=true;
@@ -686,8 +752,11 @@ function applyState(){
// Zuletzt geladene Datei merken (Issue #55): solange sie über den State
// sichtbar ist. Beim Druckende/Abbruch leert die Bridge file_ready+filename
// (Issue #29) — die gemerkte Referenz bleibt für die Karten-Aktionen.
// Echte ready-Datei oder laufender Druck hebt einen vorherigen „Clear" auf.
if(s.file_ready||printing) _idleCleared=false;
if(s.file_ready) _lastLoadedFile=s.file_ready;
else if(s.filename) _lastLoadedFile=s.filename;
else if(s.filename && !_idleCleared) _lastLoadedFile=s.filename;
else if(_idleCleared) _lastLoadedFile=null;
// Idle-Aktionen (Drucken/Slots/Leeren) nur wenn nicht gedruckt wird, eine
// Datei bekannt ist und der grüne Banner nicht ohnehin schon dieselbe Aktion
// anbietet.
@@ -864,7 +933,13 @@ function applyState(){
var activity=(slot.activity||'');
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'');
var slotLabel=T.label_slot+' '+(globalIdx+1);
var profile=(window._slotProfileMap||{})[globalIdx];
// Gemapptes Profil nur für belegte Slots verwenden — sonst zeigt ein
// verwaistes Mapping (Slot wurde geleert) ein „Geister"-Profil (Issue #57).
var profile=empty?null:(window._slotProfileMap||{})[globalIdx];
var genericType=(slot.type||slot.material_type||'');
// Material-Label: bei belegtem Slot mit Mapping den konkreten Profilnamen
// (z.B. „eSUN PLA+") statt nur des generischen Typs zeigen (Issue #57 Punkt 4).
var materialLabel=empty?'':((profile&&profile.name)?profile.name:genericType);
var vendorBadge='';
if(!empty && profile && profile.vendor){
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
@@ -873,7 +948,7 @@ function applyState(){
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
+'<div class="slot-circle" style="background:'+col+'"></div>'
+'<div class="slot-material">'+(empty?'':(slot.type||slot.material_type||''))+'</div>'
+'<div class="slot-material" title="'+(empty?'':genericType)+'">'+materialLabel+'</div>'
+vendorBadge
+'<div class="slot-label">'+slotLabel+'</div>'
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
@@ -895,7 +970,7 @@ function applyState(){
if(co)co.style.display=(s.print_state==='printing'&&camOn)?'block':'none';
// auto-start camera during print (unless user explicitly stopped it)
if(s.print_state==='printing'&&!camOn&&s.camera_url&&!camUserStopped){
if(s.print_state==='printing'&&!camOn&&s.camera_url&&!camUserStopped&&s.camera_on_print){
camStart();
}
// reset user-stopped flag when print ends so next print auto-starts again
@@ -1011,6 +1086,11 @@ function onPollIntervalInput(){
}
// ── Filament-Profil-Mapping pro Slot ([filament_profiles]) ──
// Pro Slot ein einzelnes Profil-Dropdown (vendor+name gemeinsam, gekeyt per
// _profileKey). Kein Freitext mehr → das (vendor,name)→id-Matching kann nicht
// mehr durch manuelle Eingabe brechen (Issue #57 Punkt 1). Optionen werden aus
// /kx/filament/profiles geladen, nach Vendor gruppiert, User-Profile zuerst,
// mit demselben Vendor-Sichtbarkeitsfilter wie das Slot-Edit-Dropdown.
function renderFilamentMapping(map){
var el=document.getElementById('filament-mapping-list');
if(!el)return;
@@ -1020,21 +1100,61 @@ function renderFilamentMapping(map){
var idHint=m.id?' <span style="color:var(--txt2);font-size:11px">('+m.id+')</span>':'';
rows+='<div class="modal-field" style="margin-bottom:8px">'
+'<label>Slot '+(i+1)+idHint+'</label>'
+'<div style="display:flex;gap:6px;flex-wrap:wrap">'
+'<input type="text" id="fmap-'+i+'-vendor" placeholder="Vendor (z.B. Polymaker)" value="'+(m.vendor||'')+'" style="flex:1;min-width:120px">'
+'<input type="text" id="fmap-'+i+'-name" placeholder="Name (z.B. PolyTerra PLA)" value="'+(m.name||'')+'" style="flex:1;min-width:140px">'
+'</div></div>';
+'<select id="fmap-'+i+'" data-vendor="'+(m.vendor||'')+'" data-name="'+(m.name||'')+'" style="width:100%"></select>'
+'</div>';
}
el.innerHTML=rows;
// Dropdowns befüllen (async, geteilter Profil-Cache + Vendor-Filter)
for(var j=0;j<4;j++){ _fillMappingDropdown(j); }
}
function _fillMappingDropdown(slot){
var sel=document.getElementById('fmap-'+slot);
if(!sel) return;
var wantKey=_profileKey(sel.dataset.vendor, sel.dataset.name);
_loadOrcaFilaments(function(profiles){
sel.innerHTML='<option value="">'+(tr('slot_edit_profile_default')||'Generic (Standard)')+'</option>';
var userProfs=profiles.filter(function(p){return p.is_user;});
var systemProfs=profiles.filter(function(p){return !p.is_user;});
function _opt(g,p){
var o=document.createElement('option');
o.value=_profileKey(p.vendor,p.name);
o.dataset.vendor=p.vendor; o.dataset.name=p.name; o.dataset.id=p.id||'';
o.textContent=(p.is_user?'★ ':'')+p.name+(p.vendor?' — '+p.vendor:'');
if(o.value===wantKey)o.selected=true;
g.appendChild(o);
}
if(userProfs.length){
var gUser=document.createElement('optgroup');
gUser.label='★ '+(tr('orca_profile_user_label')||'Eigene Profile');
userProfs.forEach(function(p){_opt(gUser,p);});
sel.appendChild(gUser);
}
_loadVisibleVendors(function(vis){
var filtered=systemProfs;
if(vis&&vis.length){
var allow={};vis.forEach(function(v){allow[v]=1;});allow['Generic']=1;
filtered=systemProfs.filter(function(p){return allow[p.vendor];});
}
var byVendor={};
filtered.forEach(function(p){(byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p);});
Object.keys(byVendor).sort().forEach(function(v){
var g=document.createElement('optgroup');g.label=v;
byVendor[v].forEach(function(p){_opt(g,p);});
sel.appendChild(g);
});
});
});
}
function saveFilamentMapping(){
// Nutzt den per-Slot-Endpoint (vendor,name → ID-Lookup im Backend).
// Leere Felder = Mapping entfernen.
// Leere Auswahl ("") = Mapping entfernen.
var chain=Promise.resolve();
for(var i=0;i<4;i++){
(function(slot){
var vendor=((document.getElementById('fmap-'+slot+'-vendor')||{}).value||'').trim();
var name=((document.getElementById('fmap-'+slot+'-name')||{}).value||'').trim();
var sel=document.getElementById('fmap-'+slot);
var opt=sel?sel.options[sel.selectedIndex]:null;
var vendor=(opt&&opt.dataset.vendor)||'';
var name=(opt&&opt.dataset.name)||'';
chain=chain.then(function(){
return fetch(_apiUrl('/kx/filament/slots/'+slot+'/profile'),
{method:'POST',headers:{'Content-Type':'application/json'},
@@ -1193,6 +1313,7 @@ function doProfileImportUpload(files){
var _slotEditIndex=-1;
var _slotEditLoaded=false;
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK'];
function updateSlotEditFeedButton(){
var btn=document.getElementById('btn-slot-edit-feed');
if(!btn)return;
@@ -1329,23 +1450,39 @@ function slotEditFeed(){
}
function startReadyFile(filename){
var fn=filename||S.file_ready;
function _doStartReadyFile(){
var btn=document.getElementById('file-ready-btn');
if(btn){btn.disabled=true;btn.textContent='…';}
post('/printer/print/start',{filename:fn})
.then(function(r){return r.json();})
.then(function(){
document.getElementById('file-ready-banner').style.display='none';
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
})
.catch(function(e){
clog(tr('log_error')+' '+e,'msg-err');
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
});
}
function _gateAndStart(fileObj){
if(fileObj && fileObj.web_unverified && webUploadWarningEnabled()){
maybeGateWebUpload(fileObj, function(){ startReadyFile(fn); });
return;
}
_doStartReadyFile();
}
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){
maybeGateWebUpload(currentFile, function(){ startReadyFile(fn); });
if(currentFile){
_gateAndStart(currentFile);
return;
}
var btn=document.getElementById('file-ready-btn');
if(btn){btn.disabled=true;btn.textContent='…';}
post('/printer/print/start',{filename:fn})
.then(function(r){return r.json();})
.then(function(r){
document.getElementById('file-ready-banner').style.display='none';
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
})
.catch(function(e){
clog(tr('log_error')+' '+e,'msg-err');
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
});
fetch(_apiUrl('/kx/files')).then(function(r){return r.json();}).then(function(d){
storeFiles=d.result||[];
var refreshed=(storeFiles||[]).find(function(f){return f.filename===fn;})||null;
_gateAndStart(refreshed);
}).catch(function(){
_doStartReadyFile();
});
}
function cancelReadyFile(){
post('/api/file_ready/clear',{})
@@ -1361,8 +1498,17 @@ function startIdleFileWithSlots(){
}
function clearIdleFile(){
_lastLoadedFile=null;
_idleCleared=true; // verhindert Nachladen von s.filename im nächsten poll() (Issue #57)
_fdAutoOpenedFile=null; // nächster Upload derselben Datei soll Dialog wieder öffnen
_fdUserCancelled=false;
_fdDialogOpen=false;
sessionStorage.removeItem('fdAutoOpenedFile');
sessionStorage.removeItem('fdUserCancelled');
sessionStorage.removeItem('webVerifyCancelledFileId');
S.file_ready=''; S.filename=''; S.thumbnail=''; // sofort lokal leeren, kein Warten auf nächsten Poll
var ib=document.getElementById('d-idle-btns');if(ib)ib.style.display='none';
var fn=document.getElementById('d-fname');if(fn){fn.textContent='';fn.title='';}
var thumb=document.getElementById('d-thumbnail');if(thumb){thumb.style.display='none';thumb.src='';}
post('/api/file_ready/clear',{}).catch(function(){});
}
function selectMatPreset(m){
@@ -1461,6 +1607,11 @@ function saveSettings(){
btn.disabled=true;btn.textContent='…';
var webUploadWarning=(document.getElementById('s-web-upload-warning')||{}).checked?1:0;
S.web_upload_warning=webUploadWarning;
// Start-Print-Behavior-Wechsel könnte den Auto-Open sonst dauerhaft blockieren
// (alter _fdUserCancelled bei gleicher file_ready) → Dialog-State zurücksetzen (Issue #57).
_fdUserCancelled=false;_fdAutoOpenedFile=null;
sessionStorage.removeItem('fdUserCancelled');sessionStorage.removeItem('fdAutoOpenedFile');
sessionStorage.removeItem('webVerifyCancelledFileId');
post('/api/settings',{
printer_name: document.getElementById('s-printer-name').value,
printer_ip: document.getElementById('s-printer-ip').value,
@@ -2006,6 +2157,14 @@ function uploadGcode(file){
var zone=document.getElementById('store-upload-zone');
var status=document.getElementById('store-upload-status');
var label=document.getElementById('store-upload-label');
// Nur druckbare Dateien zulassen (Issue #59) — Drag&Drop umgeht das
// accept-Attribut, daher hier explizit prüfen.
var _fn=(file.name||'').toLowerCase();
if(!/\.(gcode|gcode\.3mf|3mf|bgcode)$/.test(_fn)){
if(status){ status.textContent=T.store_upload_only_gcode||'Only GCode files allowed'; status.style.display=''; status.className='upload-status-err'; }
clog('Upload abgelehnt (kein GCode): '+file.name,'msg-err');
return;
}
if(status) { status.textContent=T.store_upload_busy; status.style.display=''; status.className='upload-status-busy'; }
if(label) label.style.display='none';
if(zone) zone.style.pointerEvents='none';
@@ -2124,6 +2283,7 @@ var _filamentDialogMode='store'; // 'store' oder 'banner'
var _pendingWebVerifyFileId=null;
var _pendingWebVerifyFilename='';
var _pendingWebVerifyAction=null;
var _pendingWebVerifyAutoOpen=false;
// GCode-Store-Dateiliste. MUSS deklariert sein sonst ReferenceError, wenn
// "Slots wählen" im Banner geklickt wird, bevor der Browser-Tab je geladen
// wurde (Issue #29 / Theme-Auslagerung PR #27).
@@ -2191,7 +2351,8 @@ function clearWebUploadWarningFlag(fileId, onDone){
});
}
function maybeGateWebUpload(fileObj, onContinue){
function maybeGateWebUpload(fileObj, onContinue, opts){
opts=opts||{};
if(!fileObj || !fileObj.web_unverified){
if(onContinue) onContinue();
return;
@@ -2200,15 +2361,20 @@ function maybeGateWebUpload(fileObj, onContinue){
if(onContinue) onContinue();
return;
}
var cancelledId=sessionStorage.getItem('webVerifyCancelledFileId')||'';
if(opts.autoOpen && cancelledId && cancelledId===String(fileObj.id||'')){
return;
}
openWebVerifyDialog(fileObj.id, fileObj.filename, function(){
clearWebUploadWarningFlag(fileObj.id, onContinue);
});
}, !!opts.autoOpen);
}
function openWebVerifyDialog(fileId, filename, onConfirm){
function openWebVerifyDialog(fileId, filename, onConfirm, autoOpen){
_pendingWebVerifyFileId=fileId;
_pendingWebVerifyFilename=filename;
_pendingWebVerifyAction=onConfirm||null;
_pendingWebVerifyAutoOpen=!!autoOpen;
var status=document.getElementById('store-web-verify-status');
if(status){status.textContent='';}
openStoreWebVerifyDialog();
@@ -2222,9 +2388,13 @@ function openStoreWebVerifyDialog(){
function closeStoreWebVerifyDialog(){
var modal=document.getElementById('store-web-verify-dialog');
if(modal){modal.classList.remove('open');}
if(_pendingWebVerifyAutoOpen && _pendingWebVerifyFileId){
sessionStorage.setItem('webVerifyCancelledFileId', String(_pendingWebVerifyFileId));
}
_pendingWebVerifyFileId=null;
_pendingWebVerifyFilename='';
_pendingWebVerifyAction=null;
_pendingWebVerifyAutoOpen=false;
}
function confirmStoreWebVerify(){
@@ -2244,9 +2414,11 @@ function confirmStoreWebVerify(){
.then(function(){
var fileObj=(storeFiles||[]).find(function(f){return f.id===fileId;});
if(fileObj){fileObj.web_unverified=false;}
sessionStorage.removeItem('webVerifyCancelledFileId');
_pendingWebVerifyFileId=null;
_pendingWebVerifyFilename='';
_pendingWebVerifyAction=null;
_pendingWebVerifyAutoOpen=false;
closeStoreWebVerifyDialog();
loadStore();
if(typeof action==='function') action();
@@ -2262,7 +2434,7 @@ function startReadyFileWithSlots(filename,_autoOpen){
var fn=filename||S.file_ready;
var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;});
if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){
maybeGateWebUpload(currentFile, function(){ startReadyFileWithSlots(fn); });
maybeGateWebUpload(currentFile, function(){ startReadyFileWithSlots(fn,_autoOpen); }, {autoOpen:!!_autoOpen});
return;
}
_filamentDialogMode='banner';
@@ -2283,23 +2455,31 @@ function startReadyFileWithSlots(filename,_autoOpen){
});
}
function _proceedWithFileObj(fileObj){
if(fileObj && fileObj.web_unverified && webUploadWarningEnabled()){
// Verify-Gate war beim ersten Lookup noch nicht aktiv (storeFiles leer) — jetzt prüfen.
if(_autoOpen){_fdDialogOpen=false;}
maybeGateWebUpload(fileObj, function(){ startReadyFileWithSlots(fn,_autoOpen); }, {autoOpen:!!_autoOpen});
return;
}
if(fileObj){
_storeFileId=fileObj.id;
_setGcodeFilamentsFromFileObj(fileObj);
}
openWithSlots();
}
var fileObj=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;});
if(fileObj){
_storeFileId=fileObj.id;
_setGcodeFilamentsFromFileObj(fileObj);
openWithSlots();
_proceedWithFileObj(fileObj);
return;
}
// Fallback: refresh file list, then resolve current file by filename.
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
storeFiles=d.result||[];
var refreshed=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;});
if(refreshed){
_storeFileId=refreshed.id;
_setGcodeFilamentsFromFileObj(refreshed);
}
openWithSlots();
var refreshed=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;})||null;
_proceedWithFileObj(refreshed);
}).catch(function(){
openWithSlots();
});
@@ -2324,6 +2504,17 @@ function _normalizeMaterialKey(material){
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
// Orca often uses PLA for PLA+, while AMS may report PLA+.
if(key==='PLA+'||key==='PLAPLUS') return 'PLA';
// Handle modifier+base patterns in either order: "Matte PLA", "Silk PETG",
// "PLA Silk", "PLA Matte". OrcaSlicer always writes the base type in GCode
// (filament_type = PLA), but users label slots with the full product-style name.
var trimmed=(material||'').trim();
if(trimmed.indexOf(' ')>=0){
var words=trimmed.toUpperCase().split(/\s+/);
for(var i=0;i<words.length;i++){
var w=words[i].replace(/[^A-Z0-9+]/g,'');
if(_BASE_MATERIAL_TYPES.indexOf(w)>=0) return w;
}
}
return key;
}
function _materialsCompatible(a,b){
@@ -2359,6 +2550,7 @@ function openFilamentDialog(slots){
_amsSlots=slots
.filter(function(s){return s.status==='loaded';})
.sort(function(a,b){return (a.slot_index||0)-(b.slot_index||0);});
_loadSpoolmanStatus();
var dlg=document.getElementById('filament-dialog');
var title=document.getElementById('fd-title');
var body=document.getElementById('fd-slots');
@@ -2379,19 +2571,30 @@ function openFilamentDialog(slots){
if(objBody)objBody.style.display='none'; // immer eingeklappt starten
if(objArrow)objArrow.style.transform='';
if(_storeFileId){
fetch(_apiUrl('/kx/files/'+encodeURIComponent(_storeFileId)+'/objects'))
.then(function(r){return r.json()})
.then(function(d){
var names=(d.result&&d.result.names)||[];
_printObjectsSvg=(d.result&&d.result.svg_b64)||'';
if(names.length>=2){
_printObjects=names.map(function(n){return {name:n,skip:false};});
renderObjectChecklist(); renderObjectSvg();
var cnt=document.getElementById('fd-objects-count');
if(cnt)cnt.textContent='('+names.length+')';
if(objSection)objSection.style.display='block';
}
}).catch(function(){});
// Bei frischem Orca-/Web-Upload liefert der Drucker die Objektliste
// (objects_skip_parts) erst per fileDetails nach → ist im Store kurz leer.
// Daher mehrfach nachfragen, bis Objekte da sind (Issue #57 Skip-Parität).
var _objFid=_storeFileId;
var _objTries=0;
(function _loadObjects(){
if(_objFid!==_storeFileId) return; // Dialog wechselte Datei → abbrechen
fetch(_apiUrl('/kx/files/'+encodeURIComponent(_objFid)+'/objects'))
.then(function(r){return r.json()})
.then(function(d){
var names=(d.result&&d.result.names)||[];
var svg=(d.result&&d.result.svg_b64)||'';
if(names.length>=2){
_printObjectsSvg=svg;
_printObjects=names.map(function(n){return {name:n,skip:false};});
renderObjectChecklist(); renderObjectSvg();
var cnt=document.getElementById('fd-objects-count');
if(cnt)cnt.textContent='('+names.length+')';
if(objSection)objSection.style.display='block';
} else if(_objTries++ < 6){
setTimeout(_loadObjects, 1000); // bis ~6s auf fileDetails warten
}
}).catch(function(){});
})();
}
// GCode-Kanäle: bevorzugt aus _gcodeFilaments, sonst aus belegten AMS-Slots ableiten
@@ -2477,13 +2680,19 @@ function openFilamentDialog(slots){
}).join('');
}
if(dlg)dlg.classList.add('open');
// Spoolman-Section nach Slot-Render aufbauen (braucht die selects im DOM)
setTimeout(_buildSpoolmanSection, 0);
}
function closeFilamentDialog(){
var dlg=document.getElementById('filament-dialog');
if(dlg)dlg.classList.remove('open');
_fdDialogOpen=false;
if(_fdAutoOpenedFile) _fdUserCancelled=true;
if(_fdAutoOpenedFile){
_fdUserCancelled=true;
sessionStorage.setItem('fdUserCancelled','1');
sessionStorage.setItem('fdAutoOpenedFile',_fdAutoOpenedFile);
}
}
function confirmFilamentPrint(){
@@ -2525,21 +2734,54 @@ function confirmFilamentPrint(){
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
var fdAlEl=document.getElementById('fd-auto-leveling');
var fdAutoLeveling=fdAlEl?( fdAlEl.checked?1:0):(S.auto_leveling===undefined?1:S.auto_leveling?1:0);
// Spoolman: Slot→Spool-Mapping aus Dialog sammeln und senden
if(_spoolmanStatus.configured){
var slotMap={};
document.querySelectorAll('[data-spool-slot]').forEach(function(sel){
var idx=sel.dataset.spoolSlot;
var val=parseInt(sel.value);
if(val>0)slotMap[idx]=val;
});
_slotSpoolMap=slotMap;
post('/kx/spoolman/active-spool',{slot_map:slotMap}).catch(function(){});
}
closeFilamentDialog();
if(_filamentDialogMode==='banner'){
// Banner-Modus: normaler print/start mit Slot-Override
// Banner-Modus: /kx/print bevorzugen wenn _storeFileId bekannt (gleicher Pfad wie File-Browser).
var btn=document.getElementById('file-ready-btn');
if(btn){btn.disabled=true;btn.textContent='…';}
post('/printer/print/start',{filename:S.file_ready,filament_assignments:assignments,excluded_objects:excludedObjects,auto_leveling:fdAutoLeveling})
.then(function(r){return r.json();})
.then(function(){
document.getElementById('file-ready-banner').style.display='none';
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
})
.catch(function(e){
clog(tr('log_error')+' '+e,'msg-err');
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
var startPromise;
if(_storeFileId){
startPromise=fetch(_apiUrl('/kx/print'),{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
file_id:_storeFileId,
filament_assignments:assignments,
excluded_objects:excludedObjects,
auto_leveling:fdAutoLeveling
})
});
}else{
startPromise=post('/printer/print/start',{
filename:S.file_ready||_storeFilename,
filament_assignments:assignments,
excluded_objects:excludedObjects,
auto_leveling:fdAutoLeveling
});
}
startPromise.then(function(r){
if(!r.ok){return r.text().then(function(t){throw new Error(t||('HTTP '+r.status));});}
return r.json();
}).then(function(d){
if(d&&d.error) throw new Error(d.error);
if(d&&d.result&&d.result!=='ok') throw new Error(String(d.result));
document.getElementById('file-ready-banner').style.display='none';
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
}).catch(function(e){
clog(tr('log_error')+' '+e,'msg-err');
if(btn){btn.disabled=false;setText('file-ready-btn',T.file_ready_btn);}
});
} else {
// Store-Modus: POST /kx/print
fetch(_apiUrl('/kx/print'),{
@@ -2610,35 +2852,45 @@ function renderObjectSvg(){
// ── Mid-Print Skip ──
var _skipObjects=[]; // [{name, skipped, willSkip}]
var _skipSvg='';
var _skipPollTimer=null;
function _applySkipDialogState(s){
s=s||{};
_skipSvg=s.svg_b64||'';
var skipped=s.skipped||[];
// Pending-Auswahl (willSkip) beim Refresh erhalten.
var prevWillSkip={};
(_skipObjects||[]).forEach(function(o){if(o&&o.name)prevWillSkip[o.name]=!!o.willSkip;});
_skipObjects=(s.objects||[]).map(function(n){
var isSkipped=(skipped.indexOf(n)>=0);
return {name:n, skipped:isSkipped, willSkip:isSkipped?false:!!prevWillSkip[n]};
});
renderSkipList(); renderSkipSvg();
}
function openSkipDialog(){
document.getElementById('skip-status').textContent='';
document.getElementById('skip-confirm').disabled=false;
_refreshSkipDialog();
if(_skipPollTimer)clearInterval(_skipPollTimer);
_skipPollTimer=setInterval(function(){
var dlg=document.getElementById('skip-dialog');
if(!(dlg&&dlg.classList.contains('open')))return;
_refreshSkipDialog();
},2000);
document.getElementById('skip-dialog').classList.add('open');
}
function _refreshSkipDialog(){
// Erst aktueller State (mit DB-Objects + svg), dann query_obj für frischen skipped
fetch(_apiUrl('/kx/skip/state')).then(function(r){return r.json()}).then(function(d){
var s=d.result||{};
_skipSvg=s.svg_b64||'';
_skipObjects=(s.objects||[]).map(function(n){
return {name:n, skipped:(s.skipped||[]).indexOf(n)>=0, willSkip:false};
// query-Endpoint wartet intern auf frischen skip/report (bis 1.5s).
fetch(_apiUrl('/kx/skip/query'),{method:'POST'})
.then(function(r){return r.json();})
.then(function(d){_applySkipDialogState(d.result||{});})
.catch(function(){
fetch(_apiUrl('/kx/skip/state')).then(function(r){return r.json();}).then(function(d){
_applySkipDialogState(d.result||{});
}).catch(function(){});
});
renderSkipList(); renderSkipSvg();
});
// Frisch nachfragen (skipped-Liste aktualisieren)
fetch(_apiUrl('/kx/skip/query'),{method:'POST'}).then(function(r){return r.json()}).then(function(){
setTimeout(function(){
fetch(_apiUrl('/kx/skip/state')).then(function(r){return r.json()}).then(function(d){
var s=d.result||{};
var skipped=s.skipped||[];
_skipObjects.forEach(function(o){ o.skipped=skipped.indexOf(o.name)>=0; if(o.skipped)o.willSkip=false; });
renderSkipList(); renderSkipSvg();
});
}, 500);
}).catch(function(){});
}
function closeSkipDialog(){
if(_skipPollTimer){clearInterval(_skipPollTimer);_skipPollTimer=null;}
document.getElementById('skip-dialog').classList.remove('open');
}
function _shortLabel(name){

View File

@@ -39,6 +39,7 @@
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="zh-cn">中文(简体)</option>
</select>
</div>
@@ -521,6 +522,7 @@
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="zh-cn">中文(简体)</option>
</select>
</div>
@@ -644,6 +646,13 @@
<label for="fd-auto-leveling" style="margin:0;cursor:pointer;font-size:13px" id="fd-lbl-auto-leveling">Auto-Leveling</label>
</div>
</div>
<div id="fd-spoolman-section" style="display:none;margin-bottom:16px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:12px;color:var(--txt2);margin-bottom:8px;display:flex;align-items:center;gap:6px">
<span id="fd-spoolman-lbl">🧵 Spoolman</span>
<span id="fd-spoolman-loading" style="display:none;font-size:10px"></span>
</p>
<div id="fd-spoolman-rows" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button id="fd-cancel" onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="fd-print" onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>

View File

@@ -252,6 +252,7 @@
"store_upload_busy": "⏳ Hochladen…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Nur GCode-Dateien erlaubt (.gcode, .3mf, .bgcode)",
"sf_all": "Alle",
"sf_ok": "✓ Erfolgreich",
"sf_err": "✗ Fehler",

View File

@@ -280,6 +280,7 @@
"store_upload_busy": "⏳ Uploading…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Only GCode files allowed (.gcode, .3mf, .bgcode)",
"sf_all": "All",
"sf_ok": "✓ Completed",
"sf_err": "✗ Failed",

View File

@@ -252,6 +252,7 @@
"store_upload_busy": "⏳ Subiendo…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Solo se permiten archivos GCode (.gcode, .3mf, .bgcode)",
"sf_all": "Todos",
"sf_ok": "✓ Completado",
"sf_err": "✗ Fallido",

View File

@@ -252,6 +252,7 @@
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Seuls les fichiers GCode sont autorisés (.gcode, .3mf, .bgcode)",
"sf_all": "Tout",
"sf_ok": "✓ Terminés",
"sf_err": "✗ Échoués",

291
web/translations/it.json Normal file
View File

@@ -0,0 +1,291 @@
{
"header_status_standby": "Pronto",
"header_status_printing": "In stampa",
"header_status_complete": "Completato",
"header_status_error": "Errore",
"kobra_free": "Pronto",
"kobra_busy": "Occupato",
"kobra_printing": "In stampa",
"kobra_preheating": "Preriscaldamento",
"kobra_auto_leveling": "Livellamento automatico",
"kobra_checking": "Verifica",
"kobra_updated": "Aggiornamento",
"kobra_init": "Inizializzazione",
"kobra_pausing": "Pausa in corso...",
"kobra_paused": "In pausa",
"kobra_resuming": "Ripresa...",
"kobra_resumed": "Ripreso",
"kobra_stopping": "Arresto...",
"kobra_stoped": "Arrestato",
"kobra_finished": "Finito",
"kobra_failed": "Errore",
"kobra_canceled": "Annullato",
"kobra_offline": "Offline",
"nav_dashboard": "Dashboard",
"nav_print": "Stampa",
"nav_temps": "Temperature",
"nav_motion": "Movimento",
"nav_ams": "AMS",
"nav_extras": "Luce / Ventola",
"nav_console": "Console",
"card_progress": "Avanzamento",
"card_temps": "Temperature",
"card_light_fan": "Ventola",
"card_speed": "Velocità di stampa",
"card_cam": "Camera",
"lbl_elapsed": "Trascorso:",
"lbl_remaining": "Rimanente:",
"lbl_slicer_time": "Stima slicer:",
"lbl_layers": "Layer",
"lbl_zpos": "Z (mm)",
"speed_silent": "🐢 Silenzioso",
"speed_normal": "⚡ Normale",
"speed_sport": "🚀 Sport",
"lbl_light": "💡 Luce",
"lbl_feed": "Carica",
"lbl_unload": "Rimuovi",
"card_ace_dry": "Essiccazione ACE",
"ace_dry_dryer": "Essiccatore",
"ace_dry_status_off": "Stato: Spento",
"ace_dry_status_on": "Stato: Attivo",
"ace_dry_status_remaining": "Rimanente",
"ace_dry_humidity": "Umidità",
"ace_dry_current_temp": "Temperatura",
"ace_dry_chart": "Cronologia (Temp/Umidità)",
"ace_dry_temp": "Temperatura (°C)",
"ace_dry_duration": "Durata (min)",
"ace_dry_start": "▶ Avvia",
"ace_dry_stop": "■ Ferma",
"ace_dry_auto_refill": "Ricarica automatica",
"ace_dry_enable": "Abilita essiccazione",
"ace_dry_temp_line": "Temperatura di essiccazione",
"ace_dry_time_line": "Tempo di essiccazione",
"ace_dry_ui_pending": "(Solo interfaccia, backend a seguire)",
"ace_dry_dialog_title": "Impostazioni Temp/Tempo essiccatore",
"ace_dry_dialog_temp": "Temperatura (30-80°C)",
"ace_dry_dialog_time": "Tempo rim. (h:m:s)",
"ace_dry_dialog_confirm": "Conferma",
"ace_dry_dialog_cancel": "Annulla",
"ace_dry_dialog_save_restart": "Salva e riavvia",
"ace_dry_dialog_custom_name": "Nome personalizzato",
"ace_dry_dialog_reset_default": "Ripristina predefiniti",
"ace_dry_preset_pla": "PLA",
"ace_dry_preset_pla_plus": "PLA+",
"ace_dry_preset_petg": "PETG",
"ace_dry_preset_tpu": "TPU",
"ace_dry_preset_abs_asa": "ABS / ASA",
"ace_dry_preset_pa_pc": "PA / PC",
"ace_dry_preset_custom": "Personalizzato",
"cam_placeholder": "📷 Camera non avviata",
"cam_stream_unavailable": "Flusso video non disponibile",
"btn_cam_start": "▶ Camera",
"btn_cam_stop": "◼ Camera",
"btn_pause": "⏸ Pausa",
"btn_resume": "▶ Riprendi",
"btn_cancel": "✕ Stop",
"label_nozzle": "Ugello",
"label_bed": "Piatto",
"label_fan": "🌀 Ventola",
"label_light": "💡 Luce",
"label_on_off": "On / Off",
"label_speed": "Velocità",
"panel_print_title": "Controllo stampa",
"panel_print_btn_pause": "⏸ Pausa",
"panel_print_btn_resume": "▶ Riprendi",
"panel_print_btn_cancel": "✕ Annulla",
"panel_print_temps_live": "Temperature (In tempo reale)",
"label_set": "Imposta",
"label_off": "Off",
"panel_temps_nozzle": "Ugello",
"panel_temps_bed": "Piatto riscaldato",
"panel_temps_chart": "Cronologia (ultime 60 letture)",
"label_target_c": "Target:",
"panel_motion_xy": "Assi XY",
"panel_motion_z": "Asse Z",
"label_step": "Ampiezza passo:",
"btn_home_z": "Home Z",
"btn_home_xy": "Home XY",
"btn_home_all": "Home generale",
"btn_disable_motors": "Spegni motori",
"panel_ams_title": "Filamento",
"card_ams": "Filamento",
"ams_no_data": "Nessun dato ricevuto dall' AMS",
"label_slot": "Slot",
"ams_empty": "Vuoto",
"panel_extras_light": "Luce",
"panel_extras_fan": "Ventola",
"panel_extras_camera": "Camera",
"btn_cam_start2": "▶ Avvia",
"btn_cam_stop2": "◼ Ferma",
"panel_console_title": "Registro eventi",
"log_light_on": "Luce accesa",
"log_light_off": "Luce spenta",
"log_fan": "Ventola →",
"log_nozzle": "Ugello →",
"log_bed": "Piatto →",
"log_axis": "Asse",
"log_home": "Home",
"log_home_all": "Home generale",
"log_cam_start": "Camera avviata:",
"log_cam_stop": "Camera arrestata",
"log_poll_error": "Errore di sincronizzazione:",
"log_error": "Errore:",
"confirm_cancel": "Annullare davvero la stampa?",
"settings_title": "Impostazioni",
"settings_connection": "Connessione",
"settings_print": "Impostazioni di stampa",
"settings_poll": "Intervallo di sincronizzazione (secondi)",
"nav_settings": "Impostazioni",
"settings_cat_display": "Aspetto",
"settings_cat_filament": "Filamento",
"settings_cat_language": "Lingua",
"settings_cat_theme": "Alterna chiaro / scuro",
"settings_filament_mapping": "Mappatura profilo filamento (per slot)",
"settings_filament_mapping_save": "Salva mappatura",
"settings_visible_vendors": "Produttori visibili (menu del profilo)",
"settings_visible_vendors_hint": "Solo questi produttori appariranno nel menu del profilo dello slot. Se non selezioni nulla = mostra tutti. I profili \"Generici\" e i tuoi personali sono sempre visibili.",
"settings_visible_vendors_save": "Salva selezione",
"progress_action_print": "Stampa",
"progress_action_slots": "Mappa slot",
"progress_action_clear": "Cancella",
"settings_version": "Versione",
"settings_save": "Salva e riavvia",
"settings_printer_name": "Nome stampante",
"settings_printer_ip": "IP stampante",
"settings_mqtt_port": "Porta MQTT",
"settings_username": "Nome utente MQTT",
"settings_password": "Password MQTT",
"settings_device_id": "ID dispositivo",
"settings_mode_id": "ID modalità",
"hint_ip_no_port": "Solo indirizzo IP, senza porta (es. 192.168.1.102)",
"settings_default_slot": "Slot predefinito (colore singolo)",
"settings_slot_auto": "Auto (tutti gli slot caricati)",
"settings_auto_leveling": "Livellamento automatico predefinito",
"fd_options_title": "Opzioni di stampa",
"print_auto_leveling": "Livellamento automatico",
"settings_file_ready_mode": "Comportamento all'avvio stampa",
"settings_file_ready_banner": "Barra di stampa",
"settings_file_ready_dialog": "Finestra di dialogo di stampa",
"settings_camera_on_print": "Attiva la camera all'avvio della stampa",
"settings_web_upload_warning": "Mostra un avviso quando si stampano caricamenti web",
"update_check": "Controlla aggiornamenti",
"update_checking": "Verifica in corso...",
"update_available": "disponibile",
"update_none": "Già aggiornato",
"update_apply": "Installa ora",
"update_applying": "Download in corso...",
"update_restarting": "Riavvio in corso...",
"update_error": "Errore",
"btn_connect": "⚡ Connetti",
"btn_disconnect": "✕ Disconnetti",
"lbl_conn_error": "Errore di connessione:",
"slot_edit_title": "Modifica slot",
"slot_edit_color": "Colore",
"slot_edit_material": "Materiale",
"slot_edit_load": "⬇ Carica",
"slot_edit_unload": "⬆ Rimuovi",
"slot_edit_save": "💾 Salva",
"slot_edit_custom": "es. PLA, PETG, ABS…",
"slot_edit_ok": "Slot AMS",
"slot_edit_profile": "Profilo OrcaSlicer",
"slot_edit_profile_hint": "Inviato durante la sincronizzazione con OrcaSlicer come marchio specifico invece di un semplice \"Generico\"",
"slot_edit_profile_default": "— Generico (predefinito) —",
"orca_profile_section": "Profili OrcaSlicer",
"orca_profile_hint": "Importa i tuoi profili di filamento OrcaSlicer (apri la cartella utente tramite Aiuto → Mostra cartella di configurazione)",
"orca_profile_import_btn": "Importa profili",
"orca_profile_import_link": "★ Importa i tuoi profili…",
"orca_profile_import_title": "Importa i tuoi profili OrcaSlicer",
"orca_profile_help_html": "Carica un file <b>ZIP</b> della tua cartella filamenti di OrcaSlicer o file singoli <b>.json</b>.<br>In OrcaSlicer: <i>Aiuto → Mostra cartella di configurazione → user/&lt;id&gt;/filament/</i>",
"orca_profile_dropmsg": "Trascina qui o fai clic",
"orca_profile_list_label": "Attualmente importati",
"orca_profile_user_label": "Profili personali",
"orca_profile_user_empty": " nessuno ",
"orca_profile_uploading": "Caricamento in corso…",
"orca_profile_done": "Importato",
"orca_profile_skipped": "saltato",
"log_dir_all": "Tutti",
"log_dir_rx": "RX",
"log_dir_tx": "TX",
"log_dir_label": "Dir:",
"log_lvl_label": "Livello:",
"log_lvl_err": "⛔ Errori",
"log_lvl_warn": "⚠ Avvisi",
"log_topic_label": "Argomento:",
"log_topic_ams": "AMS",
"log_topic_print": "Stampa",
"log_topic_info": "Info",
"log_topic_status": "Stato",
"log_download": "⬇ Scarica",
"log_auto": "⬇ Auto",
"log_clear": "✕ Cancella",
"log_filter_placeholder": "Filtra…",
"file_ready_btn": "▶ Avvia stampa",
"file_slots_btn": "🎨 Seleziona slot",
"file_cancel_btn": "✕ Annulla",
"nav_printers": "Stampanti",
"skip_title": "✂ Salta oggetti",
"skip_hint": "Deseleziona gli oggetti che non vuoi più stampare:",
"skip_btn_label": "Oggetti",
"skip_no_objects": "Nessun oggetto in questa stampa.",
"skip_already": "saltato",
"skip_cancel": "Annulla",
"skip_confirm": "Salta",
"skip_select_at_least_one": "Seleziona almeno un oggetto.",
"skip_sending": "Invio in corso …",
"skip_success": "Gli oggetti verranno saltati.",
"fd_objects_hint": "Salta oggetti (opzionale):",
"fd_objects_toggle": "Salta oggetti",
"fd_slots_hint": "Assegna il canale GCode allo slot AMS:",
"fd_cancel": "Annulla",
"fd_print": "▶ Stampa",
"fd_no_slots_msg": "Nessuno slot AMS caricato.{br}Avviare comunque la stampa?",
"fd_slot": "Slot",
"fd_no_matching_material": "Nessun materiale corrispondente",
"fd_used": "USATO",
"add_printer": "Aggiungi stampante",
"apd_lbl_ip": "IP stampante",
"apd_lbl_name": "Nome (opzionale)",
"apd_placeholder_name": "es. Kobra X Soggiorno",
"apd_cancel": "Annulla",
"apd_confirm": "Aggiungi",
"apd_fetching": "Recupero dati dalla stampante…",
"apd_success": "Stampante aggiunta, riavvio del bridge in corso…",
"apd_err_ip": "Inserisci un indirizzo IP",
"printers_remove": "Rimuovi stampante",
"printers_remove_confirm": "Rimuovere la stampante \"{name}\"? Il bridge si riavvierà.",
"printers_active": "● attiva",
"printers_switch": "Cambia →",
"printers_current": "Stampante corrente",
"printers_loading": "Caricamento in corso…",
"printers_none": "Nessuna stampante configurata.",
"printers_empty_hint": "Nessuna stampante ancora configurata.",
"nav_browser": "Browser",
"panel_browser_title": "Browser dei file",
"store_search_placeholder": "🔍 Cerca…",
"store_empty": "Nessun file caricato.",
"store_refresh": "↻ Aggiorna",
"store_print": "▶ Stampa",
"store_download": "⬇ Scarica",
"store_delete_confirm": "Eliminare il file?",
"store_print_confirm": "Stampare il file?",
"store_web_verify_title": "Verifica file",
"store_web_verify_msg": "Verifica che questo file sia stato creato per Anycubic Kobra X.",
"store_web_verify_confirm": "Conferma",
"store_web_verify_abort": "Interrompi",
"store_no_results": "Nessun file trovato.",
"store_never": "mai stampato",
"store_estimate": "Stima",
"store_upload_label_prefix": "Trascina il GCode qui o ",
"store_upload_label_browse": "sfoglia",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ Sono consentiti solo file GCode (.gcode, .3mf, .bgcode)",
"sf_all": "Tutti",
"sf_ok": "✓ Completato",
"sf_err": "✗ Fallito",
"sf_new": "Nuovo",
"ss_date": "↓ Data",
"ss_name": "Nome AZ",
"ss_dur": "⏱ Tempo di stampa"
}

View File

@@ -252,6 +252,7 @@
"store_upload_busy": "⏳ 上传中…",
"store_upload_success": "✓ {file}",
"store_upload_error": "✗ {error}",
"store_upload_only_gcode": "✗ 仅允许 GCode 文件 (.gcode, .3mf, .bgcode)",
"sf_all": "全部",
"sf_ok": "✓ 已完成",
"sf_err": "✗ 失败",