From 79bf5e77db949222952803bd28ec44342deaa084 Mon Sep 17 00:00:00 2001 From: Pavulon87 Date: Mon, 20 Jul 2026 16:24:14 +0200 Subject: [PATCH] lots of my fixes and features --- config_loader.py | 3 + env_loader.py | 1 + kobrax_moonraker_bridge.py | 180 ++++++++++++++++++++++++++++++++-- web/themes/default/app.js | 99 +++++++++++++++---- web/themes/default/index.html | 4 + web/translations/de.json | 1 + web/translations/en.json | 1 + web/translations/es.json | 1 + web/translations/fr.json | 1 + web/translations/it.json | 1 + web/translations/zh-cn.json | 1 + 11 files changed, 267 insertions(+), 26 deletions(-) diff --git a/config_loader.py b/config_loader.py index 459942e..2682928 100644 --- a/config_loader.py +++ b/config_loader.py @@ -62,6 +62,7 @@ CONFIG_ENV_MAPPING = { "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), "VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"), + "FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"), "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), "PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"), @@ -126,6 +127,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), "vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"), + "flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"), "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), "web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), } @@ -451,6 +453,7 @@ DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) diff --git a/env_loader.py b/env_loader.py index 97f37af..95df569 100644 --- a/env_loader.py +++ b/env_loader.py @@ -49,6 +49,7 @@ DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") AUTO_LEVELING = int(get("AUTO_LEVELING", "1")) VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0")) +FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0")) WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1"))) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 8d3da7b..5754590 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1180,8 +1180,10 @@ class KobraXBridge: def _on_print(self, payload: dict): 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["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing") + if kobra_state != "" and kobra_state != "updated": self._state["kobra_state"] = kobra_state # Automatically switch on the camera at print start (settings option). @@ -1279,7 +1281,8 @@ class KobraXBridge: kobra_state = proj_state or d.get("state", "") if kobra_state: self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby") - self._state["kobra_state"] = kobra_state + if kobra_state != "" and kobra_state != "updated": + self._state["kobra_state"] = kobra_state # Hide the upload banner after the print ends (Issue #29) - the state also # arrives via info/report (project.state) depending on the printer, not only print/report. if kobra_state in ("finished", "stoped", "canceled"): @@ -1591,7 +1594,41 @@ class KobraXBridge: return [int(color[0]), int(color[1]), int(color[2]), 255] return [255, 255, 255, 255] - def _build_auto_ams_box_mapping( + @staticmethod + def _hex_to_rgba(hex_color: str | None) -> list[int]: + """Convert a '#RRGGBB' (or 'RRGGBB') string from GCode metadata to [r,g,b,255].""" + if not hex_color: + return [255, 255, 255, 255] + h = str(hex_color).strip().lstrip("#") + if len(h) < 6: + return [255, 255, 255, 255] + try: + return [int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255] + except ValueError: + return [255, 255, 255, 255] + + @staticmethod + def _gcode_filament_by_index(gcode_filaments: list[dict] | None) -> dict[int, dict]: + """Index parsed GCode filament metadata (_extract_filament_info) by paint/slot index.""" + by_index: dict[int, dict] = {} + if not gcode_filaments: + return by_index + for f in gcode_filaments: + try: + by_index[int(f.get("slot_index"))] = f + except (TypeError, ValueError): + continue + return by_index + + def _color_distance(self, rgb1: list[int], rgb2: list[int]) -> float: + """Calculate RGB Euclidean distance.""" + return ( + (rgb1[0] - rgb2[0]) ** 2 + + (rgb1[1] - rgb2[1]) ** 2 + + (rgb1[2] - rgb2[2]) ** 2 + ) ** 0.5 + + def _build_auto_ams_box_mapping_old( self, warn_on_empty_default: bool = False, loaded_slots: list[tuple[int, dict]] | None = None, @@ -1627,6 +1664,128 @@ class KobraXBridge: }) return result + + def _build_auto_ams_box_mapping( + self, + warn_on_empty_default: bool = False, + loaded_slots: list[tuple[int, dict]] | None = None, + gcode_filaments: list[dict] | None = None, + ) -> list[dict]: + """Build print mapping by matching G-code filament colors + with currently loaded AMS slots using RGB distance. + """ + + # IMPORTANT: loaded_slots is intentionally ignored whenever gcode_filaments + # is provided. Color matching needs access to ALL currently loaded slots, + # not just the ones whose index happens to coincide with the gcode's + # paint_index - otherwise a black filament physically loaded in slot 3 + # could never be matched to gcode paint_index 1 just because slot 1 + # holds a different color. loaded_slots is only used as a fallback pool + # when the caller has no gcode filament info to match colors against + # (see _build_auto_ams_box_mapping_old for that plain, index-based case). + loaded = loaded_slots if ( gcode_filaments is None or len(gcode_filaments) == 0 ) else None + if loaded is None: + loaded = self._select_loaded_slots_for_print( warn_on_empty_default=warn_on_empty_default ) + + if not loaded: + return [] + + loaded_map = {gidx: s for gidx, s in loaded} + log.debug(f"Loaded map: {loaded_map}") + + gcode_by_index = self._gcode_filament_by_index(gcode_filaments) + log.debug(f"GCode filaments by index: {gcode_by_index}") + + # Slots available for assignment. + available_slots = list(loaded_map.items()) + + result = [] + + max_idx = max(gcode_by_index.keys(), default=-1) + + # Maximum RGB distance: + # sqrt(255² + 255² + 255²) = 441 + # A threshold of 80–100 is reasonable for visually similar colors. + MAX_COLOR_DISTANCE = 100 + + for paint_index in range(max_idx + 1): + req = gcode_by_index.get(paint_index) + + paint_color = ( + self._hex_to_rgba(req.get("color_hex")) + if req else + [255, 255, 255, 255] + ) + + default_material = ( + req.get("material", "PLA") + if req else + "PLA" + ) + + best_match = None + best_distance = float("inf") + + if req: + requested_rgb = paint_color[:3] + + for slot_idx, slot in available_slots: + slot_color = slot.get("color") + + if not slot_color or len(slot_color) < 3: + continue + + # Skip slots with a different material type. + if slot.get("type") != default_material: + continue + + distance = self._color_distance( + requested_rgb, + slot_color[:3], + ) + + if distance < best_distance: + best_distance = distance + best_match = (slot_idx, slot) + + if best_match and best_distance <= MAX_COLOR_DISTANCE: + slot_idx, slot = best_match + + available_slots.remove(best_match) + + log.debug( + f"Matched paint index {paint_index}: " + f"GCODE={paint_color[:3]} " + f"AMS={slot.get('color')} " + f"distance={best_distance:.2f} " + f"slot={slot_idx}" + ) + + result.append({ + "paint_index": paint_index, + "ams_index": self._slot_to_print_ams_index(slot_idx), + "paint_color": paint_color, + "ams_color": self._slot_color_rgba(slot), + "material_type": slot.get("type", default_material), + }) + + else: + log.debug( + f"No AMS match for paint index {paint_index}: " + f"color={paint_color[:3]} " + f"best_distance={best_distance:.2f}" + ) + + result.append({ + "paint_index": paint_index, + "ams_index": self._slot_to_print_ams_index(paint_index), + "paint_color": paint_color, + "ams_color": [255, 255, 255, 255], + "material_type": default_material, + }) + + return result + def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]: """Build print mapping from UI filament assignments. @@ -2958,7 +3117,7 @@ class KobraXBridge: return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400) else: # No dialog -> all occupied slots as with a normal upload print - ams_box_mapping = self._build_auto_ams_box_mapping() + ams_box_mapping = self._build_auto_ams_box_mapping_old() auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1))) filename = gcode_file["filename"] @@ -3453,7 +3612,7 @@ class KobraXBridge: "task_settings": { "auto_leveling": auto_leveling, "vibration_compensation": getattr(self._args, "vibration_compensation", 0), - "flow_calibration": 0, + "flow_calibration": getattr(self._args, "flow_calibration", 0), "dry_mode": 0, "ai_settings": {"status": 0, "count": 0, "type": ai_type}, "timelapse": {"status": 0, "count": 0, "type": timelapse_type}, @@ -3496,7 +3655,12 @@ class KobraXBridge: # used slots; used-but-unloaded -> a warning may follow later. loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices] - ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded) + used_gcode_filaments = [ + f for f in (gcode_filaments or []) + if used_paint_indices is not None and f.get("slot_index") in used_paint_indices + ] + + ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded, gcode_filaments=used_gcode_filaments) log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}") payload = self._build_print_payload( filename, url, md5, filesize, @@ -4268,6 +4432,7 @@ class KobraXBridge: "print_speed_mode": s["print_speed_mode"], "auto_leveling": getattr(self._args, "auto_leveling", 1), "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "flow_calibration": getattr(self._args, "flow_calibration", 0), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), "light_on": s["light_on"], @@ -4420,6 +4585,7 @@ class KobraXBridge: "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), "auto_leveling": getattr(self._args, "auto_leveling", 1), "vibration_compensation": getattr(self._args, "vibration_compensation", 0), + "flow_calibration": getattr(self._args, "flow_calibration", 0), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), "print_start_dialog": getattr(self._args, "print_start_dialog", 1), @@ -4458,6 +4624,7 @@ class KobraXBridge: 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", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0)))))) + cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 0)))))) cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)))))) @@ -5602,6 +5769,7 @@ def main(): 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("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION) + parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION) parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING) parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG) diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 7d11be4..30efec0 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -437,6 +437,7 @@ function applyLang(){ setText('opt-slot-auto',T.settings_slot_auto); setText('lbl-auto-leveling',T.settings_auto_leveling); setText('lbl-vibration-compensation',T.settings_vibration_compensation); + setText('lbl-flow-calibration',T.settings_flow_calibration); setText('lbl-file-ready-mode',T.settings_file_ready_mode); setText('opt-file-ready-dialog',T.settings_file_ready_dialog); setText('opt-file-ready-banner',T.settings_file_ready_banner); @@ -1091,6 +1092,7 @@ function openSettings(){ document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation; + var vc=document.getElementById('s-flow-calibration');if(vc)vc.checked=!!d.flow_calibration; var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; var frm=document.getElementById('s-file-ready-mode');if(frm)frm.value=(d.print_start_dialog===undefined?'1':String(d.print_start_dialog?1:0)); var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); @@ -1859,6 +1861,7 @@ function saveSettings(){ default_ams_slot: document.getElementById('s-default-slot').value, auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0, + flow_calibration: (document.getElementById('s-flow-calibration')||{}).checked?1:0, camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10), web_upload_warning:webUploadWarning, @@ -2915,29 +2918,85 @@ function openFilamentDialog(slots){ var dr=ar[0]-br[0], dg=ar[1]-br[1], db=ar[2]-br[2]; return (dr*dr + dg*dg + db*db); } - var defaultSlotByPaint={}; - var usedDefaultSlot={}; - channels.forEach(function(gc,i){ + + // Globale, farboptimale Zuordnung Kanal -> Slot. + // Anforderungen: 1) Material muss passen (harter Filter, wie bisher) + // 2) Farbe so nah wie möglich + // 3) Position spielt praktisch keine Rolle mehr + // Ein rein gieriger Ansatz (Kanal für Kanal den "nächsten" Slot wählen) + // kann bei knappen/mehrdeutigen Slots suboptimale Gesamtlösungen liefern + // (z.B. Kanal A "verbraucht" den Slot, der eigentlich viel besser zu + // Kanal B gepasst hätte). Da ein AMS in der Praxis nur wenige Slots hat + // (üblich: bis zu 4), lohnt sich Brute-Force über alle möglichen + // Zuordnungen mit Pruning - liefert garantiert das global beste Ergebnis, + // ganz ohne einen vollen Hungarian-Algorithmus implementieren zu müssen. + function _bestAssignment(slotsPerChannel){ + var n=slotsPerChannel.length; + var assign=new Array(n).fill(-1); + + // Sicherheitsnetz für unrealistisch viele Kanäle: gieriger Fallback + // (kleinster Farbabstand zuerst bedient), um exponentielle Laufzeit zu + // vermeiden. Greift in der Praxis nie, ein AMS hat max. wenige Slots. + if(n>8){ + var used={}; + var order=slotsPerChannel.map(function(_,idx){return idx;}).sort(function(a,b){ + var da=slotsPerChannel[a].length?slotsPerChannel[a][0].dist:Infinity; + var db=slotsPerChannel[b].length?slotsPerChannel[b][0].dist:Infinity; + return da-db; + }); + order.forEach(function(i){ + var cands=slotsPerChannel[i]; + if(!cands.length)return; + var pick=cands.find(function(c){return !used[c.slot_index];})||cands[0]; + assign[i]=pick.slot_index; + used[pick.slot_index]=true; + }); + return assign; + } + + var bestCost=Infinity, bestAssign=assign.slice(), used={}; + (function backtrack(idx,cost){ + if(cost>=bestCost)return; // Pruning: kann eh nicht mehr besser werden + if(idx===n){ bestCost=cost; bestAssign=assign.slice(); return; } + var candidates=slotsPerChannel[idx]; + if(!candidates.length){ + assign[idx]=-1; + backtrack(idx+1,cost); + return; + } + var placedFree=false; + for(var k=0;k Mehrfachbelegung des + // farblich besten kompatiblen Slots zulassen (besser als leer) + var best=candidates[0]; + assign[idx]=best.slot_index; + backtrack(idx+1,cost+best.dist); + } + assign[idx]=-1; + })(0,0); + return bestAssign; + } + + var slotsPerChannel=channels.map(function(gc){ var compatible=_amsSlots.filter(function(s){ return _materialsCompatible(gc.material, s.material); }); - if(!compatible.length){ - defaultSlotByPaint[i]=-1; - return; - } - - var ranked=compatible.slice().sort(function(a,b){ - var da=Math.abs((a.slot_index||0)-i), db=Math.abs((b.slot_index||0)-i); - if(da!==db)return da-db; - var ca=_colorDist(gc.color_hex, a.color_hex), cb=_colorDist(gc.color_hex, b.color_hex); - if(ca!==cb)return ca-cb; - return (a.slot_index||0)-(b.slot_index||0); - }); - - var chosen=ranked.find(function(s){return !usedDefaultSlot[s.slot_index];}) || ranked[0]; - defaultSlotByPaint[i]=chosen?chosen.slot_index:-1; - if(chosen) usedDefaultSlot[chosen.slot_index]=1; + return compatible.map(function(s){ + return {slot_index:s.slot_index, dist:_colorDist(gc.color_hex, s.color_hex)}; + }).sort(function(a,b){ return a.dist-b.dist; }); }); + var _assignResult=_bestAssignment(slotsPerChannel); + var defaultSlotByPaint={}; + channels.forEach(function(gc,i){ defaultSlotByPaint[i]=_assignResult[i]; }); if(!_amsSlots.length){ body.innerHTML='

'+T.fd_no_slots_msg.replace('{br}','
')+'

'; @@ -3391,4 +3450,4 @@ function loadPrinterTab(){ }).catch(function(e){ if(grid)grid.innerHTML='
Fehler: '+e+'
'; }); -} +} \ No newline at end of file diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 3df8fcd..ab1c919 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -513,6 +513,10 @@ +