diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index f3c9ebc..96bbac6 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -447,10 +447,12 @@ class KobraXBridge: "connection_error": "", "file_ready": "", "filament_mode": "toolhead", + "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None}, } self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot self._pending_load_slot: int = -1 # global slot index requested via /api/ams/feed type=1 + self._ace_box_ids: list[int] = [] # detected ACE unit IDs (0..3) self._head_tools_model: int = -1 self._filament_mode: str = "toolhead" self._last_uploaded_file: str = "" @@ -750,6 +752,7 @@ class KobraXBridge: global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode) self._ams_loaded_slot = global_loaded + self._update_ace_drying_state(data, boxes) if self._pending_load_slot >= 0 and global_loaded == self._pending_load_slot: self._pending_load_slot = -1 activity_map = self._slot_activity_map(boxes, global_loaded) @@ -780,6 +783,69 @@ class KobraXBridge: log.info(f"AMS-Slots empfangen: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}") self._push_status_update() + def _update_ace_drying_state(self, data: dict, boxes: list): + """Extract ACE drying state from multiColorBox report/getInfo payloads.""" + ace_ids = sorted({int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0}) + self._ace_box_ids = [i for i in ace_ids if 0 <= i <= 3] + + def _humidity_from(src: dict, default=None): + for k in ("humidity", "current_humidity", "cur_humidity", "relative_humidity", "humidity_value"): + v = src.get(k) + if v is not None: + try: + return float(v) + except Exception: + return default + return default + + per_unit: list[dict] = [] + for box in boxes: + bid = int(box.get("id", -1)) + if bid < 0: + continue + bs = box.get("drying_status") or box.get("drying_settings") + if isinstance(bs, dict): + per_unit.append({ + "id": bid, + "status": int(bs.get("status", 0)), + "target_temp": int(bs.get("target_temp", 0)), + "duration": int(bs.get("duration", 0)), + "remain_time": int(bs.get("remain_time", 0)), + "humidity": _humidity_from(bs), + }) + + src = data.get("drying_status") or data.get("drying_settings") + if not isinstance(src, dict): + for box in boxes: + if int(box.get("id", -1)) < 0: + continue + cand = box.get("drying_status") or box.get("drying_settings") + if isinstance(cand, dict): + src = cand + break + + if isinstance(src, dict): + cur = self._state.get("ace_drying") or {} + self._state["ace_drying"] = { + "status": int(src.get("status", cur.get("status", 0))), + "target_temp": int(src.get("target_temp", cur.get("target_temp", 0))), + "duration": int(src.get("duration", cur.get("duration", 0))), + "remain_time": int(src.get("remain_time", cur.get("remain_time", 0))), + "humidity": _humidity_from(src, cur.get("humidity")), + "units": per_unit, + } + elif per_unit: + active = [u for u in per_unit if u.get("status", 0)] + primary = active[0] if active else per_unit[0] + self._state["ace_drying"] = { + "status": int(primary.get("status", 0)), + "target_temp": int(primary.get("target_temp", 0)), + "duration": int(primary.get("duration", 0)), + "remain_time": int(primary.get("remain_time", 0)), + "humidity": primary.get("humidity"), + "units": per_unit, + } + def _on_light(self, payload: dict): d = payload.get("data") or {} self._state["light_on"] = bool(d.get("status", 0)) @@ -2251,6 +2317,25 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0; + +
Filament
@@ -2352,7 +2437,7 @@ var S={nozzle_temp:0,nozzle_target:0,bed_temp:0,bed_target:0, print_state:'standby',filename:'',progress:0,print_duration:0,remain_time:0, curr_layer:0,total_layers:0,printer_name:'Kobra X',firmware_version:'–', camera_url:'',fan_speed:0,print_speed_mode:2,light_on:false,light_brightness:80, - ams_slots:[],filament_mode:'toolhead'}; + ams_slots:[],filament_mode:'toolhead',ace_units:[],ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,units:[]}}; var tempHistory={n:[],b:[]}; var camOn=false; var currentStep=1; @@ -2374,6 +2459,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_status_off:'Status: Aus',ace_dry_status_on:'Status: Aktiv',ace_dry_status_remaining:'Rest',ace_dry_humidity:'Luftfeuchte',ace_dry_temp:'Temperatur (°C)',ace_dry_duration:'Dauer (Min)',ace_dry_start:'▶ Start',ace_dry_stop:'■ Stop', 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', @@ -2436,6 +2522,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_status_off:'Status: Off',ace_dry_status_on:'Status: Active',ace_dry_status_remaining:'Remaining',ace_dry_humidity:'Humidity',ace_dry_temp:'Temperature (°C)',ace_dry_duration:'Duration (min)',ace_dry_start:'▶ Start',ace_dry_stop:'■ Stop', 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', @@ -2590,6 +2677,7 @@ function applyLang(){ 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-ace-dry',T.card_ace_dry); setText('d-card-speed',T.card_speed); setText('d-card-cam',T.card_cam); setText('d-card-ams',T.panel_ams_title); @@ -2647,6 +2735,11 @@ function applyLang(){ // AMS feed/unload document.querySelectorAll('.lbl-feed').forEach(e=>e.textContent=T.lbl_feed); document.querySelectorAll('.lbl-unload').forEach(e=>e.textContent=T.lbl_unload); + setText('ace-dry-start',T.ace_dry_start); + setText('ace-dry-stop',T.ace_dry_stop); + setText('d-ace-dry-humidity-label',(T.ace_dry_humidity||'Humidity')+':'); + var adTemp=document.getElementById('ace-dry-temp');if(adTemp)adTemp.setAttribute('placeholder',T.ace_dry_temp); + var adDur=document.getElementById('ace-dry-duration');if(adDur)adDur.setAttribute('placeholder',T.ace_dry_duration); // conn-btn text (nur wenn nicht im Übergangszustand) updateConnBtn(); // Slot-Edit-Dialog @@ -2885,6 +2978,31 @@ function applyState(){ amsTitle.textContent=modeTxt?(baseTitle+' - '+modeTxt):baseTitle; } + var acePresent=((s.ace_units||[]).length>0) + ||(s.filament_mode&&s.filament_mode!=='toolhead') + ||((s.ams_slots||[]).some(function(sl){return (sl.box_id||-1)>=0;})); + var aceCard=document.getElementById('d-ace-dry-card'); + if(aceCard)aceCard.style.display=acePresent?'':'none'; + var dry=s.ace_drying||{status:0,target_temp:0,duration:0,remain_time:0,humidity:null}; + var dryStatus=document.getElementById('d-ace-dry-status'); + if(dryStatus){ + if(dry.status){ + var rem=(dry.remain_time||0); + dryStatus.textContent=(T.ace_dry_status_on||'Status: Active')+(rem>0?(' - '+(T.ace_dry_status_remaining||'Remaining')+': '+rem+' min'):''); + }else{ + dryStatus.textContent=T.ace_dry_status_off||'Status: Off'; + } + } + var dryHumidity=document.getElementById('d-ace-dry-humidity'); + if(dryHumidity){ + var hv=(dry.humidity===null||dry.humidity===undefined||dry.humidity==='')?null:Number(dry.humidity); + dryHumidity.textContent=(hv===null||Number.isNaN(hv))?'-':(Math.round(hv)+'%'); + } + var dryTemp=document.getElementById('ace-dry-temp'); + if(dryTemp&&dry.target_temp>0&&String(dryTemp.value||'')==='55')dryTemp.value=dry.target_temp; + var dryDur=document.getElementById('ace-dry-duration'); + if(dryDur&&dry.duration>0&&String(dryDur.value||'')==='240')dryDur.value=dry.duration; + // AMS if(s.ams_slots&&s.ams_slots.length){ window._amsSlots=s.ams_slots; @@ -3337,6 +3455,32 @@ function amsFeed(type,slotIndex){ .catch(function(e){clog('AMS-Fehler: '+e,'msg-err');throw e;}); } +function aceDryStart(){ + var t=parseInt(document.getElementById('ace-dry-temp').value||55); + var d=parseInt(document.getElementById('ace-dry-duration').value||240); + t=Math.max(30,Math.min(80,t)); + d=Math.max(10,Math.min(1440,d)); + return post('/api/ace/dry',{action:'start',target_temp:t,duration:d}) + .then(function(r){return r.json();}) + .then(function(r){ + if(r.error){throw new Error(r.error);} + clog((T.card_ace_dry||'ACE Drying')+': '+(T.ace_dry_start||'start')+' ('+t+'°C, '+d+' min)','msg-ok'); + poll(); + }) + .catch(function(e){clog('ACE-Fehler: '+e,'msg-err');}); +} + +function aceDryStop(){ + return post('/api/ace/dry',{action:'stop'}) + .then(function(r){return r.json();}) + .then(function(r){ + if(r.error){throw new Error(r.error);} + clog((T.card_ace_dry||'ACE Drying')+': '+(T.ace_dry_stop||'stop'),'msg-ok'); + poll(); + }) + .catch(function(e){clog('ACE-Fehler: '+e,'msg-err');}); +} + // ── Camera ── function camStart(){ var img=document.getElementById('cam-img'); @@ -4107,26 +4251,103 @@ function loadPrinterTab(){ await loop.run_in_executor(None, _send) return web.json_response({"result": "ok"}) + async def handle_api_ace_dry(self, request): + try: + body = await request.json() + except Exception: + body = {} + + action = str(body.get("action", "start")).lower() + if action not in ("start", "stop"): + return web.json_response({"error": "action must be 'start' or 'stop'"}, status=400) + + ace_ids = [i for i in self._ace_box_ids if 0 <= i <= 3] + if not ace_ids: + ace_ids = sorted({ + int(s.get("box_id", -1)) + for s in self._ams_slots + if 0 <= int(s.get("box_id", -1)) <= 3 + }) + if not ace_ids and self._state.get("filament_mode") != "toolhead": + ace_ids = [0] + if not ace_ids: + return web.json_response({"error": "ACE not detected"}, status=400) + + if action == "start": + target_temp = int(body.get("target_temp", 45)) + duration = int(body.get("duration", 240)) + target_temp = max(30, min(80, target_temp)) + duration = max(10, min(24 * 60, duration)) + humidity = (self._state.get("ace_drying") or {}).get("humidity") + drying_status = { + "status": 1, + "target_temp": target_temp, + "duration": duration, + "remain_time": duration, + } + ui_state = { + "status": 1, + "target_temp": target_temp, + "duration": duration, + "remain_time": duration, + "humidity": humidity, + } + else: + drying_status = {"status": 0} + humidity = (self._state.get("ace_drying") or {}).get("humidity") + ui_state = { + "status": 0, + "target_temp": 0, + "duration": 0, + "remain_time": 0, + "humidity": humidity, + } + + payload = { + "multi_color_box": [ + {"id": bid, "drying_status": dict(drying_status)} + for bid in ace_ids + ] + } + + loop = asyncio.get_event_loop() + + def _send(): + return self.client.publish("multiColorBox", "setDry", payload, timeout=5) + + resp = await loop.run_in_executor(None, _send) + if resp is None: + return web.json_response({"error": "No response from printer"}, status=504) + if int(resp.get("code", 200)) != 200: + return web.json_response({"error": f"Printer rejected command: {resp}"}, status=502) + + self._state["ace_drying"] = ui_state + self._state_dirty = True + return web.json_response({"result": "ok"}) + async def handle_api_axis(self, request): try: body = await request.json() except Exception: body = {} - action = body.get("action", "move") + loop = asyncio.get_event_loop() - if action == "turnOff": + action = str(body.get("action", "")).lower() + + if action == "turnoff": await loop.run_in_executor(None, lambda: self.client.publish( "axis", "turnOff", None, timeout=0 )) else: - axis = int(body.get("axis", 4)) + axis = int(body.get("axis", 4)) move_type = int(body.get("move_type", 2)) - distance = float(body.get("distance", 0)) + distance = float(body.get("distance", 0)) await loop.run_in_executor(None, lambda: self.client.publish( "axis", "move", {"axis": axis, "move_type": move_type, "distance": distance}, timeout=0 )) + return web.json_response({"result": "ok"}) async def handle_api_temperature(self, request): @@ -4337,6 +4558,8 @@ function loadPrinterTab(){ "ams_slots": self._ams_slots, "ams_loaded_slot": self._ams_loaded_slot, "filament_mode": s.get("filament_mode", self._filament_mode), + "ace_drying": s.get("ace_drying", {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None}), + "ace_units": list(self._ace_box_ids), "thumbnail": self._thumbnail_b64, "connection_error": s["connection_error"], "file_ready": s["file_ready"], @@ -4382,6 +4605,7 @@ function loadPrinterTab(){ self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) boxes = data.get("multi_color_box") or [] if boxes: + self._update_ace_drying_state(data, boxes) self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) self._state["filament_mode"] = self._filament_mode global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode) @@ -4971,6 +5195,7 @@ function loadPrinterTab(){ self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model)) boxes = data.get("multi_color_box") or [] if boxes: + self._update_ace_drying_state(data, boxes) self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model) self._state["filament_mode"] = self._filament_mode global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode) @@ -5059,6 +5284,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_post("/api/speed", bridge.handle_api_speed) r.add_post("/api/ams/feed", bridge.handle_api_ams_feed) r.add_post("/api/ams/set_slot", bridge.handle_api_ams_set_slot) + r.add_post("/api/ace/dry", bridge.handle_api_ace_dry) r.add_post("/api/axis", bridge.handle_api_axis) r.add_post("/api/temperature", bridge.handle_api_temperature) r.add_get("/api/camera", bridge.handle_api_camera)