From 8c4bff6b867c40d271f01aee57c05212949294d1 Mon Sep 17 00:00:00 2001 From: Gangoke Date: Sun, 17 May 2026 22:14:51 -1000 Subject: [PATCH] ACE2 Drying Card v2, Dryer Presets, --- config.ini.example | 26 ++ config/config.ini.example | 26 ++ kobrax_moonraker_bridge.py | 617 ++++++++++++++++++++++++++++++++----- 3 files changed, 585 insertions(+), 84 deletions(-) diff --git a/config.ini.example b/config.ini.example index fa1be8b..788e70c 100644 --- a/config.ini.example +++ b/config.ini.example @@ -32,3 +32,29 @@ auto_leveling = 1 [bridge] # Poll-Intervall in Sekunden poll_interval = 3 + +[ace_dry_presets] +# Vordefinierte Dry-Set Presets (Temp in °C, Dauer in Sekunden) +pla_temp = 45 +pla_duration_sec = 14400 +pla_plus_temp = 45 +pla_plus_duration_sec = 14400 +petg_temp = 50 +petg_duration_sec = 14400 +tpu_temp = 55 +tpu_duration_sec = 14400 +abs_asa_temp = 45 +abs_asa_duration_sec = 28800 +pa_pc_temp = 55 +pa_pc_duration_sec = 43200 + +# Custom Presets (Name + Temp + Dauer) +custom_1_name = Custom 1 +custom_1_temp = 45 +custom_1_duration_sec = 14400 +custom_2_name = Custom 2 +custom_2_temp = 45 +custom_2_duration_sec = 14400 +custom_3_name = Custom 3 +custom_3_temp = 45 +custom_3_duration_sec = 14400 diff --git a/config/config.ini.example b/config/config.ini.example index b954b90..f19e6fd 100644 --- a/config/config.ini.example +++ b/config/config.ini.example @@ -34,3 +34,29 @@ auto_leveling = 1 [bridge] # Poll-Intervall in Sekunden poll_interval = 3 + +[ace_dry_presets] +# Vordefinierte Dry-Set Presets (Temp in °C, Dauer in Sekunden) +pla_temp = 45 +pla_duration_sec = 14400 +pla_plus_temp = 45 +pla_plus_duration_sec = 14400 +petg_temp = 50 +petg_duration_sec = 14400 +tpu_temp = 55 +tpu_duration_sec = 14400 +abs_asa_temp = 45 +abs_asa_duration_sec = 28800 +pa_pc_temp = 55 +pa_pc_duration_sec = 43200 + +# Custom Presets (Name + Temp + Dauer) +custom_1_name = Custom 1 +custom_1_temp = 45 +custom_1_duration_sec = 14400 +custom_2_name = Custom 2 +custom_2_temp = 45 +custom_2_duration_sec = 14400 +custom_3_name = Custom 3 +custom_3_temp = 45 +custom_3_duration_sec = 14400 diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 960a9a7..fb2c09d 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -422,32 +422,32 @@ class KobraXBridge: self.ws_clients: set[web.WebSocketResponse] = set() self._last_state: dict = {} self._state = { - "nozzle_temp": 0.0, - "nozzle_target": 0.0, - "bed_temp": 0.0, - "bed_target": 0.0, - "print_state": "standby", - "kobra_state": "free", - "filename": "", - "slicer_time": 0, - "progress": 0.0, - "print_duration": 0, - "remain_time": 0, - "curr_layer": 0, - "total_layers": 0, - "printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"), - "firmware_version": "unknown", - "upload_url": "", - "camera_url": "", - "fan_speed": 0, - "light_on": False, - "light_brightness": 80, - "taskid": "-1", - "print_speed_mode": 2, - "connection_error": "", - "file_ready": "", - "filament_mode": "toolhead", - "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None}, + "nozzle_temp": 0.0, + "nozzle_target": 0.0, + "bed_temp": 0.0, + "bed_target": 0.0, + "print_state": "standby", + "kobra_state": "free", + "filename": "", + "slicer_time": 0, + "progress": 0.0, + "print_duration": 0, + "remain_time": 0, + "curr_layer": 0, + "total_layers": 0, + "printer_name": env_loader.get("BRIDGE_PRINTER_NAME", "Anycubic Kobra X"), + "firmware_version": "unknown", + "upload_url": "", + "camera_url": "", + "fan_speed": 0, + "light_on": False, + "light_brightness": 80, + "taskid": "-1", + "print_speed_mode": 2, + "connection_error": "", + "file_ready": "", + "filament_mode": "toolhead", + "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": 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 @@ -461,6 +461,7 @@ class KobraXBridge: self._current_job_id: str = "" self._thumbnail_b64: str = "" + self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config() # Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10) self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0} @@ -474,6 +475,73 @@ class KobraXBridge: client.callbacks["light/report"] = self._on_light client.callbacks["skip/report"] = self._on_skip + def _default_ace_dry_presets(self) -> dict[str, dict]: + return { + "pla": {"temp": 45, "duration_sec": 4 * 3600}, + "pla_plus": {"temp": 45, "duration_sec": 4 * 3600}, + "petg": {"temp": 50, "duration_sec": 4 * 3600}, + "tpu": {"temp": 55, "duration_sec": 4 * 3600}, + "abs_asa": {"temp": 45, "duration_sec": 8 * 3600}, + "pa_pc": {"temp": 55, "duration_sec": 12 * 3600}, + "custom_1": {"name": "Custom 1", "temp": 45, "duration_sec": 4 * 3600}, + "custom_2": {"name": "Custom 2", "temp": 45, "duration_sec": 4 * 3600}, + "custom_3": {"name": "Custom 3", "temp": 45, "duration_sec": 4 * 3600}, + } + + def _sanitize_ace_dry_presets(self, presets: dict) -> dict[str, dict]: + out = self._default_ace_dry_presets() + for key in list(out.keys()): + src = presets.get(key) if isinstance(presets, dict) else None + if not isinstance(src, dict): + continue + try: + t = int(src.get("temp", out[key]["temp"])) + except Exception: + t = out[key]["temp"] + try: + d = int(src.get("duration_sec", out[key]["duration_sec"])) + except Exception: + d = out[key]["duration_sec"] + out[key]["temp"] = max(30, min(80, t)) + out[key]["duration_sec"] = max(10 * 60, min(24 * 3600, d)) + if key.startswith("custom_"): + name = str(src.get("name", out[key].get("name", key.replace("_", " ").title()))).strip() + out[key]["name"] = name or out[key].get("name", "Custom") + return out + + def _load_ace_dry_presets_config(self) -> dict[str, dict]: + import configparser + defaults = self._default_ace_dry_presets() + cfg_path = self._find_config_path() + if not cfg_path.is_file(): + return defaults + cfg = configparser.ConfigParser() + cfg.read(cfg_path, encoding="utf-8") + sec = "ace_dry_presets" + if not cfg.has_section(sec): + return defaults + out = {} + for key, d in defaults.items(): + temp_k = f"{key}_temp" + dur_k = f"{key}_duration_sec" + try: + temp = int(cfg.get(sec, temp_k, fallback=str(d["temp"]))) + except Exception: + temp = d["temp"] + try: + dur = int(cfg.get(sec, dur_k, fallback=str(d["duration_sec"]))) + except Exception: + dur = d["duration_sec"] + out[key] = { + "temp": max(30, min(80, temp)), + "duration_sec": max(10 * 60, min(24 * 3600, dur)), + } + if key.startswith("custom_"): + name_k = f"{key}_name" + name = cfg.get(sec, name_k, fallback=str(d.get("name", key.replace("_", " ").title()))).strip() + out[key]["name"] = name or str(d.get("name", "Custom")) + return out + # ------------------------------------------------------------------------- # MQTT callbacks (called from reader thread) # ------------------------------------------------------------------------- @@ -804,6 +872,17 @@ class KobraXBridge: def _current_temp_from(src: dict, default=None): return _num_from(src, ("current_temp", "cur_temp", "temperature", "temp", "drying_temp", "chamber_temp"), default) + def _minutes_from(src: dict, key: str, default=0): + raw = src.get(key, default) + try: + value = int(float(raw)) + except Exception: + return int(default) + # Some firmware payloads report dryer times in seconds while the UI uses minutes. + if value > (24 * 60): + return max(0, int(round(value / 60.0))) + return max(0, value) + per_unit: list[dict] = [] for box in boxes: bid = int(box.get("id", -1)) @@ -820,8 +899,8 @@ class KobraXBridge: "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)), + "duration": _minutes_from(bs, "duration", 0), + "remain_time": _minutes_from(bs, "remain_time", 0), "humidity": hu, "current_temp": ct, }) @@ -843,8 +922,8 @@ class KobraXBridge: 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))), + "duration": _minutes_from(src, "duration", cur.get("duration", 0)), + "remain_time": _minutes_from(src, "remain_time", cur.get("remain_time", 0)), "humidity": _humidity_from(src, primary.get("humidity", cur.get("humidity"))), "current_temp": _current_temp_from(src, primary.get("current_temp", cur.get("current_temp"))), "units": per_unit, @@ -2438,12 +2517,82 @@ 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',ace_units:[],ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,current_temp:null,units:[]}}; + ams_slots:[],filament_mode:'toolhead',ace_units:[],ace_dry_presets:null,ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,current_temp:null,units:[]}}; var tempHistory={n:[],b:[]}; -var aceDryHistory={0:{t:[],h:[]},1:{t:[],h:[]},2:{t:[],h:[]},3:{t:[],h:[]}}; var camOn=false; var currentStep=1; var currentPanel='dashboard'; +var aceAutoRefillPrefs=(function(){ + try{return JSON.parse(localStorage.getItem('aceAutoRefillPrefs')||'{}')||{};}catch(_){return {};} +})(); +var aceDryProfiles=(function(){ + try{return JSON.parse(localStorage.getItem('aceDryProfiles')||'{}')||{};}catch(_){return {};} +})(); +var _aceDryDialogAceId=-1; +var _aceDryDialogPresetKey=''; +var _aceDryDialogPresetOriginals={}; +var ACE_DRY_PRESET_DEFAULTS={ + pla:{temp:45,duration_sec:4*3600}, + pla_plus:{temp:45,duration_sec:4*3600}, + petg:{temp:50,duration_sec:4*3600}, + tpu:{temp:55,duration_sec:4*3600}, + abs_asa:{temp:45,duration_sec:8*3600}, + pa_pc:{temp:55,duration_sec:12*3600} +}; +var ACE_DRY_PRESETS={ + pla:{temp:45,duration_sec:4*3600}, + pla_plus:{temp:45,duration_sec:4*3600}, + petg:{temp:50,duration_sec:4*3600}, + tpu:{temp:55,duration_sec:4*3600}, + abs_asa:{temp:45,duration_sec:8*3600}, + pa_pc:{temp:55,duration_sec:12*3600}, + custom_1:{name:'Custom 1',temp:45,duration_sec:4*3600}, + custom_2:{name:'Custom 2',temp:45,duration_sec:4*3600}, + custom_3:{name:'Custom 3',temp:45,duration_sec:4*3600} +}; + +function _aceAutoRefillGet(aceId){return !!aceAutoRefillPrefs[String(aceId)];} +function _aceAutoRefillSet(aceId,on){ + aceAutoRefillPrefs[String(aceId)]=!!on; + localStorage.setItem('aceAutoRefillPrefs',JSON.stringify(aceAutoRefillPrefs)); +} +function _aceDryProfileGet(aceId){ + var p=aceDryProfiles[String(aceId)]||{}; + var temp=parseInt(p.temp,10); + var dur=parseInt(p.duration_sec,10); + if(!Number.isFinite(temp))temp=45; + if(!Number.isFinite(dur))dur=4*3600; + temp=Math.max(30,Math.min(80,temp)); + dur=Math.max(10*60,Math.min(24*3600,dur)); + return {temp:temp,duration_sec:dur,preset:p.preset||''}; +} +function _aceDryProfileSet(aceId,temp,durationSec,preset){ + aceDryProfiles[String(aceId)]={ + temp:Math.max(30,Math.min(80,parseInt(temp,10)||45)), + duration_sec:Math.max(10*60,Math.min(24*3600,parseInt(durationSec,10)||4*3600)), + preset:preset||'' + }; + localStorage.setItem('aceDryProfiles',JSON.stringify(aceDryProfiles)); +} +function _aceDryDurationMinFromSec(sec){ + var minutes=Math.round((parseInt(sec,10)||0)/60); + return Math.max(10,Math.min(1440,minutes)); +} +function _syncAceDryPresetsFromServer(raw){ + if(!raw||typeof raw!=='object')return; + Object.keys(ACE_DRY_PRESETS).forEach(function(k){ + var p=raw[k]; + if(!p||typeof p!=='object')return; + var t=parseInt(p.temp,10); + var d=parseInt(p.duration_sec,10); + if(Number.isFinite(t))ACE_DRY_PRESETS[k].temp=Math.max(30,Math.min(80,t)); + if(Number.isFinite(d))ACE_DRY_PRESETS[k].duration_sec=Math.max(10*60,Math.min(24*3600,d)); + if(/^custom_[123]$/.test(k)&&typeof p.name==='string'){ + var n=p.name.trim(); + ACE_DRY_PRESETS[k].name=n||('Custom '+k.slice(-1)); + } + }); +} // ── Theme ── function toggleTheme(){ @@ -2461,7 +2610,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:'Aktuelle 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', + 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', 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', @@ -2524,7 +2673,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:'Current Temp',ace_dry_chart:'History (Temp/Humidity)',ace_dry_temp:'Temperature (°C)',ace_dry_duration:'Duration (min)',ace_dry_start:'▶ Start',ace_dry_stop:'■ Stop', + 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', 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', @@ -2739,14 +2888,25 @@ function applyLang(){ document.querySelectorAll('.lbl-unload').forEach(e=>e.textContent=T.lbl_unload); for(var i=0;i<4;i++){ setText('d-card-ace-dry-'+i,'ACE '+(i+1)+' - '+(T.ace_dry_dryer||'Dryer')); - setText('ace-dry-start-'+i,T.ace_dry_start); - setText('ace-dry-stop-'+i,T.ace_dry_stop); + setText('d-ace-auto-refill-label-'+i,T.ace_dry_auto_refill||'Auto Refill'); + setText('d-ace-drying-enable-label-'+i,T.ace_dry_enable||'Enable Drying'); setText('d-ace-dry-humidity-label-'+i,(T.ace_dry_humidity||'Humidity')+':'); setText('d-ace-dry-current-temp-label-'+i,(T.ace_dry_current_temp||'Current Temp')+':'); + setText('d-ace-dry-target-label-'+i,(T.ace_dry_temp_line||'Drying Temperature')+':'); + setText('d-ace-dry-time-label-'+i,(T.ace_dry_time_line||'Drying Time')+':'); setText('d-ace-dry-chart-label-'+i,T.ace_dry_chart||'History (Temp/Humidity)'); var adTemp=document.getElementById('ace-dry-temp-'+i);if(adTemp)adTemp.setAttribute('placeholder',T.ace_dry_temp); var adDur=document.getElementById('ace-dry-duration-'+i);if(adDur)adDur.setAttribute('placeholder',T.ace_dry_duration); } + setText('ace-dry-dialog-title',T.ace_dry_dialog_title||'Dryer Temp/Time Settings'); + setText('ace-dry-dialog-temp-label',T.ace_dry_dialog_temp||'Temperature (30-80°C)'); + setText('ace-dry-dialog-time-label',T.ace_dry_dialog_time||'Rem. Time (h:m:s)'); + setText('ace-dry-dialog-custom-name-label',T.ace_dry_dialog_custom_name||'Custom Name'); + setText('ace-dry-dialog-cancel',T.ace_dry_dialog_cancel||'Cancel'); + setText('ace-dry-dialog-confirm',T.ace_dry_dialog_confirm||'Confirm'); + setText('ace-dry-dialog-reset-default',T.ace_dry_dialog_reset_default||'Reset to Default'); + setText('ace-dry-dialog-save-preset',T.ace_dry_dialog_save_restart||'Save & Restart'); + aceDryDialogSyncCustomButtonNames(); // conn-btn text (nur wenn nicht im Übergangszustand) updateConnBtn(); // Slot-Edit-Dialog @@ -2770,21 +2930,35 @@ function ensureAceDryCards(){ for(var i=0;i<4;i++){ html+=' + + + @@ -4649,6 +5087,7 @@ function loadPrinterTab(){ "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, "current_temp": None}), "ace_units": list(self._ace_box_ids), + "ace_dry_presets": self._ace_dry_presets, "thumbnail": self._thumbnail_b64, "connection_error": s["connection_error"], "file_ready": s["file_ready"], @@ -4731,6 +5170,7 @@ function loadPrinterTab(){ "device_id": self._args.device_id, "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), "auto_leveling": getattr(self._args, "auto_leveling", 1), + "ace_dry_presets": self._ace_dry_presets, }) async def handle_api_settings_post(self, request): @@ -4745,7 +5185,7 @@ function loadPrinterTab(){ cfg.read(config_path, encoding="utf-8") # Sections sicherstellen - for section in ("connection", "print", "bridge"): + for section in ("connection", "print", "bridge", "ace_dry_presets"): if not cfg.has_section(section): cfg.add_section(section) @@ -4766,6 +5206,15 @@ function loadPrinterTab(){ elif cfg.has_option("bridge", "printer_name"): cfg.remove_option("bridge", "printer_name") + incoming_presets = data.get("ace_dry_presets") if isinstance(data, dict) else None + presets = self._sanitize_ace_dry_presets(incoming_presets if isinstance(incoming_presets, dict) else self._ace_dry_presets) + for key, val in presets.items(): + cfg.set("ace_dry_presets", f"{key}_temp", str(val["temp"])) + cfg.set("ace_dry_presets", f"{key}_duration_sec", str(val["duration_sec"])) + if key.startswith("custom_"): + cfg.set("ace_dry_presets", f"{key}_name", str(val.get("name", key.replace("_", " ").title()))) + self._ace_dry_presets = presets + with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n\n") cfg.write(f)