diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index b7f203e..f61fe1a 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -1,5 +1,46 @@ # Changelog +## [0.9.23] – 2026-06-16 + +### Neu +- **Druckdialog nach Upload automatisch öffnen.** Eine neue Einstellung + `print_start_dialog` (Einstellungen → Drucker → „Druckstart-Verhalten") steuert, + was nach einem Upload bei leerlaufendem Drucker passiert: „Print-Dialog" öffnet + den Slot-Zuordnungs-Dialog automatisch, „Print-Leiste" behält das bisherige + Banner. Basiert auf PR #56 von @gangoke. +- **Auto-Leveling-Schalter pro Druck.** Der Druckdialog hat jetzt eine eigene + Auto-Leveling-Checkbox, die den globalen Standard für einen einzelnen Druck + überschreibt. + +### Behoben +- **Objekt-Skip wurde beim Druckstart still ignoriert (PR #56, @gangoke).** Der + Skip-Befehl wurde gesendet, *bevor* der Drucker im `printing`-Status war, und + daher verworfen. Der Skip wird nun in einer Retry-Schleife erneut angewendet, + sobald der Druck bestätigt läuft — mit einer Pending-Sperre, damit die UI den + Skip-Status nicht vorzeitig zurücksetzt. +- **Upload während eines laufenden Drucks überschrieb die Vorschau des laufenden + Auftrags.** Ein neuer Upload während des Drucks ersetzt nicht mehr Thumbnail / + file_ready des Auftrags auf dem Druckbett. + +## [0.9.22] – 2026-06-16 + +### Neu +- **Neu strukturiertes Einstellungs-Panel.** Das Einstellungs-Modal wurde durch + ein dauerhaftes Master-Detail-Panel mit fünf Kategorien ersetzt: Verbindung, + Drucker, Darstellung, Filament und System. Das Poll-Intervall ist nun live + einstellbar. +- **Vendor-Sichtbarkeitsfilter (Issue #41).** Eine neue Checkliste in den + Filament-Einstellungen beschränkt das Slot-Profil-Dropdown auf bestimmte + Hersteller. „Generic" und eigene importierte Profile sind immer sichtbar. +- **Idle-Datei-Aktionen in der Fortschritts-Karte (Issue #55).** Nach einem + Upload bei leerlaufendem Drucker erscheinen drei Schnellaktionen direkt in der + Fortschritts-Karte: ▶ Drucken, ⚙ Slots zuordnen und ✕ Leeren. + +### Behoben +- **Mobileraker-Kompatibilität (Issue #48).** Absturz in `ConfigExtruder.fromJson` + (leeres `configfile.config`), Hänger beim Refresh (Metadata-Endlosschleife) und + fehlende ETA/Restzeit behoben. + ## [0.9.21] – 2026-06-14 ### Behoben diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed96cd..f8b1b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.9.23] – 2026-06-16 + +### New +- **Auto-open print dialog after upload.** A new `print_start_dialog` setting + (Settings → Printer → "Start Print Behavior") controls what happens after a + file is uploaded while the printer is idle: `Print Dialog` opens the + slot-assignment dialog automatically, `Print Bar` keeps the previous banner + behaviour. Based on PR #56 by @gangoke. +- **Per-print auto-leveling toggle.** The print dialog now has its own + auto-leveling checkbox that overrides the global default for a single print. + +### Fixed +- **Object skip was silently ignored at print start (PR #56, @gangoke).** The + skip command was sent *before* the printer entered the `printing` state, so it + was dropped. The skip is now re-applied in a retry loop once the print is + confirmed running, with a pending-lock so the UI doesn't reset the skip state + prematurely. +- **Upload during an active print overwrote the running job's preview.** + Uploading a new file while printing no longer replaces the thumbnail / + file_ready of the job currently on the bed. + ## [0.9.22] – 2026-06-16 ### New diff --git a/VERSION b/VERSION index ea3f0d7..68a5945 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.22 +0.9.23 diff --git a/config_loader.py b/config_loader.py index ff14dd1..b1012e1 100644 --- a/config_loader.py +++ b/config_loader.py @@ -61,6 +61,7 @@ def _load_config_file(path: pathlib.Path): "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), + "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), } for env_key, (section, option) in mapping.items(): @@ -73,6 +74,18 @@ def _load_config_file(path: pathlib.Path): pass + # Backward compatibility: old key FILE_READY_DIALOG → PRINT_START_DIALOG + if "PRINT_START_DIALOG" not in os.environ: + try: + legacy = cfg.get(CONFIG_SECTION_PRINT, "file_ready_dialog") + if legacy: + os.environ["PRINT_START_DIALOG"] = legacy + except (configparser.NoSectionError, configparser.NoOptionError): + pass + if "PRINT_START_DIALOG" not in os.environ and "FILE_READY_DIALOG" in os.environ: + os.environ["PRINT_START_DIALOG"] = os.environ["FILE_READY_DIALOG"] + + def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): """Einmalige Migration: .env → config.ini anlegen.""" env_vals: dict[str, str] = {} @@ -303,3 +316,4 @@ 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"))) diff --git a/env_loader.py b/env_loader.py index 342df7b..5ed9379 100644 --- a/env_loader.py +++ b/env_loader.py @@ -48,3 +48,4 @@ 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")) +PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index cea6819..d9e25e5 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -791,6 +791,7 @@ class KobraXBridge: "print_speed_mode": 2, "connection_error": "", "file_ready": "", + "print_start_dialog": getattr(args, "print_start_dialog", 1), "filament_mode": "toolhead", "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None}, } @@ -814,6 +815,9 @@ class KobraXBridge: # Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10) self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0} + # Pre-Print-Skip: pending until printer enters printing state + self._pending_preprint_skip: list[str] = [] + self._pending_preprint_skip_deadline: float = 0.0 # Theme-Name prüfen (keine Sonderzeichen oder Umlaute) raw_theme = (getattr(args, "ui_theme", None) or "default").strip() @@ -1067,7 +1071,16 @@ class KobraXBridge: """ d = payload.get("data") or {} skipped = d.get("objects_skip_parts") or d.get("skipped") or d.get("skipped_parts") or [] - # Liste immer (auch leer) übernehmen – sonst bleibt sie auf alten Stand + # Während ein Pre-Print-Skip noch pending ist, leere Früh-Reports ignorieren + # damit die UI nicht sofort zurückspringt bevor der Drucker den Skip bestätigt. + now = time.time() + if (not skipped and self._pending_preprint_skip + and now <= self._pending_preprint_skip_deadline): + return + # 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 = [] + self._pending_preprint_skip_deadline = 0.0 self._skip_state = { "skipped": list(skipped), "ts": int(time.time()), @@ -1079,14 +1092,19 @@ class KobraXBridge: d = payload.get("data") or {} details = d.get("file_details") or {} thumb = details.get("thumbnail") or details.get("png_image") or "" - if thumb: + file_name = d.get("filename") or details.get("filename") or self._last_uploaded_file + active_print = self._state.get("print_state") in ("printing", "paused") + current_print_file = self._state.get("filename") or "" + # Uploads während eines laufenden Drucks dürfen die aktive + # Fortschritts-Vorschau nicht überschreiben. + if thumb and (not active_print or (file_name and file_name == current_print_file)): self._thumbnail_b64 = thumb log.info(f"Vorschaubild empfangen: {len(thumb)} Zeichen base64") # Part-Skip: Objekt-Liste + optionales SVG (v0.9.10) objs = details.get("objects_skip_parts") or [] svg = details.get("svg_image") or "" if objs: - filename = d.get("filename") or details.get("filename") or self._last_uploaded_file + filename = file_name if filename: try: self._store.update_file_objects(filename, objs, svg) @@ -1095,6 +1113,33 @@ class KobraXBridge: log.warning(f"update_file_objects fehlgeschlagen: {e}") self._push_status_update() + def _apply_preprint_skip_after_start(self, names: list[str], retries: int = 20, delay_s: float = 0.75): + """Sendet Skip-Befehl erst nachdem Drucker in printing-State gewechselt hat. + + Vorher sendet der Drucker den Befehl ins Leere (kein aktiver Druck). + """ + wanted = [str(n) for n in (names or []) if isinstance(n, str) and n] + if not wanted: + return False + for i in range(max(1, int(retries))): + try: + if self._state.get("kobra_state") != "printing": + time.sleep(max(0.1, float(delay_s))) + continue + resp = self.client.skip_objects(wanted) + if resp is not None: + log.info(f"Pre-Print skip applied ({len(wanted)} objects) on attempt {i+1}/{retries}") + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + return True + except Exception as e: + log.debug(f"Pre-Print skip attempt {i+1}/{retries} failed: {e}") + time.sleep(max(0.1, float(delay_s))) + log.warning(f"Pre-Print skip could not be confirmed after {retries} attempts") + self._pending_preprint_skip = [] + self._pending_preprint_skip_deadline = 0.0 + return False + @staticmethod def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str: """Detect active filament topology mode. @@ -2538,7 +2583,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))) filename = gcode_file["filename"] file_path = gcode_file["path"] @@ -2570,6 +2615,15 @@ class KobraXBridge: }, } + # Pre-Print-Skip sofort im UI-Status spiegeln + self._skip_state = {"skipped": list(excluded_objects), "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"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}") loop = asyncio.get_event_loop() result = await loop.run_in_executor( @@ -2578,6 +2632,9 @@ class KobraXBridge: if result is None: return self._json_cors({"error": "Keine Antwort vom Drucker"}, status=504) + if excluded_objects: + loop.run_in_executor(None, lambda: self._apply_preprint_skip_after_start(excluded_objects)) + # Job in History starten self._current_job_id = self._store.start_job( gcode_file_id=gcode_file["id"], @@ -3759,6 +3816,7 @@ class KobraXBridge: "thumbnail": thumbnail, "connection_error": s["connection_error"], "file_ready": s["file_ready"], + "print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)), "version": self._read_version(), }) @@ -3897,6 +3955,7 @@ class KobraXBridge: "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), + "print_start_dialog": getattr(self._args, "print_start_dialog", 1), "poll_interval": getattr(self._args, "poll_interval", 3), "filament_profiles": {str(k): v for k, v in self._filament_profiles.items()}, "visible_vendors": self._visible_vendors, @@ -3930,6 +3989,7 @@ class KobraXBridge: cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) + cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) if "poll_interval" in data: try: pi = max(1, min(60, int(data["poll_interval"]))) @@ -4102,7 +4162,8 @@ class KobraXBridge: # die alten Werte statt der geänderten config.ini. for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD", "MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING", - "CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "BRIDGE_PRINTER_NAME"): + "CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "PRINT_START_DIALOG", + "FILE_READY_DIALOG", "BRIDGE_PRINTER_NAME"): os.environ.pop(_k, None) in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER") @@ -4987,6 +5048,8 @@ def main(): parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) 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("--host", default="0.0.0.0", help="Bind-Adresse für den Bridge-Server") diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 44e425f..5b2e773 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -9,6 +9,9 @@ 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 _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 currentStep=1; var currentPanel='dashboard'; var aceAutoRefillPrefs=(function(){ @@ -355,8 +358,13 @@ function applyLang(){ setText('lbl-default-slot',T.settings_default_slot); setText('opt-slot-auto',T.settings_slot_auto); setText('lbl-auto-leveling',T.settings_auto_leveling); + setText('lbl-file-ready-mode',T.settings_file_ready_mode); + setText('opt-file-ready-dialog',T.settings_file_ready_dialog); + setText('opt-file-ready-banner',T.settings_file_ready_banner); setText('lbl-camera-on-print',T.settings_camera_on_print); setText('lbl-web-upload-warning',T.settings_web_upload_warning); + setText('fd-options-title',T.fd_options_title); + setText('fd-lbl-auto-leveling',T.print_auto_leveling); setText('lbl-update-check',T.update_check); setText('lbl-update-apply',T.update_apply); @@ -652,10 +660,18 @@ function applyState(){ var bannerVisible=false; var frb=document.getElementById('file-ready-banner'); if(frb){ + var shouldAutoOpen=(s.print_start_dialog===undefined?true:!!s.print_start_dialog); if(s.file_ready&&s.print_state==='standby'){ document.getElementById('file-ready-name').textContent=s.file_ready; - frb.style.display='flex'; - bannerVisible=true; + // 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); + } else { + frb.style.display='flex'; + bannerVisible=true; + } }else{frb.style.display='none';} } // skip-button (mid-print) – nur sichtbar wenn aktuell gedruckt wird @@ -958,6 +974,7 @@ function openSettings(){ document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; + var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0)); var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); // Poll-Intervall (Sekunden) — Backend hat Vorrang vor localStorage var pi=document.getElementById('s-poll-interval'); @@ -1455,6 +1472,7 @@ function saveSettings(){ default_ams_slot: document.getElementById('s-default-slot').value, auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, + print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10), web_upload_warning:webUploadWarning, poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)), }).then(function(){ @@ -2239,7 +2257,8 @@ function confirmStoreWebVerify(){ }); } -function startReadyFileWithSlots(filename){ +function startReadyFileWithSlots(filename,_autoOpen){ + if(!_autoOpen) _fdAutoOpenedFile=null; // manueller Aufruf → Auto-Open-Sperre aufheben var fn=filename||S.file_ready; var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;}); if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){ @@ -2252,10 +2271,16 @@ function startReadyFileWithSlots(filename){ _storeFileId=null; _gcodeFilaments=[]; + var _autoOpenFile=_autoOpen?fn:null; + if(_autoOpen) _fdDialogOpen=true; // bereits während Fetch sperren function openWithSlots(){ fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){ + if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;} openFilamentDialog(d.result||[]); - }).catch(function(){openFilamentDialog([]);}); + }).catch(function(){ + if(_autoOpenFile && _fdUserCancelled){_fdDialogOpen=false;return;} + openFilamentDialog([]); + }); } var fileObj=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;}); @@ -2325,6 +2350,9 @@ function openFilamentDialog(slots){ var title=document.getElementById('fd-title'); var body=document.getElementById('fd-slots'); if(title)title.textContent='▶ '+_storeFilename; + // Auto-Leveling-Checkbox mit globalem Default vorbelegen + var fdAl=document.getElementById('fd-auto-leveling'); + if(fdAl) fdAl.checked=(S.auto_leveling===undefined?true:!!S.auto_leveling); // Objekt-Liste laden (nur Store-Modus: per File-ID; Banner-Modus hat keine ID) _printObjects=[]; _printObjectsSvg=''; @@ -2432,6 +2460,8 @@ function openFilamentDialog(slots){ function closeFilamentDialog(){ var dlg=document.getElementById('filament-dialog'); if(dlg)dlg.classList.remove('open'); + _fdDialogOpen=false; + if(_fdAutoOpenedFile) _fdUserCancelled=true; } function confirmFilamentPrint(){ @@ -2471,12 +2501,14 @@ function confirmFilamentPrint(){ } // Pre-Print Skip: Namen der abgehakten Objekte sammeln 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); closeFilamentDialog(); if(_filamentDialogMode==='banner'){ // Banner-Modus: normaler print/start mit Slot-Override 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}) + 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'; @@ -2491,7 +2523,7 @@ function confirmFilamentPrint(){ fetch(_apiUrl('/kx/print'),{ method:'POST', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({file_id:_storeFileId,filament_assignments:assignments,excluded_objects:excludedObjects}) + body:JSON.stringify({file_id:_storeFileId,filament_assignments:assignments,excluded_objects:excludedObjects,auto_leveling:fdAutoLeveling}) }).then(function(r){return r.json()}).then(function(d){ if(d.result==='ok'){clog('Druckstart: '+_storeFilename,'msg-ok');showPanel('dashboard');} else{clog('Druckfehler: '+(d.error||'?'),'msg-err');} diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 5be145c..424ea10 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -492,6 +492,13 @@ +