Compare commits
3 Commits
v0.9.24
...
print-work
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
755b82137c | ||
|
|
34b3c1601a | ||
| eea570052f |
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
113
kobrax_client.py
113
kobrax_client.py
@@ -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:
|
||||
|
||||
@@ -1077,6 +1077,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 +1142,10 @@ class KobraXBridge:
|
||||
return False
|
||||
for i in range(max(1, int(retries))):
|
||||
try:
|
||||
if self._state.get("kobra_state") != "printing":
|
||||
# kobra_state ist waehrend Start oft nicht exakt "printing"
|
||||
# (z.B. busy/preheating). Auf print_state pruefen, sonst wird
|
||||
# skip/start nie abgeschickt.
|
||||
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)
|
||||
@@ -2482,26 +2504,29 @@ class KobraXBridge:
|
||||
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).
|
||||
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)
|
||||
return self._json_cors({"result": self._skip_state})
|
||||
|
||||
async def handle_kx_skip_state(self, request):
|
||||
"""Aktueller Skip-State.
|
||||
# skip/report kommt asynchron; kurz warten, damit der Rückgabewert
|
||||
# möglichst den frisch bestätigten Druckerzustand enthält.
|
||||
deadline = time.time() + 1.5
|
||||
while time.time() < deadline:
|
||||
cur_ts = int(self._skip_state.get("ts", 0) or 0)
|
||||
if cur_ts > prev_ts:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
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()})
|
||||
|
||||
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,13 +2538,25 @@ 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,
|
||||
}
|
||||
|
||||
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).
|
||||
"""
|
||||
result = self._build_skip_state_result()
|
||||
return self._json_cors({"result": result})
|
||||
|
||||
async def handle_kx_printers(self, request):
|
||||
@@ -2615,8 +2652,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-Bestaetigung 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 +2962,18 @@ class KobraXBridge:
|
||||
if not file_data:
|
||||
return web.json_response({"error": "no file received"}, status=400)
|
||||
|
||||
# Nur druckbare Dateien zulassen (Issue #59) — verhindert dass z.B. ein
|
||||
# JPG im File-Browser landet. OrcaSlicer-Uploads sind .gcode/.gcode.3mf,
|
||||
# der Kobra X akzeptiert .gcode/.3mf/.bgcode.
|
||||
_allowed_ext = (".gcode", ".gcode.3mf", ".3mf", ".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 +3176,7 @@ 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)
|
||||
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 +3206,15 @@ class KobraXBridge:
|
||||
},
|
||||
}
|
||||
|
||||
# UI erst nach echter Drucker-Bestaetigung 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 +3227,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 +3864,7 @@ 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),
|
||||
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
|
||||
"light_on": s["light_on"],
|
||||
"light_brightness": s["light_brightness"],
|
||||
|
||||
@@ -10,8 +10,8 @@ var camUserStopped=false; // user stopped camera manually — suppress auto-rest
|
||||
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 _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(){
|
||||
@@ -664,10 +664,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: nur Dialog verwenden, den 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;
|
||||
@@ -1329,23 +1337,40 @@ 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(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);}
|
||||
});
|
||||
}
|
||||
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);}
|
||||
});
|
||||
// Fresh page loads may not have storeFiles populated yet.
|
||||
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',{})
|
||||
@@ -2006,6 +2031,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 +2157,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 +2225,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 +2235,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 +2262,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 +2288,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 +2308,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 +2329,31 @@ function startReadyFileWithSlots(filename,_autoOpen){
|
||||
});
|
||||
}
|
||||
|
||||
function _proceedWithFileObj(fileObj){
|
||||
if(fileObj && fileObj.web_unverified && webUploadWarningEnabled()){
|
||||
// Verify gate was bypassed (storeFiles was empty on page load) — re-gate now.
|
||||
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();
|
||||
});
|
||||
@@ -2483,7 +2537,11 @@ 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(){
|
||||
@@ -2527,12 +2585,38 @@ function confirmFilamentPrint(){
|
||||
var fdAutoLeveling=fdAlEl?( fdAlEl.checked?1:0):(S.auto_leveling===undefined?1:S.auto_leveling?1:0);
|
||||
closeFilamentDialog();
|
||||
if(_filamentDialogMode==='banner'){
|
||||
// Banner-Modus: normaler print/start mit Slot-Override
|
||||
// Banner-Modus: identisches Start-Verhalten wie im File-Browser bevorzugen.
|
||||
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(){
|
||||
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);}
|
||||
})
|
||||
@@ -2610,35 +2694,54 @@ 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||[];
|
||||
// Keep local pending selections while live state refreshes in background.
|
||||
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};
|
||||
});
|
||||
renderSkipList(); renderSkipSvg();
|
||||
});
|
||||
// Frisch nachfragen (skipped-Liste aktualisieren)
|
||||
fetch(_apiUrl('/kx/skip/query'),{method:'POST'}).then(function(r){return r.json()}).then(function(){
|
||||
setTimeout(function(){
|
||||
// Query-Endpoint liefert bereits den frischen, zusammengeführten Skip-State.
|
||||
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){
|
||||
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(){});
|
||||
_applySkipDialogState(d.result||{});
|
||||
}).catch(function(){});
|
||||
});
|
||||
}
|
||||
function closeSkipDialog(){
|
||||
if(_skipPollTimer){clearInterval(_skipPollTimer);_skipPollTimer=null;}
|
||||
document.getElementById('skip-dialog').classList.remove('open');
|
||||
}
|
||||
function _shortLabel(name){
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "✗ 失败",
|
||||
|
||||
Reference in New Issue
Block a user