diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index f552e29..c208c3a 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -1,5 +1,29 @@ # Changelog +## [0.9.16] – 2026-05-22 + +### Neu +- **Kamera bei Druckstart automatisch einschalten:** neue Einstellung „Kamera bei + Druckstart einschalten" — die Bridge startet den Kamera-Stream automatisch, wenn + ein Druck beginnt (für OrcaSlicer und die Bridge-UI). + +### Fixes +- **Einfarbiger Druck durch leeren AMS-Slot blockiert:** OrcaSlicer schreibt alle + konfigurierten Filamente in den GCode-Header, auch wenn das Modell nur eines + nutzt — die Bridge meldete dem Drucker dadurch alle Farben als nötig, und ein + leerer ungenutzter Slot brach den Druck ab. Die Bridge mappt jetzt nur die im + GCode tatsächlich genutzten Filamente. +- **Slicer-Zeit + Thumbnail fehlten nach Browser-Reload** (oder bei Druckstart + direkt aus OrcaSlicer): beide werden jetzt aus dem GCode-Store anhand des + Dateinamens wiederhergestellt statt aus flüchtigem State. +- **Deutsche Übersetzungslücken** im ACE-Trockner-Dialog behoben. + +### Logging +- Wiederholte Log-Zeilen werden als Zähler („×N") zusammengefasst statt zu spammen; + Status-Poll-Verkehr wird nicht mehr auf INFO geloggt. +- Neuer Level-Filter (Alle / Fehler / Warnungen), Toast bei neuen Fehlern, volle + Tracebacks im Browser-Log und ein Download-Dateiname mit Zeitstempel. + ## [0.9.15] – 2026-05-21 ### Fixes (Issue #29) diff --git a/CHANGELOG.md b/CHANGELOG.md index b41fcd1..65484ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [0.9.16] – 2026-05-22 + +### New +- **Auto-start camera on print:** new setting "Turn camera on at print start" — + when enabled, the bridge starts the camera stream automatically when a print + begins (works for both OrcaSlicer and the Bridge UI). + +### Fixes +- **Single-color print blocked by an empty AMS slot:** OrcaSlicer writes all + configured filaments into the GCode header even when the model uses only one, + so the bridge told the printer it needed every color — and an empty unused slot + aborted the print. The bridge now maps only the filaments actually used by the + GCode. +- **Slicer time + thumbnail missing after a browser reload** (or when a print was + started directly from OrcaSlicer): both are now restored from the GCode store + by filename instead of relying on volatile state. +- **German translation gaps** in the ACE dryer dialog fixed. + +### Logging +- Repeated log lines are collapsed into a counter ("×N") instead of spamming the + console; status-poll traffic is no longer logged at INFO. +- New log level filter (All / Errors / Warnings), a toast on new errors, full + tracebacks forwarded to the browser log, and a timestamped download filename. + ## [0.9.15] – 2026-05-21 ### Fixes (Issue #29) diff --git a/VERSION b/VERSION index 5d11b14..f806549 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.15 +0.9.16 diff --git a/config_loader.py b/config_loader.py index c7e97dd..4aa81f7 100644 --- a/config_loader.py +++ b/config_loader.py @@ -59,6 +59,7 @@ def _load_config_file(path: pathlib.Path): "DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"), "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), + "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), } for env_key, (section, option) in mapping.items(): @@ -95,6 +96,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): cfg[CONFIG_SECTION_PRINT] = { "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), + "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), } cfg[CONFIG_SECTION_BRIDGE] = { "poll_interval": "3", @@ -175,3 +177,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")) +CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0")) diff --git a/kobrax_client.py b/kobrax_client.py index 66d17fc..b8154cd 100644 --- a/kobrax_client.py +++ b/kobrax_client.py @@ -371,9 +371,12 @@ class KobraXClient: report_registered = True topic = self._pub_topic(msg_type) - log.info("TX %-25s action=%-12s data=%s", - f"{msg_type}/request", action, - json.dumps(data, ensure_ascii=False) if data else "null") + # Status-Poll-TX (query/getInfo) ist reines Rauschen (alle paar Sekunden) → + # auf DEBUG. Aktions-TX (start/set/control/move/…) bleibt INFO sichtbar. + _tx_level = logging.DEBUG if action in ("query", "getInfo") else logging.INFO + log.log(_tx_level, "TX %-25s action=%-12s data=%s", + f"{msg_type}/request", action, + json.dumps(data, ensure_ascii=False) if data else "null") try: with self._lock: self._sock.sendall(_build_publish(topic, payload)) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index c3b4270..e47f0b9 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -151,11 +151,19 @@ class _BrowserLogHandler(logging.Handler): _fmt = logging.Formatter(datefmt="%H:%M:%S") def emit(self, record: logging.LogRecord): + msg = record.getMessage() + # Exceptions mit Traceback in den Browser durchreichen (sonst sieht der + # Nutzer nur "Fehler: X" ohne Kontext). + if record.exc_info: + try: + msg += "\n" + self._fmt.formatException(record.exc_info) + except Exception: + pass entry = { "ts": self._fmt.formatTime(record, "%H:%M:%S"), "lvl": record.levelname, "name": record.name, - "msg": record.getMessage(), + "msg": msg, } _log_buffer.append(entry) for q in list(_log_sse_queues): @@ -554,6 +562,7 @@ class KobraXBridge: self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir self._current_job_id: str = "" + self._camera_autostarted: bool = False self._thumbnail_b64: str = "" self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config() @@ -665,6 +674,20 @@ class KobraXBridge: if kobra_state: self._state["kobra_state"] = kobra_state + # Kamera bei Druckstart automatisch einschalten (Settings-Option). + # Zentral hier, damit es alle Druck-Startwege abdeckt (OrcaSlicer + UI). + # _camera_autostarted verhindert Mehrfach-Trigger pro Druck. + if kobra_state == "printing": + if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False): + self._camera_autostarted = True + try: + self.client.start_camera() + log.info("Kamera bei Druckstart automatisch eingeschaltet") + except Exception as e: + log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") + elif kobra_state in ("free", "finished", "stoped", "canceled"): + self._camera_autostarted = False + # Job-History: Druckstart erkennen if kobra_state == "printing" and not self._current_job_id: filename = d.get("filename", self._state.get("filename", "")) @@ -737,6 +760,18 @@ class KobraXBridge: # je nach Drucker auch über info/report (project.state), nicht nur print/report. if kobra_state in ("finished", "stoped", "canceled"): self._state["file_ready"] = "" + # Kamera-Autostart auch hier (OrcaSlicer meldet Start oft via info/report). + # _camera_autostarted-Guard verhindert Doppel-Start mit _on_print. + if kobra_state == "printing": + if getattr(self._args, "camera_on_print", 0) and not getattr(self, "_camera_autostarted", False): + self._camera_autostarted = True + try: + self.client.start_camera() + log.info("Kamera bei Druckstart automatisch eingeschaltet") + except Exception as e: + log.warning(f"Kamera-Autostart fehlgeschlagen: {e}") + elif kobra_state in ("free", "finished", "stoped", "canceled"): + self._camera_autostarted = False if project: if "filename" in project: self._state["filename"] = project["filename"] @@ -1833,7 +1868,7 @@ class KobraXBridge: log.info(f"Upload+Print (print=true): {remote_filename}") self._state["file_ready"] = "" loop = asyncio.get_event_loop() - loop.run_in_executor(None, lambda: self._start_print(remote_filename, serve_url, file_md5, file_size)) + loop.run_in_executor(None, lambda: self._start_print(remote_filename, serve_url, file_md5, file_size, gcode_filaments=gcode_filaments)) else: log.info(f"Nur hochgeladen (print=false): {remote_filename}") self._state["file_ready"] = remote_filename @@ -1858,12 +1893,32 @@ class KobraXBridge: } }, status=201) - def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0): + def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0, + gcode_filaments: list | None = None): self._state["file_ready"] = "" loaded = self._select_loaded_slots_for_print(warn_on_empty_default=True) + + # Nur die im GCode TATSÄCHLICH genutzten Paints auf Slots mappen. OrcaSlicer + # schreibt im Header alle konfigurierten Filamente (filament_colour=…;…;…;…), + # nutzt aber oft nur eines (z.B. einfarbig → nur T3). Würden wir alle + # belegten Slots mappen, erwartet der Drucker alle Farben und blockiert, + # wenn ein anderer (ungenutzter) Slot leer ist. Die genutzten Paint-Indizes + # liefert _extract_filament_info via is_used (echte T-Tool-Changes). + used_paint_indices = None + if gcode_filaments: + used = [int(f["slot_index"]) for f in gcode_filaments + if f.get("is_used") and "slot_index" in f] + if used: + used_paint_indices = set(used) + + if used_paint_indices is not None: + # GCode-Paint-Index N entspricht AMS-Slot N (global_index). Nur belegte + # genutzte Slots mappen; nicht-belegte genutzte → später Warnung möglich. + loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] + use_ams = len(loaded) > 0 ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) - log.debug(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i, _ in loaded]}") + log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}") auto_leveling = getattr(self._args, "auto_leveling", 1) payload = { "taskid": "-1", @@ -2538,6 +2593,23 @@ class KobraXBridge: async def handle_api_state(self, request): s = self._state + # Slicer-Zeit + Thumbnail sind nur flüchtig im State (werden beim Upload + # gesetzt). Nach Browser-Reload oder bei OrcaSlicer-Direktdruck (Datei kam + # nicht über den UI-Upload) fehlen sie → aus dem GCode-Store anhand des + # laufenden Dateinamens nachladen. + slicer_time = s["slicer_time"] + thumbnail = self._thumbnail_b64 + fname = s.get("filename", "") + if fname and (not slicer_time or not thumbnail): + try: + gf = self._store.get_file_by_name(fname) + if gf: + if not slicer_time and gf.get("est_print_time_sec"): + slicer_time = int(gf["est_print_time_sec"]) + if not thumbnail and gf.get("thumbnail_b64"): + thumbnail = gf["thumbnail_b64"] + except Exception: + pass return web.json_response({ "printer_name": s["printer_name"], "firmware_version": s["firmware_version"], @@ -2553,7 +2625,7 @@ class KobraXBridge: "curr_layer": s["curr_layer"], "total_layers": s["total_layers"], "filename": s["filename"], - "slicer_time": s["slicer_time"], + "slicer_time": slicer_time, "camera_url": s["camera_url"], "fan_speed": s["fan_speed"], "print_speed_mode": s["print_speed_mode"], @@ -2566,7 +2638,7 @@ class KobraXBridge: "ace_units": list(self._ace_box_ids), "ace_auto_feed": dict(self._ace_auto_feed), "ace_dry_presets": self._ace_dry_presets, - "thumbnail": self._thumbnail_b64, + "thumbnail": thumbnail, "connection_error": s["connection_error"], "file_ready": s["file_ready"], "version": self._read_version(), @@ -2648,6 +2720,7 @@ class KobraXBridge: "device_id": self._args.device_id, "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), "auto_leveling": getattr(self._args, "auto_leveling", 1), + "camera_on_print": getattr(self._args, "camera_on_print", 0), "ace_dry_presets": self._ace_dry_presets, }) @@ -2676,6 +2749,7 @@ class KobraXBridge: cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or ""))) cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto")))) 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)))))) if not cfg.has_option("bridge", "poll_interval"): cfg.set("bridge", "poll_interval", "3") printer_name = str(data.get("printer_name", "")).strip() @@ -2940,12 +3014,15 @@ class KobraXBridge: async def handle_api_log_download(self, request): """Gibt alle gepufferten Log-Einträge als Plaintext zum Download.""" - lines = [f"[{e['ts']}] {e['lvl']:<5} {e['name']}: {e['msg']}" for e in _log_buffer] - text = "\n".join(lines) + header = (f"# KX-Bridge Log | Version {self._read_version()} | " + f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {len(_log_buffer)} Einträge\n") + lines = [f"[{e['ts']}] {e['lvl']:<7} {e['name']}: {e['msg']}" for e in _log_buffer] + text = header + "\n".join(lines) + "\n" + fname = f"kx-bridge-log_{time.strftime('%Y%m%d-%H%M%S')}.txt" return web.Response( body=text.encode("utf-8"), content_type="text/plain", - headers={"Content-Disposition": 'attachment; filename="kx-bridge.log"'}, + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, ) async def handle_api_update_check(self, request): @@ -3517,6 +3594,7 @@ def main(): parser.add_argument("--device-id", default=env_loader.DEVICE_ID) parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT) 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("--host", default="0.0.0.0", help="Bind-Adresse für den Bridge-Server") diff --git a/web/DOC/THEME-JS-ID-HOOKS.md b/web/DOC/THEME-JS-ID-HOOKS.md index a8f199b..6c2ba02 100644 --- a/web/DOC/THEME-JS-ID-HOOKS.md +++ b/web/DOC/THEME-JS-ID-HOOKS.md @@ -54,6 +54,10 @@ Referenzliste für JavaScript-/DOM-Hooks. | `#logdir-all` | Hook / Selektor | | `#logdir-rx` | Hook / Selektor | | `#logdir-tx` | Hook / Selektor | +| `#log-lbl-level` | i18n-Label "Level:" | +| `#loglvl-all` | onclick `setLogLevel('all')` | +| `#loglvl-err` | onclick `setLogLevel('err')` — nur Fehler | +| `#loglvl-warn` | onclick `setLogLevel('warn')` — Fehler + Warnungen | | `#nb-console` | Hook / Selektor | | `#nb-dashboard` | Hook / Selektor | | `#nb-printers` | Hook / Selektor | diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 5a73457..ec9bd7a 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -96,7 +96,7 @@ var LANG_DE={ card_progress:'Fortschritt',card_temps:'Temperaturen',card_light_fan:'Lüfter',card_speed:'Druckgeschwindigkeit',card_cam:'Kamera',lbl_elapsed:'Verstrichen:',lbl_remaining:'Restzeit:',lbl_slicer_time:'Slicer-Schätzung:',lbl_layers:'Layer', speed_silent:'🐢 Leise',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport', lbl_light:'💡 Licht',lbl_feed:'Einziehen',lbl_unload:'Ausziehen', - card_ace_dry:'ACE Trocknung',ace_dry_dryer:'Trockner',ace_dry_status_off:'Status: Aus',ace_dry_status_on:'Status: Aktiv',ace_dry_status_remaining:'Rest',ace_dry_humidity:'Luftfeuchte',ace_dry_current_temp:'Temperatur',ace_dry_chart:'Verlauf (Temp/Feuchte)',ace_dry_temp:'Temperatur (°C)',ace_dry_duration:'Dauer (Min)',ace_dry_start:'▶ Start',ace_dry_stop:'■ Stop',ace_dry_auto_refill:'Auto Refill',ace_dry_enable:'Enable Drying',ace_dry_temp_line:'Trocknungstemperatur',ace_dry_time_line:'Trocknungszeit',ace_dry_ui_pending:'(nur UI, Backend folgt)',ace_dry_dialog_title:'Dryer Temp/Time Settings',ace_dry_dialog_temp:'Temperature (30-80°C)',ace_dry_dialog_time:'Rem. Time (h:m:s)',ace_dry_dialog_confirm:'Confirm',ace_dry_dialog_cancel:'Cancel',ace_dry_dialog_save_restart:'Speichern & Neustart',ace_dry_dialog_custom_name:'Custom Name', + card_ace_dry:'ACE Trocknung',ace_dry_dryer:'Trockner',ace_dry_status_off:'Status: Aus',ace_dry_status_on:'Status: Aktiv',ace_dry_status_remaining:'Rest',ace_dry_humidity:'Luftfeuchte',ace_dry_current_temp:'Temperatur',ace_dry_chart:'Verlauf (Temp/Feuchte)',ace_dry_temp:'Temperatur (°C)',ace_dry_duration:'Dauer (Min)',ace_dry_start:'▶ Start',ace_dry_stop:'■ Stop',ace_dry_auto_refill:'Auto-Nachschub',ace_dry_enable:'Trocknung aktivieren',ace_dry_temp_line:'Trocknungstemperatur',ace_dry_time_line:'Trocknungszeit',ace_dry_ui_pending:'(nur UI, Backend folgt)',ace_dry_dialog_title:'Trockner Temp/Zeit-Einstellungen',ace_dry_dialog_temp:'Temperatur (30-80°C)',ace_dry_dialog_time:'Restzeit (h:m:s)',ace_dry_dialog_confirm:'Bestätigen',ace_dry_dialog_cancel:'Abbrechen',ace_dry_dialog_save_restart:'Speichern & Neustart',ace_dry_dialog_custom_name:'Eigener Name',ace_dry_dialog_reset_default:'Auf Standard zurücksetzen', cam_placeholder:'📷 Kamera nicht gestartet',btn_cam_start:'▶ Kamera',btn_cam_stop:'◼ Kamera', btn_pause:'⏸ Pause',btn_resume:'▶ Weiter',btn_cancel:'✕ Stopp', label_nozzle:'Nozzle',label_bed:'Bett',label_fan:'🌀 Lüfter',label_light:'💡 Licht',label_on_off:'Ein / Aus',label_speed:'Geschwindigkeit', @@ -112,7 +112,7 @@ var LANG_DE={ settings_title:'Einstellungen',settings_connection:'Verbindung',settings_print:'Druckeinstellungen',settings_poll:'Poll-Intervall',settings_version:'Version', settings_save:'Speichern & Neustart',settings_printer_name:'Drucker-Name',settings_printer_ip:'Drucker-IP',settings_mqtt_port:'MQTT-Port', settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',hint_ip_no_port:'Nur IP-Adresse, kein Port (z.B. 192.168.1.102)', - settings_default_slot:'Standard-Slot (Einfarbdruck)',settings_slot_auto:'Auto (alle belegten Slots)',settings_auto_leveling:'Auto-Leveling vor Druck', + settings_default_slot:'Standard-Slot (Einfarbdruck)',settings_slot_auto:'Auto (alle belegten Slots)',settings_auto_leveling:'Auto-Leveling vor Druck',settings_camera_on_print:'Kamera bei Druckstart einschalten', update_check:'Auf Updates prüfen',update_checking:'Prüfe...',update_available:'verfügbar',update_none:'Bereits aktuell', update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler', btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen', @@ -121,7 +121,7 @@ var LANG_DE={ slot_edit_load:'⬇ Einziehen',slot_edit_unload:'⬆ Ausziehen', slot_edit_save:'💾 Speichern',slot_edit_custom:'z.B. PLA, PETG, ABS…', slot_edit_ok:'AMS Slot', - log_dir_all:'Alle', + log_dir_all:'Alle',log_lvl_label:'Level:', file_ready_btn:'▶ Druck starten', file_slots_btn:'🎨 Slots wählen', file_cancel_btn:'✕ Abbrechen', @@ -159,7 +159,7 @@ var LANG_EN={ card_progress:'Progress',card_temps:'Temperatures',card_light_fan:'Fan',card_speed:'Print Speed',card_cam:'Camera',lbl_elapsed:'Elapsed:',lbl_remaining:'Remaining:',lbl_slicer_time:'Slicer estimate:',lbl_layers:'Layer', speed_silent:'🐢 Silent',speed_normal:'⚡ Normal',speed_sport:'🚀 Sport', lbl_light:'💡 Light',lbl_feed:'Load',lbl_unload:'Unload', - card_ace_dry:'ACE Drying',ace_dry_dryer:'Dryer',ace_dry_status_off:'Status: Off',ace_dry_status_on:'Status: Active',ace_dry_status_remaining:'Remaining',ace_dry_humidity:'Humidity',ace_dry_current_temp:'Temperature',ace_dry_chart:'History (Temp/Humidity)',ace_dry_temp:'Temperature (°C)',ace_dry_duration:'Duration (min)',ace_dry_start:'▶ Start',ace_dry_stop:'■ Stop',ace_dry_auto_refill:'Auto Refill',ace_dry_enable:'Enable Drying',ace_dry_temp_line:'Drying Temperature',ace_dry_time_line:'Drying Time',ace_dry_ui_pending:'(UI only, backend next)',ace_dry_dialog_title:'Dryer Temp/Time Settings',ace_dry_dialog_temp:'Temperature (30-80°C)',ace_dry_dialog_time:'Rem. Time (h:m:s)',ace_dry_dialog_confirm:'Confirm',ace_dry_dialog_cancel:'Cancel',ace_dry_dialog_save_restart:'Save & Restart',ace_dry_dialog_custom_name:'Custom Name', + card_ace_dry:'ACE Drying',ace_dry_dryer:'Dryer',ace_dry_status_off:'Status: Off',ace_dry_status_on:'Status: Active',ace_dry_status_remaining:'Remaining',ace_dry_humidity:'Humidity',ace_dry_current_temp:'Temperature',ace_dry_chart:'History (Temp/Humidity)',ace_dry_temp:'Temperature (°C)',ace_dry_duration:'Duration (min)',ace_dry_start:'▶ Start',ace_dry_stop:'■ Stop',ace_dry_auto_refill:'Auto Refill',ace_dry_enable:'Enable Drying',ace_dry_temp_line:'Drying Temperature',ace_dry_time_line:'Drying Time',ace_dry_ui_pending:'(UI only, backend next)',ace_dry_dialog_title:'Dryer Temp/Time Settings',ace_dry_dialog_temp:'Temperature (30-80°C)',ace_dry_dialog_time:'Rem. Time (h:m:s)',ace_dry_dialog_confirm:'Confirm',ace_dry_dialog_cancel:'Cancel',ace_dry_dialog_save_restart:'Save & Restart',ace_dry_dialog_custom_name:'Custom Name',ace_dry_dialog_reset_default:'Reset to Default', cam_placeholder:'📷 Camera not started',btn_cam_start:'▶ Camera',btn_cam_stop:'◼ Camera', btn_pause:'⏸ Pause',btn_resume:'▶ Resume',btn_cancel:'✕ Stop', label_nozzle:'Nozzle',label_bed:'Bed',label_fan:'🌀 Fan',label_light:'💡 Light',label_on_off:'On / Off',label_speed:'Speed', @@ -175,7 +175,7 @@ var LANG_EN={ settings_title:'Settings',settings_connection:'Connection',settings_print:'Print Settings',settings_poll:'Poll Interval',settings_version:'Version', settings_save:'Save & Restart',settings_printer_name:'Printer Name',settings_printer_ip:'Printer IP',settings_mqtt_port:'MQTT Port', settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',hint_ip_no_port:'IP address only, no port (e.g. 192.168.1.102)', - settings_default_slot:'Default Slot (single color)',settings_slot_auto:'Auto (all loaded slots)',settings_auto_leveling:'Auto-Leveling before print', + settings_default_slot:'Default Slot (single color)',settings_slot_auto:'Auto (all loaded slots)',settings_auto_leveling:'Auto-Leveling before print',settings_camera_on_print:'Turn camera on at print start', update_check:'Check for Updates',update_checking:'Checking...',update_available:'available',update_none:'Already up to date', update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error', btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect', @@ -184,7 +184,7 @@ var LANG_EN={ slot_edit_load:'⬇ Load',slot_edit_unload:'⬆ Unload', slot_edit_save:'💾 Save',slot_edit_custom:'e.g. PLA, PETG, ABS…', slot_edit_ok:'AMS Slot', - log_dir_all:'All', + log_dir_all:'All',log_lvl_label:'Level:', file_ready_btn:'▶ Start Print', file_slots_btn:'🎨 Select Slots', file_cancel_btn:'✕ Cancel', @@ -362,6 +362,7 @@ function applyLang(){ setText('lbl-default-slot',T.settings_default_slot); setText('opt-slot-auto',T.settings_slot_auto); setText('lbl-auto-leveling',T.settings_auto_leveling); + setText('lbl-camera-on-print',T.settings_camera_on_print); setText('lbl-update-check',T.update_check); setText('lbl-update-apply',T.update_apply); @@ -402,6 +403,8 @@ function applyLang(){ updateSlotEditFeedButton(); var mi=document.getElementById('slot-edit-mat');if(mi)mi.setAttribute('placeholder',T.slot_edit_custom); setText('logdir-all',T.log_dir_all); + setText('loglvl-all',T.log_dir_all); + setText('log-lbl-level',T.log_lvl_label); setText('file-ready-btn',T.file_ready_btn); setText('file-slots-btn',T.file_slots_btn); setText('file-cancel-btn',T.file_cancel_btn); @@ -480,6 +483,7 @@ var consoleLogs=[]; var logAutoScroll=true; var logBadgeCount=0; var logDirFilter='all'; // 'all'|'rx'|'tx' +var logLevelFilter='all'; // 'all'|'err'|'warn' var logTopicFilter=''; // '' = no topic filter function clog(msg,cls){ @@ -496,16 +500,41 @@ function _lvlCls(lvl){ function _appendLog(entry,forceCls){ var cls=forceCls||_lvlCls(entry.lvl); var label=entry.name?'['+entry.name+'] ':''; - consoleLogs.push({ts:entry.ts,msg:label+entry.msg,cls:cls}); + var fullMsg=label+entry.msg; + // Wiederholungen als Zähler zusammenfassen (×N) statt N identische Zeilen. + var last=consoleLogs[consoleLogs.length-1]; + if(last&&last.msg===fullMsg&&last.cls===cls){ + last.count=(last.count||1)+1; + last.ts=entry.ts; // letzte Sichtung + renderLog(); + return; + } + consoleLogs.push({ts:entry.ts,msg:fullMsg,cls:cls,count:1}); if(consoleLogs.length>500)consoleLogs.shift(); - // Badge wenn Tab nicht aktiv und Fehler/Warnungen + // Badge + Toast wenn Tab nicht aktiv und Fehler/Warnungen if(currentPanel!=='console'&&(cls==='msg-err'||cls==='msg-warn')){ logBadgeCount++; var bc=logBadgeCount>99?'99+':logBadgeCount; ['log-badge','log-badge-bot'].forEach(function(id){var b=document.getElementById(id);if(b){b.style.display='inline';b.textContent=bc;}}); } + if(cls==='msg-err')showToast(entry.msg.split('\n')[0]); renderLog(); } +// Kurze rote Snackbar bei Fehlern (auch wenn Konsole-Tab nicht offen). +var _toastTimer=null; +function showToast(msg){ + var t=document.getElementById('kx-toast'); + if(!t){ + t=document.createElement('div'); t.id='kx-toast'; + t.style.cssText='position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--err);color:#fff;padding:10px 18px;border-radius:8px;font-size:13px;z-index:9999;max-width:90vw;box-shadow:0 4px 16px rgba(0,0,0,.4);cursor:pointer'; + t.onclick=function(){showPanel('console');t.style.display='none';}; + document.body.appendChild(t); + } + t.textContent='⚠ '+msg; + t.style.display='block'; + clearTimeout(_toastTimer); + _toastTimer=setTimeout(function(){t.style.display='none';},6000); +} function setLogDir(dir){ logDirFilter=dir; document.querySelectorAll('.log-dir-btn').forEach(function(b){ @@ -514,6 +543,14 @@ function setLogDir(dir){ }); renderLog(); } +function setLogLevel(lvl){ + logLevelFilter=lvl; + document.querySelectorAll('.log-lvl-btn').forEach(function(b){ + b.style.background=b.id==='loglvl-'+lvl?'var(--accent)':'var(--raised)'; + b.style.color=b.id==='loglvl-'+lvl?'#fff':'var(--txt2)'; + }); + renderLog(); +} function setLogTopic(topic){ var inp=document.getElementById('log-filter'); var active=inp.value===topic; @@ -534,11 +571,16 @@ function renderLog(){ var m=l.msg; if(logDirFilter==='rx'&&!/ RX[ (]/.test(m))return false; if(logDirFilter==='tx'&&!/ TX[ (]/.test(m))return false; + if(logLevelFilter==='err'&&l.cls!=='msg-err')return false; + if(logLevelFilter==='warn'&&l.cls!=='msg-err'&&l.cls!=='msg-warn')return false; if(fl&&!m.toLowerCase().includes(fl))return false; return true; }); var savedScroll=logAutoScroll?null:el.scrollTop; - el.innerHTML=rows.map(l=>`
${l.ts}${escHtml(l.msg)}
`).join(''); + el.innerHTML=rows.map(function(l){ + var cnt=(l.count&&l.count>1)?' (×'+l.count+')':''; + return '
'+l.ts+''+escHtml(l.msg)+''+cnt+'
'; + }).join(''); if(logAutoScroll)el.scrollTop=el.scrollHeight; else if(savedScroll!==null)el.scrollTop=savedScroll; } @@ -867,6 +909,7 @@ function openSettings(){ document.getElementById('s-mode-id').value=d.mode_id||''; document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); + var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; }); var v=localStorage.getItem('pollInterval')||'2000'; document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); @@ -1007,6 +1050,7 @@ function saveSettings(){ mode_id: document.getElementById('s-mode-id').value, default_ams_slot: document.getElementById('s-default-slot').value, auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, + camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, }).then(function(){ btn.textContent=T.update_restarting; setTimeout(function(){ diff --git a/web/themes/default/index.html b/web/themes/default/index.html index f0b5c6d..84b33f0 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -94,6 +94,10 @@ +
@@ -406,7 +410,7 @@
Ereignis-Log - ⬇ Download
@@ -423,6 +427,10 @@ + Level: + + + Topic: