From 566018e71c831fef32f20343079d4b76b8266b83 Mon Sep 17 00:00:00 2001 From: fenopy Date: Wed, 3 Jun 2026 07:11:49 -0500 Subject: [PATCH 1/5] feat: add "start" notification --- kobrax_moonraker_bridge.py | 6 ++++-- web/themes/default/app.js | 4 ++-- web/translations/de.json | 5 +++-- web/translations/en.json | 5 +++-- web/translations/es.json | 5 +++-- web/translations/zh-cn.json | 5 +++-- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 8874a3c..0d0de14 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -988,7 +988,9 @@ class KobraXBridge: # Benachrichtigungen bei Statuswechsel (filename vor Cleanup holen) if kobra_state and kobra_state != self._prev_kobra_state: _notif_filename = d.get("filename", self._state.get("filename", "")) - if kobra_state == "finished": + if kobra_state == "printing": + self._notify("started", _notif_filename) + elif kobra_state == "finished": self._notify("finished", _notif_filename) elif kobra_state == "failed": self._notify("failed", _notif_filename) @@ -3841,7 +3843,7 @@ class KobraXBridge: cfg.remove_section("notifications") incoming_notifs = data.get("notifications") if isinstance(incoming_notifs, list): - valid_events = {"finished", "failed", "cancelled", "paused"} + valid_events = {"started", "finished", "failed", "cancelled", "paused"} entries = [] for entry in incoming_notifs: if not isinstance(entry, dict): diff --git a/web/themes/default/app.js b/web/themes/default/app.js index cf03d31..cc8af9b 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -312,7 +312,7 @@ function applyLang(){ setText('modal-sec-print',T.settings_print); setText('modal-sec-poll',T.settings_poll); setText('modal-sec-notifications',T.settings_notifications); - setText('notif-hint',T.settings_notifications_hint); + var nhEl=document.getElementById('notif-hint');if(nhEl && T.settings_notifications_hint)nhEl.innerHTML=T.settings_notifications_hint; setText('lbl-notif-add',T.settings_notif_add); setText('modal-sec-version',T.settings_version); // Custom-Profile-Import (Issue #41) @@ -888,7 +888,7 @@ function drawChart(id,_,series){ // ── Notifications ── var _notifRows=[]; -var _NOTIF_EVENTS=['finished','failed','cancelled','paused']; +var _NOTIF_EVENTS=['started','finished','failed','cancelled','paused']; function notifRenderList(entries){ _notifRows=entries.map(function(e){return {url:e.url||'',events:e.events||[]};}); notifRefreshDOM(); diff --git a/web/translations/de.json b/web/translations/de.json index b842725..29df2eb 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -245,7 +245,7 @@ "ss_name": "A–Z Name", "ss_dur": "⏱ Druckzeit", "settings_notifications": "Benachrichtigungen", - "settings_notifications_hint": "Benachrichtigungen über Apprise-URLs senden (discord://, telegram://, gotify://, slack://, …)", + "settings_notifications_hint": "Benachrichtigungen über Apprise-URLs senden (discord://, telegram://, gotify://, slack://, …)", "settings_notif_add": "Benachrichtigung hinzufügen", "settings_notif_empty": "Keine Benachrichtigungen konfiguriert.", "settings_notif_test": "Test", @@ -254,5 +254,6 @@ "settings_notif_ev_finished": "Fertig", "settings_notif_ev_failed": "Fehler", "settings_notif_ev_cancelled": "Abgebrochen", - "settings_notif_ev_paused": "Pausiert" + "settings_notif_ev_paused": "Pausiert", + "settings_notif_ev_started": "Gestartet" } diff --git a/web/translations/en.json b/web/translations/en.json index f00ecd6..95238d6 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -245,7 +245,7 @@ "ss_name": "A–Z Name", "ss_dur": "⏱ Print time", "settings_notifications": "Notifications", - "settings_notifications_hint": "Send notifications via Apprise URLs (discord://, telegram://, gotify://, slack://, …)", + "settings_notifications_hint": "Send notifications via Apprise URLs (discord://, telegram://, gotify://, slack://, …)", "settings_notif_add": "Add notification", "settings_notif_empty": "No notifications configured.", "settings_notif_test": "Test", @@ -254,5 +254,6 @@ "settings_notif_ev_finished": "Finished", "settings_notif_ev_failed": "Failed", "settings_notif_ev_cancelled": "Cancelled", - "settings_notif_ev_paused": "Paused" + "settings_notif_ev_paused": "Paused", + "settings_notif_ev_started": "Started" } diff --git a/web/translations/es.json b/web/translations/es.json index a455ec4..0cf5201 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -245,7 +245,7 @@ "ss_name": "A–Z Nombre", "ss_dur": "⏱ Tiempo de impresión", "settings_notifications": "Notificaciones", - "settings_notifications_hint": "Enviar notificaciones via URLs de Apprise (discord://, telegram://, gotify://, slack://, …)", + "settings_notifications_hint": "Enviar notificaciones via URLs de Apprise (discord://, telegram://, gotify://, slack://, …)", "settings_notif_add": "Añadir notificación", "settings_notif_empty": "No hay notificaciones configuradas.", "settings_notif_test": "Probar", @@ -254,5 +254,6 @@ "settings_notif_ev_finished": "Terminado", "settings_notif_ev_failed": "Error", "settings_notif_ev_cancelled": "Cancelado", - "settings_notif_ev_paused": "Pausado" + "settings_notif_ev_paused": "Pausado", + "settings_notif_ev_started": "Iniciado" } diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index f0a8cca..c6b27d6 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -245,7 +245,7 @@ "ss_name": "A–Z 名称", "ss_dur": "⏱ 打印时间", "settings_notifications": "通知", - "settings_notifications_hint": "通过 Apprise URL 发送通知 (discord://, telegram://, gotify://, slack://, …)", + "settings_notifications_hint": "通过 Apprise URL 发送通知 (discord://, telegram://, gotify://, slack://, …)", "settings_notif_add": "添加通知", "settings_notif_empty": "未配置通知。", "settings_notif_test": "测试", @@ -254,5 +254,6 @@ "settings_notif_ev_finished": "完成", "settings_notif_ev_failed": "失败", "settings_notif_ev_cancelled": "已取消", - "settings_notif_ev_paused": "已暂停" + "settings_notif_ev_paused": "已暂停", + "settings_notif_ev_started": "开始打印" } From cedab6d3b2c008f0602236dd8f737c3d31624f12 Mon Sep 17 00:00:00 2001 From: fenopy Date: Wed, 3 Jun 2026 07:16:39 -0500 Subject: [PATCH 2/5] feat: add notify every x options --- kobrax_moonraker_bridge.py | 85 ++++++++++++++++++++++++++++++----- web/themes/default/app.js | 10 ++++- web/themes/default/index.html | 12 +++++ web/translations/de.json | 7 ++- web/translations/en.json | 7 ++- web/translations/es.json | 7 ++- web/translations/zh-cn.json | 7 ++- 7 files changed, 118 insertions(+), 17 deletions(-) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 0d0de14..9878386 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -800,9 +800,20 @@ class KobraXBridge: self._camera_autostarted: bool = False self.camera_cache: CameraCache = CameraCache() self._prev_kobra_state: str = "" + self._notify_every_minutes: int = 0 + self._notify_every_layers: int = 0 + self._last_progress_notif_time: float = 0.0 + self._last_progress_notif_layer: int = 0 try: import config_loader as _cl2 self._notification_urls: list[dict] = _cl2.list_notification_urls() + _np = _cl2._find_config_file() + if _np: + import configparser as _cparser + _ncfg = _cparser.ConfigParser() + _ncfg.read(_np, encoding="utf-8") + self._notify_every_minutes = _ncfg.getint("notifications", "notify_every_minutes", fallback=0) + self._notify_every_layers = _ncfg.getint("notifications", "notify_every_layers", fallback=0) except Exception: self._notification_urls = [] @@ -907,15 +918,32 @@ class KobraXBridge: return printer_name = self._state.get("printer_name", "KX-Bridge") titles = { + "started": "Print Started ▶", "finished": "Print Finished ✓", "failed": "Print Failed ✗", "cancelled": "Print Cancelled", - "paused": "Print Paused", + "paused": "Print Paused ⏸", + "progress": "Print Progress", } title = titles.get(event, "KX-Bridge") - body = filename if filename else printer_name - if filename and printer_name: - body = f"{filename}\n{printer_name}" + if event == "progress": + pct = int(self._state.get("progress", 0) * 100) + curr = self._state.get("curr_layer", 0) + total = self._state.get("total_layers", 0) + rem = self._state.get("remain_time", 0) + rem_str = f"{rem // 3600}h {(rem % 3600) // 60}m" if rem else "" + parts = [f"{pct}%"] + if total: + parts.append(f"Layer {curr}/{total}") + if rem_str: + parts.append(f"Remaining: {rem_str}") + if filename: + parts.append(filename) + body = " • ".join(parts) if parts else printer_name + else: + body = filename if filename else printer_name + if filename and printer_name: + body = f"{filename}\n{printer_name}" def _send(): try: @@ -930,6 +958,27 @@ class KobraXBridge: threading.Thread(target=_send, daemon=True).start() + def _check_progress_notifications(self): + if self._state.get("kobra_state") != "printing": + return + if not any("progress" in e.get("events", []) for e in self._notification_urls): + return + if self._notify_every_minutes == 0 and self._notify_every_layers == 0: + return + now = time.monotonic() + curr_layer = self._state.get("curr_layer", 0) + fired = False + if self._notify_every_minutes > 0: + if now - self._last_progress_notif_time >= self._notify_every_minutes * 60: + fired = True + if not fired and self._notify_every_layers > 0 and curr_layer > 0: + if curr_layer - self._last_progress_notif_layer >= self._notify_every_layers: + fired = True + if fired: + self._last_progress_notif_time = now + self._last_progress_notif_layer = curr_layer + self._notify("progress", self._state.get("filename", "")) + # ------------------------------------------------------------------------- # MQTT callbacks (called from reader thread) # ------------------------------------------------------------------------- @@ -990,6 +1039,8 @@ class KobraXBridge: _notif_filename = d.get("filename", self._state.get("filename", "")) if kobra_state == "printing": self._notify("started", _notif_filename) + self._last_progress_notif_time = time.monotonic() + self._last_progress_notif_layer = self._state.get("curr_layer", 0) elif kobra_state == "finished": self._notify("finished", _notif_filename) elif kobra_state == "failed": @@ -3790,8 +3841,10 @@ class KobraXBridge: "auto_leveling": getattr(self._args, "auto_leveling", 1), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), - "ace_dry_presets": self._ace_dry_presets, - "notifications": self._notification_urls, + "ace_dry_presets": self._ace_dry_presets, + "notifications": self._notification_urls, + "notify_every_minutes": self._notify_every_minutes, + "notify_every_layers": self._notify_every_layers, }) async def handle_api_settings_post(self, request): @@ -3843,7 +3896,7 @@ class KobraXBridge: cfg.remove_section("notifications") incoming_notifs = data.get("notifications") if isinstance(incoming_notifs, list): - valid_events = {"started", "finished", "failed", "cancelled", "paused"} + valid_events = {"started", "finished", "failed", "cancelled", "paused", "progress"} entries = [] for entry in incoming_notifs: if not isinstance(entry, dict): @@ -3853,12 +3906,19 @@ class KobraXBridge: continue events = [e for e in entry.get("events", []) if e in valid_events] entries.append({"url": url, "events": events}) - if entries: - cfg.add_section("notifications") - for i, entry in enumerate(entries, 1): - cfg.set("notifications", f"url_{i}", entry["url"]) - cfg.set("notifications", f"events_{i}", ",".join(entry["events"])) + cfg.add_section("notifications") + for i, entry in enumerate(entries, 1): + cfg.set("notifications", f"url_{i}", entry["url"]) + cfg.set("notifications", f"events_{i}", ",".join(entry["events"])) self._notification_urls = entries + elif not cfg.has_section("notifications"): + cfg.add_section("notifications") + notify_every_minutes = max(0, int(data.get("notify_every_minutes") or 0)) + notify_every_layers = max(0, int(data.get("notify_every_layers") or 0)) + cfg.set("notifications", "notify_every_minutes", str(notify_every_minutes)) + cfg.set("notifications", "notify_every_layers", str(notify_every_layers)) + self._notify_every_minutes = notify_every_minutes + self._notify_every_layers = notify_every_layers with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n\n") @@ -4611,6 +4671,7 @@ class KobraXBridge: print_r = self.client.publish("print", "query", timeout=3.0) if print_r: self._on_print(print_r) + self._check_progress_notifications() box = self.client.query_multicolor_box() if box: data = box.get("data") or {} diff --git a/web/themes/default/app.js b/web/themes/default/app.js index cc8af9b..cff6d42 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -314,6 +314,10 @@ function applyLang(){ setText('modal-sec-notifications',T.settings_notifications); var nhEl=document.getElementById('notif-hint');if(nhEl && T.settings_notifications_hint)nhEl.innerHTML=T.settings_notifications_hint; setText('lbl-notif-add',T.settings_notif_add); + setText('lbl-notif-interval',T.settings_notif_interval_lbl); + setText('lbl-notif-min-unit',T.settings_notif_min_unit); + setText('lbl-notif-layers-unit',T.settings_notif_layers_unit); + setText('lbl-notif-zero-off',T.settings_notif_zero_off); setText('modal-sec-version',T.settings_version); // Custom-Profile-Import (Issue #41) setText('modal-sec-orca-profiles',T.orca_profile_section); @@ -888,7 +892,7 @@ function drawChart(id,_,series){ // ── Notifications ── var _notifRows=[]; -var _NOTIF_EVENTS=['started','finished','failed','cancelled','paused']; +var _NOTIF_EVENTS=['started','finished','failed','cancelled','paused','progress']; function notifRenderList(entries){ _notifRows=entries.map(function(e){return {url:e.url||'',events:e.events||[]};}); notifRefreshDOM(); @@ -983,6 +987,8 @@ function openSettings(){ var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); notifRenderList(d.notifications||[]); + var enm=document.getElementById('s-notif-every-min');if(enm)enm.value=d.notify_every_minutes||0; + var enl=document.getElementById('s-notif-every-layers');if(enl)enl.value=d.notify_every_layers||0; }); var v=localStorage.getItem('pollInterval')||'2000'; document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); @@ -1353,6 +1359,8 @@ function saveSettings(){ camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, web_upload_warning:webUploadWarning, notifications: notifCollect(), + notify_every_minutes: parseInt((document.getElementById('s-notif-every-min')||{}).value||'0',10)||0, + notify_every_layers: parseInt((document.getElementById('s-notif-every-layers')||{}).value||'0',10)||0, }).then(function(){ btn.textContent=T.update_restarting; setTimeout(function(){ diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 690d845..1c3c2ed 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -142,6 +142,18 @@ style="background:var(--raised);color:var(--txt)"> + Add notification +
+ Repeat interval + + + min + + + + layers + + (0 = off) +
diff --git a/web/translations/de.json b/web/translations/de.json index 29df2eb..97597ec 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -255,5 +255,10 @@ "settings_notif_ev_failed": "Fehler", "settings_notif_ev_cancelled": "Abgebrochen", "settings_notif_ev_paused": "Pausiert", - "settings_notif_ev_started": "Gestartet" + "settings_notif_ev_started": "Gestartet", + "settings_notif_ev_progress": "Verlauf", + "settings_notif_interval_lbl": "Wiederholungsintervall", + "settings_notif_min_unit": "Min.", + "settings_notif_layers_unit": "Layer", + "settings_notif_zero_off": "(0 = aus)" } diff --git a/web/translations/en.json b/web/translations/en.json index 95238d6..44d2318 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -255,5 +255,10 @@ "settings_notif_ev_failed": "Failed", "settings_notif_ev_cancelled": "Cancelled", "settings_notif_ev_paused": "Paused", - "settings_notif_ev_started": "Started" + "settings_notif_ev_started": "Started", + "settings_notif_ev_progress": "Progress", + "settings_notif_interval_lbl": "Repeat interval", + "settings_notif_min_unit": "min", + "settings_notif_layers_unit": "layers", + "settings_notif_zero_off": "(0 = off)" } diff --git a/web/translations/es.json b/web/translations/es.json index 0cf5201..025f22d 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -255,5 +255,10 @@ "settings_notif_ev_failed": "Error", "settings_notif_ev_cancelled": "Cancelado", "settings_notif_ev_paused": "Pausado", - "settings_notif_ev_started": "Iniciado" + "settings_notif_ev_started": "Iniciado", + "settings_notif_ev_progress": "Progreso", + "settings_notif_interval_lbl": "Intervalo de repetición", + "settings_notif_min_unit": "min", + "settings_notif_layers_unit": "capas", + "settings_notif_zero_off": "(0 = off)" } diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index c6b27d6..1f90ba3 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -255,5 +255,10 @@ "settings_notif_ev_failed": "失败", "settings_notif_ev_cancelled": "已取消", "settings_notif_ev_paused": "已暂停", - "settings_notif_ev_started": "开始打印" + "settings_notif_ev_started": "开始打印", + "settings_notif_ev_progress": "进度", + "settings_notif_interval_lbl": "重复间隔", + "settings_notif_min_unit": "分钟", + "settings_notif_layers_unit": "层", + "settings_notif_zero_off": "(0 = 关闭)" } From 080fda0f1a63609a08ee2dbf12246958aed02ff1 Mon Sep 17 00:00:00 2001 From: fenopy Date: Wed, 3 Jun 2026 07:21:07 -0500 Subject: [PATCH 3/5] feat: add send image option to notifs --- config_loader.py | 3 ++- kobrax_moonraker_bridge.py | 49 ++++++++++++++++++++++++++++--------- web/themes/default/app.js | 14 ++++++++--- web/translations/de.json | 3 ++- web/translations/en.json | 3 ++- web/translations/es.json | 3 ++- web/translations/zh-cn.json | 3 ++- 7 files changed, 57 insertions(+), 21 deletions(-) diff --git a/config_loader.py b/config_loader.py index f4ed70b..71480e0 100644 --- a/config_loader.py +++ b/config_loader.py @@ -185,7 +185,8 @@ def list_notification_urls() -> list[dict]: if url: events_str = cfg.get("notifications", f"events_{idx}", fallback="finished,failed,cancelled") events = [e.strip() for e in events_str.split(",") if e.strip()] - result.append({"url": url, "events": events}) + include_image = cfg.get("notifications", f"image_{idx}", fallback="false").strip().lower() in ("1", "true", "yes") + result.append({"url": url, "events": events, "include_image": include_image}) idx += 1 return result diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 9878386..79cd195 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -913,8 +913,8 @@ class KobraXBridge: # ------------------------------------------------------------------------- def _notify(self, event: str, filename: str = ""): - urls = [e["url"] for e in self._notification_urls if event in e.get("events", [])] - if not urls: + matching = [e for e in self._notification_urls if event in e.get("events", [])] + if not matching: return printer_name = self._state.get("printer_name", "KX-Bridge") titles = { @@ -927,10 +927,10 @@ class KobraXBridge: } title = titles.get(event, "KX-Bridge") if event == "progress": - pct = int(self._state.get("progress", 0) * 100) - curr = self._state.get("curr_layer", 0) + pct = int(self._state.get("progress", 0) * 100) + curr = self._state.get("curr_layer", 0) total = self._state.get("total_layers", 0) - rem = self._state.get("remain_time", 0) + rem = self._state.get("remain_time", 0) rem_str = f"{rem // 3600}h {(rem % 3600) // 60}m" if rem else "" parts = [f"{pct}%"] if total: @@ -945,14 +945,37 @@ class KobraXBridge: if filename and printer_name: body = f"{filename}\n{printer_name}" + urls_plain = [e["url"] for e in matching if not e.get("include_image")] + urls_image = [e["url"] for e in matching if e.get("include_image")] + jpeg_bytes = self.camera_cache.latest_jpeg if urls_image else b"" + def _send(): + import apprise, os, tempfile try: - import apprise - ap = apprise.Apprise() - for url in urls: - ap.add(url) - ap.notify(title=title, body=body) - log.info(f"Benachrichtigung '{event}' gesendet ({len(urls)} URL(s))") + if urls_plain: + ap = apprise.Apprise() + for u in urls_plain: + ap.add(u) + ap.notify(title=title, body=body) + if urls_image: + ap2 = apprise.Apprise() + for u in urls_image: + ap2.add(u) + attach = None + tmppath = None + if jpeg_bytes: + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: + f.write(jpeg_bytes) + tmppath = f.name + attach = apprise.AppriseAttachment() + attach.add(tmppath) + ap2.notify(title=title, body=body, attach=attach) + if tmppath: + try: + os.unlink(tmppath) + except Exception: + pass + log.info(f"Benachrichtigung '{event}' gesendet ({len(matching)} URL(s))") except Exception as e: log.warning(f"Benachrichtigung fehlgeschlagen: {e}") @@ -3905,11 +3928,13 @@ class KobraXBridge: if not url: continue events = [e for e in entry.get("events", []) if e in valid_events] - entries.append({"url": url, "events": events}) + include_image = bool(entry.get("include_image", False)) + entries.append({"url": url, "events": events, "include_image": include_image}) cfg.add_section("notifications") for i, entry in enumerate(entries, 1): cfg.set("notifications", f"url_{i}", entry["url"]) cfg.set("notifications", f"events_{i}", ",".join(entry["events"])) + cfg.set("notifications", f"image_{i}", "true" if entry["include_image"] else "false") self._notification_urls = entries elif not cfg.has_section("notifications"): cfg.add_section("notifications") diff --git a/web/themes/default/app.js b/web/themes/default/app.js index cff6d42..6723a6c 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -894,7 +894,7 @@ function drawChart(id,_,series){ var _notifRows=[]; var _NOTIF_EVENTS=['started','finished','failed','cancelled','paused','progress']; function notifRenderList(entries){ - _notifRows=entries.map(function(e){return {url:e.url||'',events:e.events||[]};}); + _notifRows=entries.map(function(e){return {url:e.url||'',events:e.events||[],include_image:!!e.include_image};}); notifRefreshDOM(); } function notifRefreshDOM(){ @@ -912,6 +912,9 @@ function notifRefreshDOM(){ +' '+lbl+''; }).join(' '); + var imgCheck=''; return '
' +'
' +''+(T.settings_notif_test||'Test')+'' +'' +'
' - +'
'+evChecks+'
' + +'
'+evChecks+imgCheck+'
' +'
' +'
'; }).join(''); } function _escHtml(s){return (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function notifAddRow(){ - _notifRows.push({url:'',events:['finished','failed']}); + _notifRows.push({url:'',events:['finished','failed'],include_image:false}); notifRefreshDOM(); } function notifRemoveRow(idx){ @@ -942,9 +945,12 @@ function notifToggleEvent(idx,ev){ var pos=evs.indexOf(ev); if(pos>=0)evs.splice(pos,1);else evs.push(ev); } +function notifToggleImage(idx){ + if(_notifRows[idx])_notifRows[idx].include_image=!_notifRows[idx].include_image; +} function notifCollect(){ return _notifRows.filter(function(r){return r.url.trim();}).map(function(r){ - return {url:r.url.trim(),events:r.events}; + return {url:r.url.trim(),events:r.events,include_image:!!r.include_image}; }); } function notifTest(idx){ diff --git a/web/translations/de.json b/web/translations/de.json index 97597ec..6e6fac1 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -260,5 +260,6 @@ "settings_notif_interval_lbl": "Wiederholungsintervall", "settings_notif_min_unit": "Min.", "settings_notif_layers_unit": "Layer", - "settings_notif_zero_off": "(0 = aus)" + "settings_notif_zero_off": "(0 = aus)", + "settings_notif_send_image": "Bild" } diff --git a/web/translations/en.json b/web/translations/en.json index 44d2318..decf3ea 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -260,5 +260,6 @@ "settings_notif_interval_lbl": "Repeat interval", "settings_notif_min_unit": "min", "settings_notif_layers_unit": "layers", - "settings_notif_zero_off": "(0 = off)" + "settings_notif_zero_off": "(0 = off)", + "settings_notif_send_image": "Image" } diff --git a/web/translations/es.json b/web/translations/es.json index 025f22d..b6177b1 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -260,5 +260,6 @@ "settings_notif_interval_lbl": "Intervalo de repetición", "settings_notif_min_unit": "min", "settings_notif_layers_unit": "capas", - "settings_notif_zero_off": "(0 = off)" + "settings_notif_zero_off": "(0 = off)", + "settings_notif_send_image": "Imagen" } diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index 1f90ba3..e0a7f80 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -260,5 +260,6 @@ "settings_notif_interval_lbl": "重复间隔", "settings_notif_min_unit": "分钟", "settings_notif_layers_unit": "层", - "settings_notif_zero_off": "(0 = 关闭)" + "settings_notif_zero_off": "(0 = 关闭)", + "settings_notif_send_image": "图片" } From e473359cebbafc1ccec8a51804dd1bcfe2a098be Mon Sep 17 00:00:00 2001 From: fenopy Date: Wed, 3 Jun 2026 07:22:58 -0500 Subject: [PATCH 4/5] chore: add claude.md --- CLAUDE.md | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..df03a7d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,161 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +KX-Bridge is a Python 3.11+ bridge that emulates the Klipper/Moonraker API to enable OrcaSlicer to control Anycubic Kobra X printers in LAN mode — no Klipper, no Raspberry Pi required. It reverse-engineers the Anycubic MQTT protocol to relay commands from slicers to the printer. + +## Running + +**Docker (recommended):** +```bash +docker compose up -d +docker compose logs -f +``` + +**Python directly:** +```bash +pip install -r requirements.txt +python kobrax_moonraker_bridge.py --printer-ip 192.168.x.x +``` + +**Build binary:** +```bash +pyinstaller kx-bridge.spec +``` + +No test suite exists in this repository. + +## Architecture + +The system bridges three parties: + +``` +OrcaSlicer / Browser + ↕ HTTP + WebSocket (port 7125+, Moonraker API) +KX-Bridge (kobrax_moonraker_bridge.py) + ↕ MQTT over TLS (port 9883, Anycubic LAN protocol) +Anycubic Kobra X Printer +``` + +### Core Files + +- [kobrax_moonraker_bridge.py](kobrax_moonraker_bridge.py) — Main server (~5,000 lines). Houses `KobraXBridge` (HTTP/WebSocket handlers, state management), `GCodeStore` (SQLite-backed file/print-history storage), and `CameraCache` (MJPEG stream caching). This is where all Moonraker API endpoints are implemented. +- [kobrax_client.py](kobrax_client.py) — Low-level MQTT client for the Kobra X. Handles raw MQTT 3.1.1 framing, TLS certificate auth, topic subscriptions, and message dispatch. +- [config_loader.py](config_loader.py) — Loads `config/config.ini` (primary); [env_loader.py](env_loader.py) handles `.env` fallback. Also exposes `list_notification_urls()`, `list_printers()`, `list_filament_profiles()`. +- [fetch_credentials.py](fetch_credentials.py) — Standalone tool to pull printer credentials over HTTP from the Kobra X. +- [extract_credentials.py](extract_credentials.py) — Reads credentials from a running AnycubicSlicerNext process (Windows/Linux). + +### Web UI + +Single-page app served by the bridge itself: +- [web/themes/default/index.html](web/themes/default/index.html) — Shell HTML +- [web/themes/default/app.js](web/themes/default/app.js) — All client-side logic (~2,500 lines): WebSocket handling, state rendering, UI events +- [web/themes/default/style.css](web/themes/default/style.css) — Dark/light theme via CSS variables +- [web/translations/](web/translations/) — i18n strings (DE, EN, ES, ZH-CN) + +When adding UI text, all four translation files must be updated. The hint element for notifications uses `innerHTML` (not `textContent`) to support links — follow the same pattern used for `orca_profile_help_html` when translation values contain HTML. + +The theme system is documented in [web/DOC/THEME-CSS-HOOKS.md](web/DOC/THEME-CSS-HOOKS.md) and [web/DOC/THEME-JS-ID-HOOKS.md](web/DOC/THEME-JS-ID-HOOKS.md). + +### Configuration + +Primary config: `config/config.ini` (copy from `config/config.ini.example`). Sections: +- `[connection]` — Printer IP, MQTT credentials, device/mode IDs +- `[printer_N]` — One section per printer for multi-printer setups (ports 7125–7130) +- `[print]` — AMS slots, auto-leveling, camera +- `[filament_profiles]` — Per-slot filament for AMS +- `[bridge]` — Poll interval (1–5 s, default 3 s), printer name +- `[notifications]` — Apprise notification URLs and interval settings (see below) + +### Data Storage + +- **SQLite** at `data/kx-bridge.db` — G-code metadata, print history, thumbnails (base64) +- **`data/orca_filaments.json`** — OrcaSlicer filament database, loaded at startup + +### Key Protocol Details + +- **MQTT auth:** AES-256-CBC encrypted credentials + TLS certificates (`anycubic_slicer.crt/key` bundled via PyInstaller) +- **Moonraker emulation:** Kobra states are mapped to Klipper states via `KOBRA_TO_KLIPPER_STATE` in `kobrax_moonraker_bridge.py` +- **Real-time logs:** Streamed to the browser via Server-Sent Events (SSE), not WebSocket +- **Multi-printer:** Each printer runs its own `KobraXBridge` instance on a separate port + +### PyInstaller Build + +[kx-bridge.spec](kx-bridge.spec) bundles the web UI, filament database, and Anycubic TLS certificates into a single binary. The `web/` tree maps to `static/` inside the binary. Both `pycryptodome` and `apprise` use `collect_all()` to capture dynamically loaded plugins. + +### Notification System (Apprise) + +Push notifications are sent via the [apprise](https://github.com/caronc/apprise) library (supports 60+ services via URL syntax: `discord://`, `telegram://`, `pover://`, `gotify://`, `slack://`, etc.). + +**Config format** (`[notifications]` section): +```ini +url_1 = discord://webhook_id/webhook_token +events_1 = started,finished,failed,cancelled,paused,progress +image_1 = false +url_2 = pover://USERKEY@TOKEN +events_2 = finished,failed +image_2 = true +notify_every_minutes = 10 +notify_every_layers = 0 +``` + +**Key implementation points:** +- `KobraXBridge._notify(event, filename)` — fires on state transitions detected in `_on_print()`. Splits matching URLs into plain and image groups; image group attaches a temp `.jpg` from `camera_cache.latest_jpeg` via `apprise.AppriseAttachment`. +- `KobraXBridge._check_progress_notifications()` — called from `_poll_loop()` every poll tick; fires `progress` event when the time or layer threshold is crossed. Both counters are reset together when either fires, and are also reset when a new print starts. +- `_prev_kobra_state` guards against duplicate notifications when the printer repeatedly reports the same state. +- All notification dispatches run in `threading.Thread(daemon=True)` to avoid blocking the MQTT reader thread or the event loop. +- The test endpoint (`POST /api/notifications/test`) runs apprise synchronously via `run_in_executor`. + +**Supported events:** `started`, `finished`, `failed`, `cancelled`, `paused`, `progress` + +**Settings UI:** Managed through the WebUI settings modal. Each URL entry has per-event checkboxes, a `📷 Image` toggle, and a Test button. Global repeat interval fields (minutes / layers) apply to all URLs subscribed to the `progress` event. + +## Android App + +A companion Android app lives in [android/](android/). It connects to a running KX-Bridge server and provides a mobile printer control panel. + +### Building + +```bash +cd android +./gradlew assembleDebug # APK at app/build/outputs/apk/debug/ +./gradlew assembleRelease # Minified release APK +``` + +- **Min SDK:** 26 (Android 8.0), **Compile/Target SDK:** 34 +- **Language:** Kotlin 2.0.0 + Jetpack Compose (Material3) +- **Java toolchain:** 17 +- No test suite exists. + +### Architecture + +``` +android/app/src/main/java/com/kxbridge/ +├── MainActivity.kt # Entry point; reads SharedPreferences for server URL +├── data/ +│ ├── PrinterRepository.kt # OkHttp3 HTTP client; polls /api/state every 3 s +│ └── model/PrinterState.kt # kotlinx-serialization data model (snake_case JSON) +├── viewmodel/PrinterViewModel.kt # StateFlow (Loading / Success / Error) +└── ui/ + ├── PrinterScreen.kt # Main control UI: temps, progress, pause/resume/cancel + └── SetupScreen.kt # First-run server URL entry form +``` + +`MainActivity` stores the server URL in SharedPreferences (`"kxbridge"` / key `"server_url"`) and switches between `SetupScreen` and `PrinterScreen` based on whether a URL is configured. + +`PrinterViewModel` drives all network interaction: it holds a `PrinterRepository`, exposes a `StateFlow`, and exposes command methods (`pause`, `resume`, `cancel`). `UiState` is a sealed interface. + +### Communication with KX-Bridge + +The app uses plain HTTP (cleartext allowed in the manifest): + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| GET | `/api/state` | Fetch `PrinterState` JSON (polled every 3 s) | +| POST | `/printer/print/pause` | Pause active print | +| POST | `/printer/print/resume` | Resume paused print | +| POST | `/printer/print/cancel` | Cancel print | + +`PrinterState` fields use `@SerialName` for snake_case JSON keys. ProGuard is configured to keep `com.kxbridge.data.model.**` to prevent serialization breakage in release builds. From 7c62f60bde48ee4b8913c9b0be950c52c6817d1e Mon Sep 17 00:00:00 2001 From: fenopy Date: Wed, 3 Jun 2026 07:24:37 -0500 Subject: [PATCH 5/5] fix: move light toggle to top --- .../src/main/java/com/kxbridge/ui/PrinterScreen.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt b/android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt index 7fe3f7d..3f84cbc 100644 --- a/android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt +++ b/android/app/src/main/java/com/kxbridge/ui/PrinterScreen.kt @@ -156,6 +156,12 @@ private fun PrinterContent( ) { StatusBadge(state.printState) + LightCard( + isOn = state.lightOn, + brightness = state.lightBrightness, + onToggle = onToggleLight, + ) + CameraCard( cameraFrame = cameraFrame, cameraEnabled = cameraEnabled, @@ -197,12 +203,6 @@ private fun PrinterContent( ) } - LightCard( - isOn = state.lightOn, - brightness = state.lightBrightness, - onToggle = onToggleLight, - ) - if (state.amsSlots.isNotEmpty()) { FilamentCard(slots = state.amsSlots, mode = state.filamentMode) }