diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 35d860d..bd05801 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -17,6 +17,8 @@ import hashlib import json import logging import os +import pathlib +import re import sys import tempfile import time @@ -56,8 +58,9 @@ KLIPPER_VERSION = "v0.12.0-1" class KobraXBridge: - def __init__(self, client: KobraXClient): + def __init__(self, client: KobraXClient, args=None): self.client = client + self._args = args self.ws_clients: set[web.WebSocketResponse] = set() self._last_state: dict = {} self._state = { @@ -66,6 +69,7 @@ class KobraXBridge: "bed_temp": 0.0, "bed_target": 0.0, "print_state": "standby", + "kobra_state": "free", "filename": "", "progress": 0.0, "print_duration": 0, @@ -110,6 +114,8 @@ class KobraXBridge: d = payload.get("data") or {} kobra_state = payload.get("state", "") self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing") + if kobra_state: + self._state["kobra_state"] = kobra_state self._state["filename"] = d.get("filename", self._state["filename"]) if "progress" in d: self._state["progress"] = float(d["progress"]) / 100.0 @@ -128,6 +134,7 @@ class KobraXBridge: kobra_state = d.get("state", "") if kobra_state: self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby") + self._state["kobra_state"] = kobra_state t = d.get("temp") or {} if t: self._state["nozzle_temp"] = float(t.get("curr_nozzle_temp", 0)) @@ -630,6 +637,7 @@ main{flex:1;overflow-y:auto;padding:20px} /* ── TEMPS ── */ .temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px} .temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative} .temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px} .temp-row{display:flex;align-items:baseline;gap:6px} @@ -702,6 +710,35 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background: .panel{display:none} .panel.active{display:block} +/* ── MODAL ── */ +.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6); + z-index:200;align-items:center;justify-content:center;padding:16px} +.modal-overlay.open{display:flex} +.modal-box{background:var(--card);border:1px solid var(--border);border-radius:14px; + width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:24px; + display:flex;flex-direction:column;gap:18px} +.modal-header{display:flex;align-items:center;justify-content:space-between} +.modal-title{font-size:15px;font-weight:700;color:var(--txt)} +.modal-close{background:none;border:none;color:var(--txt2);font-size:20px; + cursor:pointer;padding:4px 8px;border-radius:6px} +.modal-close:hover{background:var(--raised);color:var(--txt)} +.modal-section{font-size:10px;text-transform:uppercase;letter-spacing:.1em; + color:var(--txt2);margin-bottom:6px;margin-top:4px} +.modal-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px} +.modal-field label{font-size:12px;color:var(--txt2)} +.modal-field input{background:var(--raised);border:1px solid var(--border); + border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%} +.modal-field input:focus{outline:none;border-color:var(--accent)} +.poll-btns{display:flex;gap:8px} +.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border); + border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s} +.poll-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600} +.update-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.update-status{font-size:12px;color:var(--txt2);flex:1;min-width:0} +.modal-save{width:100%;padding:10px;background:var(--accent);border:none; + border-radius:8px;color:#000;font-weight:700;font-size:14px;cursor:pointer;margin-top:4px} +.modal-save:hover{opacity:.88} + /* ── BOTTOM NAV (mobile) ── */ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0; background:var(--card);border-top:1px solid var(--border); @@ -711,20 +748,52 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0; .bnav-btn.active{color:var(--accent)} .bnav-icon{font-size:20px} -@media(max-width:768px){ - nav.sidebar{display:none} - nav.bottom-nav{display:flex} - main{padding:12px;padding-bottom:72px} - .hero{grid-template-columns:1fr} - .temp-pair{grid-template-columns:1fr} - .ams-slots{grid-template-columns:repeat(2,1fr)} - .joypad{grid-template-columns:repeat(3,48px);grid-template-rows:repeat(3,48px)} -} -@media(min-width:769px) and (max-width:1200px){ +/* ── Tablet (769–1100px): schmale Sidebar ── */ +@media(min-width:769px) and (max-width:1100px){ nav.sidebar{width:52px;padding:12px 4px} .nav-btn .nav-text{display:none} .nav-btn{justify-content:center;padding:10px} .nav-icon{width:auto} + .grid{grid-template-columns:repeat(2,1fr)} + .hero{grid-template-columns:1fr} +} + +/* ── Mobile (≤768px): Bottom-Nav, 1-Spalte ── */ +@media(max-width:768px){ + nav.sidebar{display:none} + nav.bottom-nav{display:flex} + main{padding:10px;padding-bottom:72px} + + /* Header kompakt */ + header{padding:0 12px;gap:8px} + .hname{display:none} + + /* 1-Spalten-Grid, full-width spans funktionieren weiterhin */ + .grid{grid-template-columns:1fr;gap:12px} + + /* Hero: Kamera über Info */ + .hero{grid-template-columns:1fr} + .cam-wrap{max-height:220px} + + /* Temp-Pair und Temp-Card übereinander */ + .temp-pair{grid-template-columns:1fr} + .temp-card-inner{grid-template-columns:1fr} + + /* AMS: 2 Spalten */ + .ams-slots{grid-template-columns:repeat(2,1fr)} + + /* Joypad etwas kleiner */ + .joypad{grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:5px} + .joy{font-size:16px} + + /* Buttons größere Touch-Targets */ + .btn{padding:10px 14px;font-size:13px} + .btn-sm{padding:8px 12px} + .step-btn{padding:8px 12px;font-size:13px} + + /* Modal vollbreite auf kleinen Screens */ + .modal-box{padding:16px;border-radius:10px} + .poll-btns{gap:6px} } @@ -736,22 +805,74 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
Standby
+ + + +
@@ -760,42 +881,52 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
-
-
-
-
📷 Kamera nicht gestartet
-
- - - + +
+
+
📷 Kamera
+
+ 💡 Licht +
-
- -
-
Fortschritt
-
0%
-
-
-
- - -
-
-
- - - -
+
+
+
📷 Kamera nicht gestartet
+
+ + +
- -
+ +
+
Fortschritt
+ +
0%
+
+
+ + +
+
+
+ + + +
+
+ + +
Temperaturen
-
+
Nozzle
@@ -803,12 +934,14 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
°C
0°C
- - - - +
+
+
+
+ + + +
Bett
@@ -817,132 +950,23 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
°C
0°C
- - - - -
-
-
- - -
-
Licht & Lüfter
-
- 💡 Licht - -
-
- 🌀 Lüfter - - 0 -
-
-
-
- - -
-
-
-
Drucksteuerung
-
-
0%
-
-
- - -
-
-
-
- - - -
-
-
-
Temperaturen (Live)
- -
-
-
Nozzle
-
-
°C
+
+
-
0°C
-
- - -
-
-
-
Bett
-
-
°C
-
-
0°C
-
- - +
+ + +
+
+
Verlauf (letzte 60 Messungen)
+ +
-
-
- -
-
-
-
Nozzle
-
-
-
°C
-
-
Ziel: 0°C
-
-
-
-
- - - -
-
-
-
Heizbett
-
-
-
°C
-
-
Ziel: 0°C
-
-
-
-
- - - -
-
-
-
Verlauf (letzte 60 Messungen)
- -
-
-
- - -
-
+
XY-Achsen
@@ -977,54 +1001,13 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
Schrittweite: 1 mm
-
-
- -
-
-
AMS / Filamentbox
-
-
- Keine AMS-Daten empfangen -
-
-
-
Slot auswählen
-
- - Slot 1 -
-
- - -
-
-
-
- - -
-
+
-
💡 Licht
-
- Ein / Aus - -
-
-
-
🌀 Lüfter
+
🌀 Lüfter
- Geschwindigkeit - - 0 + + 0
@@ -1034,13 +1017,26 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
-
-
📷 Kamera
-
- - + + +
+
AMS / Filamentbox
+
+
Keine AMS-Daten empfangen
+
+
+
Slot auswählen
+
+ + Slot 1 +
+
+ + +
-
@@ -1057,9 +1053,6 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0; @@ -1085,8 +1078,9 @@ function toggleTheme(){ // ── i18n ── var LANG_DE={ header_status_standby:'Bereit',header_status_printing:'Druckt',header_status_complete:'Fertig',header_status_error:'Fehler', + kobra_free:'Bereit',kobra_busy:'Beschäftigt',kobra_printing:'Druckt',kobra_preheating:'Aufheizen',kobra_auto_leveling:'Nivellierung',kobra_checking:'Prüfung',kobra_updated:'Aktualisierung',kobra_init:'Initialisierung',kobra_finished:'Abgeschlossen',kobra_failed:'Fehler',kobra_canceled:'Abgebrochen',kobra_offline:'Offline', nav_dashboard:'Dashboard',nav_print:'Druck',nav_temps:'Temperaturen',nav_motion:'Achsen',nav_ams:'AMS',nav_extras:'Licht / Lüfter',nav_console:'Konsole', - card_progress:'Fortschritt',card_temps:'Temperaturen',card_light_fan:'Licht & Lüfter', + card_progress:'Fortschritt',card_temps:'Temperaturen',card_light_fan:'Lüfter', 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', @@ -1098,12 +1092,18 @@ var LANG_DE={ panel_extras_light:'Licht',panel_extras_fan:'Lüfter',panel_extras_camera:'Kamera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop', panel_console_title:'Ereignis-Log', log_light_on:'Licht an',log_light_off:'Licht aus',log_fan:'Lüfter →',log_nozzle:'Nozzle →',log_bed:'Bett →',log_axis:'Achse',log_home:'Home',log_home_all:'Home All',log_cam_start:'Kamera gestartet:',log_cam_stop:'Kamera gestoppt',log_poll_error:'Poll-Fehler:',log_error:'Fehler:', - confirm_cancel:'Druck wirklich abbrechen?' + confirm_cancel:'Druck wirklich abbrechen?', + settings_title:'Einstellungen',settings_connection:'Verbindung',settings_poll:'Poll-Intervall',settings_version:'Version', + settings_save:'Speichern & Neustart',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', + 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' }; var LANG_EN={ header_status_standby:'Ready',header_status_printing:'Printing',header_status_complete:'Complete',header_status_error:'Error', + kobra_free:'Ready',kobra_busy:'Busy',kobra_printing:'Printing',kobra_preheating:'Preheating',kobra_auto_leveling:'Auto Leveling',kobra_checking:'Checking',kobra_updated:'Updating',kobra_init:'Initializing',kobra_finished:'Finished',kobra_failed:'Error',kobra_canceled:'Cancelled',kobra_offline:'Offline', nav_dashboard:'Dashboard',nav_print:'Print',nav_temps:'Temperatures',nav_motion:'Motion',nav_ams:'AMS',nav_extras:'Light / Fan',nav_console:'Console', - card_progress:'Progress',card_temps:'Temperatures',card_light_fan:'Light & Fan', + card_progress:'Progress',card_temps:'Temperatures',card_light_fan:'Fan', 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', @@ -1115,7 +1115,12 @@ var LANG_EN={ panel_extras_light:'Light',panel_extras_fan:'Fan',panel_extras_camera:'Camera',btn_cam_start2:'▶ Start',btn_cam_stop2:'◼ Stop', panel_console_title:'Event Log', log_light_on:'Light on',log_light_off:'Light off',log_fan:'Fan →',log_nozzle:'Nozzle →',log_bed:'Bed →',log_axis:'Axis',log_home:'Home',log_home_all:'Home All',log_cam_start:'Camera started:',log_cam_stop:'Camera stopped',log_poll_error:'Poll error:',log_error:'Error:', - confirm_cancel:'Really cancel the print?' + confirm_cancel:'Really cancel the print?', + settings_title:'Settings',settings_connection:'Connection',settings_poll:'Poll Interval',settings_version:'Version', + settings_save:'Save & Restart',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', + 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' }; var currentLang='de'; var T=LANG_DE; @@ -1130,22 +1135,15 @@ function toggleLang(){ function applyLang(){ // Nav var nb=document.getElementById('nb-dashboard');if(nb)nb.querySelector('.nav-text').textContent=T.nav_dashboard; - nb=document.getElementById('nb-print');if(nb)nb.querySelector('.nav-text').textContent=T.nav_print; - nb=document.getElementById('nb-temps');if(nb)nb.querySelector('.nav-text').textContent=T.nav_temps; - nb=document.getElementById('nb-motion');if(nb)nb.querySelector('.nav-text').textContent=T.nav_motion; - nb=document.getElementById('nb-ams');if(nb)nb.querySelector('.nav-text').textContent=T.nav_ams; - nb=document.getElementById('nb-extras');if(nb)nb.querySelector('.nav-text').textContent=T.nav_extras; nb=document.getElementById('nb-console');if(nb)nb.querySelector('.nav-text').textContent=T.nav_console; // Bottom nav var bnb=document.getElementById('bnb-dashboard');if(bnb)bnb.lastChild.textContent=T.nav_dashboard; - bnb=document.getElementById('bnb-print');if(bnb)bnb.lastChild.textContent=T.nav_print; - bnb=document.getElementById('bnb-motion');if(bnb)bnb.lastChild.textContent=T.nav_motion; - bnb=document.getElementById('bnb-extras');if(bnb)bnb.lastChild.textContent=T.nav_extras; bnb=document.getElementById('bnb-console');if(bnb)bnb.lastChild.textContent=T.nav_console; // Dashboard card titles setText('d-card-progress',T.card_progress); setText('d-card-temps',T.card_temps); setText('d-card-lightfan',T.card_light_fan); + setText('d-card-ams',T.panel_ams_title); // Dashboard buttons setText('d-btn-pause',T.btn_pause); setText('d-btn-resume',T.btn_resume); @@ -1153,41 +1151,33 @@ function applyLang(){ setText('cam-toggle-btn',camOn?T.btn_cam_stop:T.btn_cam_start); setText('cam-placeholder-txt',T.cam_placeholder); // Temp labels - document.querySelectorAll('.lbl-nozzle').forEach(e=>e.textContent=T.label_nozzle); - document.querySelectorAll('.lbl-bed').forEach(e=>e.textContent=T.label_bed); - document.querySelectorAll('.lbl-light').forEach(e=>e.textContent=T.label_light); - document.querySelectorAll('.lbl-fan').forEach(e=>e.textContent=T.label_fan); - document.querySelectorAll('.lbl-on-off').forEach(e=>e.textContent=T.label_on_off); - document.querySelectorAll('.lbl-speed').forEach(e=>e.textContent=T.label_speed); document.querySelectorAll('.lbl-set').forEach(e=>e.textContent=T.label_set); document.querySelectorAll('.lbl-off').forEach(e=>e.textContent=T.label_off); - document.querySelectorAll('.lbl-target-c').forEach(e=>e.textContent=T.label_target_c); - // Panel titles - setText('ptitle-print',T.panel_print_title); - setText('ptitle-temps-nozzle',T.panel_temps_nozzle); - setText('ptitle-temps-bed',T.panel_temps_bed); - setText('ptitle-temps-chart',T.panel_temps_chart); + setText('d-chart-label',T.panel_temps_chart); + // Axis labels setText('ptitle-motion-xy',T.panel_motion_xy); setText('ptitle-motion-z',T.panel_motion_z); - setText('ptitle-ams',T.panel_ams_title); - setText('ptitle-extras-light',T.panel_extras_light); - setText('ptitle-extras-fan',T.panel_extras_fan); - setText('ptitle-extras-camera',T.panel_extras_camera); - setText('ptitle-console',T.panel_console_title); - // Home buttons document.querySelectorAll('.lbl-home-x').forEach(e=>e.textContent=T.btn_home_x); document.querySelectorAll('.lbl-home-y').forEach(e=>e.textContent=T.btn_home_y); document.querySelectorAll('.lbl-home-z').forEach(e=>e.textContent=T.btn_home_z); document.querySelectorAll('.lbl-home-all').forEach(e=>e.textContent=T.btn_home_all); document.querySelectorAll('.lbl-step').forEach(e=>e.textContent=T.label_step); - // Print panel buttons - setText('p-btn-pause',T.panel_print_btn_pause); - setText('p-btn-resume',T.panel_print_btn_resume); - setText('p-btn-cancel',T.panel_print_btn_cancel); - setText('p-title-temps',T.panel_print_temps_live); - // Extras buttons - setText('e-btn-cam-start',T.btn_cam_start2); - setText('e-btn-cam-stop',T.btn_cam_stop2); + // Console + setText('ptitle-console',T.panel_console_title); + // Settings modal + setText('modal-title-settings',T.settings_title); + setText('modal-sec-connection',T.settings_connection); + setText('modal-sec-poll',T.settings_poll); + setText('modal-sec-version',T.settings_version); + setText('btn-save-settings',T.settings_save); + setText('lbl-printer-ip',T.settings_printer_ip); + setText('lbl-mqtt-port',T.settings_mqtt_port); + setText('lbl-username',T.settings_username); + setText('lbl-password',T.settings_password); + setText('lbl-device-id',T.settings_device_id); + setText('lbl-mode-id',T.settings_mode_id); + setText('lbl-update-check',T.update_check); + setText('lbl-update-apply',T.update_apply); } function setText(id,txt){var el=document.getElementById(id);if(el)el.textContent=txt;} (function(){ @@ -1232,38 +1222,33 @@ function applyState(){ // header var b=document.getElementById('h-badge'); b.className='hbadge '+s.print_state; - document.getElementById('h-state').textContent=s.print_state.charAt(0).toUpperCase()+s.print_state.slice(1); + document.getElementById('h-state').textContent=T['kobra_'+s.kobra_state]||s.kobra_state||T.header_status_standby; document.getElementById('h-pname').textContent=s.printer_name; // temps - ['d-nt','p-nt','t-nt'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=s.nozzle_temp.toFixed(1)}); - ['d-nt-t','p-nt-t','t-nt-t'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=s.nozzle_target.toFixed(0)}); - ['d-bt','p-bt','t-bt'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=s.bed_temp.toFixed(1)}); - ['d-bt-t','p-bt-t','t-bt-t'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=s.bed_target.toFixed(0)}); + var nt=document.getElementById('d-nt');if(nt)nt.textContent=s.nozzle_temp.toFixed(1); + var ntt=document.getElementById('d-nt-t');if(ntt)ntt.textContent=s.nozzle_target.toFixed(0); + var bt=document.getElementById('d-bt');if(bt)bt.textContent=s.bed_temp.toFixed(1); + var btt=document.getElementById('d-bt-t');if(btt)btt.textContent=s.bed_target.toFixed(0); - // SVG arcs (0..100.5 = full, offset=100.5 means empty) - var narc=clamp(s.nozzle_temp/300,0,1); - var barc=clamp(s.bed_temp/120,0,1); - var da=document.getElementById('d-narc');if(da)da.setAttribute('stroke-dashoffset',(100.5*(1-narc)).toFixed(1)); - var db=document.getElementById('d-barc');if(db)db.setAttribute('stroke-dashoffset',(100.5*(1-barc)).toFixed(1)); - - // temp bars (temps panel) - var nb=document.getElementById('t-ntbar');if(nb)nb.style.width=clamp(s.nozzle_temp/300*100,0,100)+'%'; - var bb=document.getElementById('t-btbar');if(bb)bb.style.width=clamp(s.bed_temp/120*100,0,100)+'%'; + // temp bars (dashboard) + var nb=document.getElementById('d-ntbar');if(nb)nb.style.width=clamp(s.nozzle_temp/300*100,0,100)+'%'; + var bb=document.getElementById('d-btbar');if(bb)bb.style.width=clamp(s.bed_temp/120*100,0,100)+'%'; // progress var pct=Math.round(s.progress*100); - ['d-pct','p-pct'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=pct}); - ['d-pbar','p-pbar'].forEach(id=>{var el=document.getElementById(id);if(el)el.style.width=pct+'%'}); + var dpct=document.getElementById('d-pct');if(dpct)dpct.textContent=pct; + var dpbar=document.getElementById('d-pbar');if(dpbar)dpbar.style.width=pct+'%'; var layers=s.curr_layer&&s.total_layers?'L '+s.curr_layer+' / '+s.total_layers:'–'; - ['d-layers','p-layers'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=layers}); + var dlayers=document.getElementById('d-layers');if(dlayers)dlayers.textContent=layers; var elapsed=fmtTime(s.print_duration); - ['d-elapsed','p-elapsed'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=elapsed}); + var delapsed=document.getElementById('d-elapsed');if(delapsed)delapsed.textContent=elapsed; var fn=s.filename||'–'; - ['d-fname','p-fname'].forEach(id=>{var el=document.getElementById(id);if(el){el.textContent=fn;el.title=fn}}); + var dfname=document.getElementById('d-fname');if(dfname){dfname.textContent=fn;dfname.title=fn}; + var pfname=document.getElementById('p-fname');if(pfname){pfname.textContent=fn;pfname.title=fn}; var cfo=document.getElementById('cam-fname');if(cfo)cfo.textContent=fn!=='–'?fn:''; // thumbnail @@ -1280,13 +1265,8 @@ function applyState(){ // light/fan sync document.getElementById('d-light-toggle').checked=s.light_on; - document.getElementById('e-light-toggle').checked=s.light_on; - ['d-fan','e-fan'].forEach(id=>{var el=document.getElementById(id);if(el)el.value=s.fan_speed}); - ['d-fan-val','e-fan-val'].forEach(id=>{var el=document.getElementById(id);if(el)el.textContent=s.fan_speed}); - document.getElementById('e-fan-val').textContent=s.fan_speed; - - // camera url - if(s.camera_url){document.getElementById('e-cam-url').textContent=s.camera_url} + var dfan=document.getElementById('d-fan');if(dfan)dfan.value=s.fan_speed; + var dfanval=document.getElementById('d-fan-val');if(dfanval)dfanval.textContent=s.fan_speed; // AMS if(s.ams_slots&&s.ams_slots.length){ @@ -1322,8 +1302,7 @@ function updateHistory(){ tempHistory.b.push(S.bed_temp); if(tempHistory.n.length>60)tempHistory.n.shift(); if(tempHistory.b.length>60)tempHistory.b.shift(); - drawChart('p-chart',tempHistory,[{data:tempHistory.n,color:'#00c8ff',max:300},{data:tempHistory.b,color:'#ff6b35',max:120}]); - drawChart('t-chart',tempHistory,[{data:tempHistory.n,color:'#00c8ff',max:300},{data:tempHistory.b,color:'#ff6b35',max:120}]); + drawChart('d-chart',tempHistory,[{data:tempHistory.n,color:'#00c8ff',max:300},{data:tempHistory.b,color:'#ff6b35',max:120}]); } function drawChart(id,_,series){ var canvas=document.getElementById(id);if(!canvas)return; @@ -1345,6 +1324,94 @@ function drawChart(id,_,series){ }); } +// ── Settings Modal ── +var _updateTag=''; +var _updateUrl=''; +function openSettings(){ + fetch('/api/settings').then(function(r){return r.json()}).then(function(d){ + document.getElementById('s-printer-ip').value=d.printer_ip||''; + document.getElementById('s-mqtt-port').value=d.mqtt_port||9883; + document.getElementById('s-username').value=d.username||''; + document.getElementById('s-password').value=d.password||''; + document.getElementById('s-device-id').value=d.device_id||''; + document.getElementById('s-mode-id').value=d.mode_id||''; + }); + var v=localStorage.getItem('pollInterval')||'2000'; + document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); + var pb=document.getElementById('poll-'+Math.round(parseInt(v)/1000)); + if(pb)pb.classList.add('active'); + document.getElementById('s-version-label').textContent='v'+('__VERSION__'||'?'); + document.getElementById('update-status').textContent=''; + document.getElementById('btn-update-apply').style.display='none'; + _updateTag='';_updateUrl=''; + document.getElementById('settings-modal').classList.add('open'); +} +function closeSettings(){ + document.getElementById('settings-modal').classList.remove('open'); +} +function setPoll(ms){ + document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); + var id='poll-'+Math.round(ms/1000); + var pb=document.getElementById(id);if(pb)pb.classList.add('active'); + localStorage.setItem('pollInterval',ms); + clearInterval(pollTimer); + pollTimer=setInterval(poll,ms); +} +function saveSettings(){ + var btn=document.getElementById('btn-save-settings'); + btn.disabled=true;btn.textContent='…'; + post('/api/settings',{ + printer_ip: document.getElementById('s-printer-ip').value, + mqtt_port: parseInt(document.getElementById('s-mqtt-port').value)||9883, + username: document.getElementById('s-username').value, + password: document.getElementById('s-password').value, + device_id: document.getElementById('s-device-id').value, + mode_id: document.getElementById('s-mode-id').value, + }).then(function(){ + btn.textContent=T.update_restarting; + setTimeout(function(){ + btn.disabled=false; + setText('btn-save-settings',T.settings_save); + closeSettings(); + poll(); + },4000); + }).catch(function(e){ + btn.disabled=false;setText('btn-save-settings',T.settings_save); + clog('Settings-Fehler: '+e,'msg-err'); + }); +} +function checkUpdate(){ + var sb=document.getElementById('update-status'); + sb.textContent=T.update_checking; + document.getElementById('btn-update-apply').style.display='none'; + _updateTag='';_updateUrl=''; + fetch('/api/update/check').then(function(r){return r.json()}).then(function(d){ + if(d.error){sb.textContent=T.update_error+': '+d.error;return;} + if(d.update_available){ + sb.textContent='v'+d.latest+' '+T.update_available; + sb.style.color='var(--ok)'; + _updateTag=d.tag;_updateUrl=d.download_url; + document.getElementById('btn-update-apply').style.display='inline-block'; + } else { + sb.textContent=T.update_none; + sb.style.color='var(--txt2)'; + } + }).catch(function(e){sb.textContent=T.update_error+': '+e;}); +} +function applyUpdate(){ + if(!_updateUrl)return; + var sb=document.getElementById('update-status'); + var btn=document.getElementById('btn-update-apply'); + btn.disabled=true;sb.textContent=T.update_applying; + post('/api/update/apply',{download_url:_updateUrl,tag:_updateTag}).then(function(){ + sb.textContent=T.update_restarting; + closeSettings(); + setTimeout(function(){poll();},5000); + }).catch(function(e){ + btn.disabled=false;sb.textContent=T.update_error+': '+e; + }); +} + // ── Poll ── async function poll(){ try{ @@ -1356,7 +1423,11 @@ async function poll(){ updateHistory(); }catch(e){clog('Poll-Fehler: '+e,'msg-err')} } -poll();setInterval(poll,2000); +var pollTimer; +(function(){ + var ms=parseInt(localStorage.getItem('pollInterval')||'2000'); + poll();pollTimer=setInterval(poll,ms); +})(); // ── Print actions ── function printAction(a){ @@ -1407,18 +1478,6 @@ function setBed(){ .then(function(){clog('Bett → '+v+'°C','msg-ok')}) .catch(function(e){clog('Temp-Fehler: '+e,'msg-err')}); } -function setNozzle2(){ - var v=parseFloat(document.getElementById('t-nozzle-inp').value||0); - post('/api/temperature',{nozzle:v,bed:S.bed_target}) - .then(function(){clog('Nozzle → '+v+'°C','msg-ok')}) - .catch(function(e){clog('Temp-Fehler: '+e,'msg-err')}); -} -function setBed2(){ - var v=parseFloat(document.getElementById('t-bed-inp').value||0); - post('/api/temperature',{nozzle:S.nozzle_target,bed:v}) - .then(function(){clog('Bett → '+v+'°C','msg-ok')}) - .catch(function(e){clog('Temp-Fehler: '+e,'msg-err')}); -} // ── Light ── function setLight(){ @@ -1427,12 +1486,6 @@ function setLight(){ .then(function(){clog('Licht '+(on?'an, '+br+'%':'aus'),'msg-ok')}) .catch(function(e){clog('Licht-Fehler: '+e,'msg-err')}); } -function setLight2(){ - var on=document.getElementById('e-light-toggle').checked; - post('/api/light',{on:on,brightness:80}) - .then(function(){clog('Licht '+(on?'an, '+br+'%':'aus'),'msg-ok')}) - .catch(function(e){clog('Licht-Fehler: '+e,'msg-err')}); -} // ── Fan ── function setFan(){ @@ -1442,15 +1495,9 @@ function setFan(){ .then(function(){clog('Lüfter → '+v+'%','msg-ok')}) .catch(function(e){clog('Lüfter-Fehler: '+e,'msg-err')}); } -function setFan2(){ - var v=parseInt(document.getElementById('e-fan').value); - post('/api/fan',{speed:v}) - .then(function(){clog('Lüfter → '+v+'%','msg-ok')}) - .catch(function(e){clog('Lüfter-Fehler: '+e,'msg-err')}); -} function quickFan(v){ - document.getElementById('e-fan').value=v; - document.getElementById('e-fan-val').textContent=v; + document.getElementById('d-fan').value=v; + document.getElementById('d-fan-val').textContent=v; post('/api/fan',{speed:v}) .then(function(){clog('Lüfter → '+v+'%','msg-ok')}) .catch(function(e){clog('Lüfter-Fehler: '+e,'msg-err')}); @@ -1473,21 +1520,23 @@ function camStart(){ img.style.display='none'; sp.style.display='block'; post('/api/camera/start',{}).then(function(){ - return new Promise(function(res){setTimeout(res,800)}); - }).then(function(){ - img.onload=function(){ - sp.style.display='none'; - img.style.display='block'; - }; img.onerror=function(){ sp.style.display='none'; + img.style.display='none'; ph.style.display='flex'; + camOn=false; + document.getElementById('cam-toggle-btn').textContent=T.btn_cam_start||'▶ Kamera'; clog((T.log_error||'Fehler:')+' Stream nicht verfügbar','msg-err'); }; img.src='/api/camera/stream?t='+Date.now(); camOn=true; document.getElementById('cam-toggle-btn').textContent=T.btn_cam_stop||'◼ Kamera'; clog((T.log_cam_start||'Kamera gestartet'),'msg-ok'); + // MJPEG liefert kein onload – Spinner nach kurzem Timeout ausblenden + setTimeout(function(){ + sp.style.display='none'; + img.style.display='block'; + },1200); }).catch(function(e){ sp.style.display='none'; ph.style.display='flex'; @@ -1513,6 +1562,8 @@ function toggleCam(){if(camOn)camStop();else camStart()} """ + version = self._read_version() + html = html.replace("'__VERSION__'", f"'{version}'") return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) @@ -1641,13 +1692,26 @@ function toggleCam(){if(camOn)camStop();else camStart()} }) await resp.prepare(request) + is_rtsp = url.lower().startswith("rtsp://") + ffmpeg_input_args = [ + "-fflags", "nobuffer", + "-flags", "low_delay", + ] + if is_rtsp: + ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] + else: + # HTTP-FLV/HLS: braucht mehr Probe-Puffer für Container-Erkennung + ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"] + proc = await asyncio.create_subprocess_exec( "ffmpeg", "-loglevel", "quiet", + *ffmpeg_input_args, "-i", url, - "-vf", "fps=10,scale=640:-1", + "-vf", "fps=15,scale=640:-1", "-f", "image2pipe", "-vcodec", "mjpeg", - "-q:v", "5", + "-q:v", "3", + "-flush_packets", "1", "pipe:1", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, @@ -1709,6 +1773,7 @@ function toggleCam(){if(camOn)camStop();else camStart()} "printer_name": s["printer_name"], "firmware_version": s["firmware_version"], "print_state": s["print_state"], + "kobra_state": s["kobra_state"], "nozzle_temp": s["nozzle_temp"], "nozzle_target": s["nozzle_target"], "bed_temp": s["bed_temp"], @@ -1777,6 +1842,160 @@ function toggleCam(){if(camOn)camStop();else camStart()} self._ams_slots = slots return self._ams_slots + # ─── Settings ──────────────────────────────────────────────────────────── + + def _find_env_path(self) -> pathlib.Path: + """Gibt den Pfad zur .env-Datei zurück (neben Script oder im Parent).""" + script_dir = pathlib.Path(__file__).parent + for base in (script_dir, script_dir.parent): + p = base / ".env" + if p.is_file(): + return p + return script_dir.parent / ".env" + + async def handle_api_settings_get(self, request): + return web.json_response({ + "printer_ip": self._args.printer_ip, + "mqtt_port": self._args.mqtt_port, + "username": self._args.username, + "password": self._args.password, + "mode_id": self._args.mode_id, + "device_id": self._args.device_id, + }) + + async def handle_api_settings_post(self, request): + data = await request.json() + env_path = self._find_env_path() + # Bestehende .env einlesen um Kommentare/Extra-Keys zu erhalten + existing: "dict[str, str]" = {} + lines: "list[str]" = [] + if env_path.is_file(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + k, _, v = stripped.partition("=") + existing[k.strip()] = v.strip() + lines.append(line) + # Werte aktualisieren + mapping = { + "PRINTER_IP": str(data.get("printer_ip", existing.get("PRINTER_IP", ""))), + "MQTT_PORT": str(data.get("mqtt_port", existing.get("MQTT_PORT", "9883"))), + "MQTT_USERNAME": str(data.get("username", existing.get("MQTT_USERNAME",""))), + "MQTT_PASSWORD": str(data.get("password", existing.get("MQTT_PASSWORD",""))), + "MODE_ID": str(data.get("mode_id", existing.get("MODE_ID", ""))), + "DEVICE_ID": str(data.get("device_id", existing.get("DEVICE_ID", ""))), + } + # Zeilen ersetzen oder neue Keys anhängen + written: "set[str]" = set() + new_lines: "list[str]" = [] + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + k = stripped.partition("=")[0].strip() + if k in mapping: + new_lines.append(f"{k}={mapping[k]}") + written.add(k) + continue + new_lines.append(line) + for k, v in mapping.items(): + if k not in written: + new_lines.append(f"{k}={v}") + env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + log.info(f"Settings gespeichert in {env_path}") + # Response senden, dann Neustart + response = web.json_response({"status": "restarting"}) + asyncio.get_event_loop().call_later(0.3, self._restart_bridge) + return response + + def _restart_bridge(self): + log.info("Bridge wird neu gestartet …") + os.execv(sys.executable, [sys.executable] + sys.argv) + + # ─── Update ────────────────────────────────────────────────────────────── + + GITEA_RELEASE_API = "https://gitea.it-drui.de/api/v1/repos/viewit/KX-Bridge-Release/releases?limit=1&pre-release=true" + GITEA_RAW_BASE = "https://gitea.it-drui.de/viewit/KX-Bridge-Release/raw/tag" + + def _read_version(self) -> str: + for base in (pathlib.Path(__file__).parent, pathlib.Path(__file__).parent.parent): + p = base / "VERSION" + if p.is_file(): + return p.read_text(encoding="utf-8").strip() + return "unknown" + + def _write_version(self, version: str): + for base in (pathlib.Path(__file__).parent, pathlib.Path(__file__).parent.parent): + p = base / "VERSION" + if p.is_file(): + p.write_text(version + "\n", encoding="utf-8") + return + # Fallback: neben dem Script + (pathlib.Path(__file__).parent.parent / "VERSION").write_text(version + "\n", encoding="utf-8") + + @staticmethod + def _parse_version(v: str) -> "tuple[int, ...]": + """'v0.9.1-beta1' → (0, 9, 1) – nur numerische Teile vor dem ersten '-'""" + v = v.lstrip("v").split("-")[0] + parts = re.split(r"[.\s]+", v) + result = [] + for p in parts: + try: + result.append(int(p)) + except ValueError: + break + return tuple(result) or (0,) + + async def handle_api_update_check(self, request): + current = self._read_version() + try: + async with aiohttp.ClientSession() as session: + async with session.get(self.GITEA_RELEASE_API, timeout=aiohttp.ClientTimeout(total=10)) as resp: + if resp.status != 200: + return web.json_response({"error": f"Gitea HTTP {resp.status}"}, status=502) + releases = await resp.json(content_type=None) + if not releases: + return web.json_response({"error": "Keine Releases gefunden"}, status=404) + data = releases[0] + tag = data.get("tag_name", "") + latest = tag.lstrip("v") + update_available = self._parse_version(tag) > self._parse_version(current) + download_url = f"{self.GITEA_RAW_BASE}/{tag}/kobrax_moonraker_bridge.py" + return web.json_response({ + "current": current, + "latest": latest, + "update_available": update_available, + "tag": tag, + "download_url": download_url, + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=502) + + async def handle_api_update_apply(self, request): + data = await request.json() + download_url = data.get("download_url", "") + new_tag = data.get("tag", "") + if not download_url: + return web.json_response({"error": "download_url fehlt"}, status=400) + script_path = pathlib.Path(__file__).resolve() + try: + async with aiohttp.ClientSession() as session: + async with session.get(download_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + return web.json_response({"error": f"Download HTTP {resp.status}"}, status=502) + content = await resp.read() + # Atomisch ersetzen + tmp = script_path.with_suffix(".py.new") + tmp.write_bytes(content) + os.replace(tmp, script_path) + if new_tag: + self._write_version(new_tag.lstrip("v")) + log.info(f"Update auf {new_tag} installiert, starte neu …") + except Exception as e: + return web.json_response({"error": str(e)}, status=502) + response = web.json_response({"status": "updating"}) + asyncio.get_event_loop().call_later(0.3, self._restart_bridge) + return response + async def handle_catchall(self, request): body = await request.read() log.warning(f"UNBEKANNT {request.method} {request.path_qs} body={body[:200]}") @@ -1922,8 +2141,41 @@ function toggleCam(){if(camOn)camStop();else camStart()} # Poll loop (sync, runs in executor) # ------------------------------------------------------------------------- + def _printer_reachable(self) -> bool: + """TCP-Probe auf den MQTT-Port – kein ICMP nötig, kein root erforderlich.""" + import socket as _socket + try: + with _socket.create_connection( + (self._args.printer_ip, self._args.mqtt_port), timeout=2.0 + ): + return True + except OSError: + return False + def _poll_loop(self, stop_event: threading.Event): + _offline = False # True = Drucker zuletzt nicht erreichbar + _probe_interval = 10.0 # Sekunden zwischen TCP-Probes im Offline-Modus + while not stop_event.is_set(): + # ── Offline-Modus: warten bis Drucker wieder erreichbar ────────── + if _offline: + if self._printer_reachable(): + log.info("Drucker erreichbar – stelle MQTT-Verbindung her …") + try: + self.client.connect() + _offline = False + self._state["print_state"] = "standby" + self._state["kobra_state"] = "free" + log.info("MQTT-Verbindung wiederhergestellt") + except Exception as e: + log.warning(f"Verbindungsaufbau fehlgeschlagen: {e}") + stop_event.wait(_probe_interval) + continue + else: + stop_event.wait(_probe_interval) + continue + + # ── Online-Modus: normaler Poll ────────────────────────────────── try: info = self.client.query_info() if info: @@ -1942,6 +2194,16 @@ function toggleCam(){if(camOn)camStop();else camStart()} self._ams_slots = slots except Exception as e: log.warning(f"Poll-Fehler: {e}") + # Prüfen ob Drucker wirklich weg ist + if not self._printer_reachable(): + log.info("Drucker nicht erreichbar – wechsle in Offline-Modus") + self._state["print_state"] = "error" + self._state["kobra_state"] = "offline" + try: + self.client.disconnect() + except Exception: + pass + _offline = True stop_event.wait(3.0) @@ -1987,6 +2249,10 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_post("/api/camera/start", bridge.handle_api_camera_start) r.add_post("/api/camera/stop", bridge.handle_api_camera_stop) r.add_get("/api/state", bridge.handle_api_state) + r.add_get("/api/settings", bridge.handle_api_settings_get) + r.add_post("/api/settings", bridge.handle_api_settings_post) + r.add_get("/api/update/check", bridge.handle_api_update_check) + r.add_post("/api/update/apply", bridge.handle_api_update_apply) r.add_get("/serve/{filename}", bridge.handle_serve_file) # Root + favicon (OrcaSlicer öffnet / in eingebettetem Browser) @@ -2018,7 +2284,7 @@ async def run_bridge(args): await loop.run_in_executor(None, client.connect) log.info("MQTT verbunden") - bridge = KobraXBridge(client) + bridge = KobraXBridge(client, args=args) app = build_app(bridge) stop_event = threading.Event()