From 28c7521c5d4588f7e575eea0ab601debd45fd360 Mon Sep 17 00:00:00 2001 From: fenopy Date: Wed, 3 Jun 2026 06:10:35 -0500 Subject: [PATCH] feat: notificatons with apprise --- config_loader.py | 24 ++++++++ kobrax_moonraker_bridge.py | 101 ++++++++++++++++++++++++++++++++++ kx-bridge.spec | 6 ++ requirements.txt | 1 + web/themes/default/app.js | 81 +++++++++++++++++++++++++++ web/themes/default/index.html | 10 ++++ web/translations/de.json | 13 ++++- web/translations/en.json | 13 ++++- web/translations/es.json | 13 ++++- web/translations/zh-cn.json | 13 ++++- 10 files changed, 271 insertions(+), 4 deletions(-) diff --git a/config_loader.py b/config_loader.py index 807f911..f4ed70b 100644 --- a/config_loader.py +++ b/config_loader.py @@ -166,6 +166,30 @@ def list_printers() -> list[dict]: return printers +def list_notification_urls() -> list[dict]: + """Reads [{url, events: [str]}] from the [notifications] section of config.ini.""" + path = _find_config_file() + if not path: + return [] + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + if not cfg.has_section("notifications"): + return [] + result = [] + idx = 1 + while True: + url_key = f"url_{idx}" + if not cfg.has_option("notifications", url_key): + break + url = cfg.get("notifications", url_key).strip() + 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}) + idx += 1 + return result + + def list_filament_profiles() -> dict[int, dict]: """Liest die [filament_profiles]-Sektion aus config.ini. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 6e02e48..8874a3c 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -799,6 +799,12 @@ class KobraXBridge: self._current_job_id: str = "" self._camera_autostarted: bool = False self.camera_cache: CameraCache = CameraCache() + self._prev_kobra_state: str = "" + try: + import config_loader as _cl2 + self._notification_urls: list[dict] = _cl2.list_notification_urls() + except Exception: + self._notification_urls = [] self._thumbnail_b64: str = "" self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config() @@ -891,6 +897,39 @@ class KobraXBridge: out[key]["name"] = name or str(d.get("name", "Custom")) return out + # ------------------------------------------------------------------------- + # Notifications (apprise) + # ------------------------------------------------------------------------- + + 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: + return + printer_name = self._state.get("printer_name", "KX-Bridge") + titles = { + "finished": "Print Finished ✓", + "failed": "Print Failed ✗", + "cancelled": "Print Cancelled", + "paused": "Print Paused", + } + title = titles.get(event, "KX-Bridge") + body = filename if filename else printer_name + if filename and printer_name: + body = f"{filename}\n{printer_name}" + + def _send(): + 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))") + except Exception as e: + log.warning(f"Benachrichtigung fehlgeschlagen: {e}") + + threading.Thread(target=_send, daemon=True).start() + # ------------------------------------------------------------------------- # MQTT callbacks (called from reader thread) # ------------------------------------------------------------------------- @@ -946,6 +985,19 @@ class KobraXBridge: log.info(f"Job abgebrochen: {self._current_job_id}") self._current_job_id = "" + # 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": + self._notify("finished", _notif_filename) + elif kobra_state == "failed": + self._notify("failed", _notif_filename) + elif kobra_state in ("stoped", "canceled"): + self._notify("cancelled", _notif_filename) + elif kobra_state == "paused": + self._notify("paused", _notif_filename) + self._prev_kobra_state = kobra_state + # Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der # Drucker meldet "finished" nach erfolgreichem Druck — file_ready wurde # bisher nur bei stoped/canceled geleert, dadurch kam das Banner zurück. @@ -3737,6 +3789,7 @@ class KobraXBridge: "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, }) async def handle_api_settings_post(self, request): @@ -3783,6 +3836,28 @@ class KobraXBridge: cfg.set("ace_dry_presets", f"{key}_name", str(val.get("name", key.replace("_", " ").title()))) self._ace_dry_presets = presets + # Benachrichtigungen speichern + if cfg.has_section("notifications"): + cfg.remove_section("notifications") + incoming_notifs = data.get("notifications") + if isinstance(incoming_notifs, list): + valid_events = {"finished", "failed", "cancelled", "paused"} + entries = [] + for entry in incoming_notifs: + if not isinstance(entry, dict): + continue + url = str(entry.get("url", "")).strip() + if not url: + 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"])) + self._notification_urls = entries + with open(config_path, "w", encoding="utf-8") as f: f.write("# KX-Bridge Konfigurationsdatei\n\n") cfg.write(f) @@ -3792,6 +3867,31 @@ class KobraXBridge: asyncio.get_event_loop().call_later(0.3, self._restart_bridge) return response + async def handle_api_notifications_test(self, request): + try: + data = await request.json() + except Exception: + return self._json_cors({"status": "error", "message": "invalid json"}, status=400) + url = str(data.get("url", "")).strip() + if not url: + return self._json_cors({"status": "error", "message": "url required"}, status=400) + printer_name = self._state.get("printer_name", "KX-Bridge") + try: + import apprise + ap = apprise.Apprise() + ap.add(url) + ok = await asyncio.get_event_loop().run_in_executor( + None, + lambda: ap.notify(title="KX-Bridge Test", body=f"Test notification from {printer_name}"), + ) + if ok is False: + return self._json_cors({"status": "error", "message": "Notification failed (check URL)"}) + return self._json_cors({"status": "ok"}) + except ImportError: + return self._json_cors({"status": "error", "message": "apprise not installed"}, status=500) + except Exception as e: + return self._json_cors({"status": "error", "message": str(e)}, status=500) + async def handle_kx_printer_add(self, request): """Fügt einen Drucker hinzu: holt Credentials via IP, schreibt [printer_N], Neustart.""" try: @@ -4627,6 +4727,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: 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_post("/api/notifications/test", bridge.handle_api_notifications_test) r.add_get("/api/update/check", bridge.handle_api_update_check) r.add_post("/api/update/apply", bridge.handle_api_update_apply) r.add_post("/api/file_ready/clear", bridge.handle_api_file_ready_clear) diff --git a/kx-bridge.spec b/kx-bridge.spec index f7326e0..4d09968 100644 --- a/kx-bridge.spec +++ b/kx-bridge.spec @@ -16,6 +16,12 @@ datas += _d binaries += _b hiddenimports += _h +# apprise — alle Notification-Plugins (dynamisch geladen via entry_points) +_d, _b, _h = collect_all("apprise") +datas += _d +binaries += _b +hiddenimports += _h + a = Analysis( ["kobrax_moonraker_bridge.py"], pathex=[], diff --git a/requirements.txt b/requirements.txt index e29debe..84b8c72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ aiohttp>=3.9 imageio-ffmpeg>=0.4.9 requests>=2.30.0 pycryptodome>=3.20.0 +apprise>=1.8 diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 158dc44..cf03d31 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -311,6 +311,9 @@ function applyLang(){ setText('modal-sec-connection',T.settings_connection); 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); + setText('lbl-notif-add',T.settings_notif_add); setText('modal-sec-version',T.settings_version); // Custom-Profile-Import (Issue #41) setText('modal-sec-orca-profiles',T.orca_profile_section); @@ -883,6 +886,82 @@ function drawChart(id,_,series){ }); } +// ── Notifications ── +var _notifRows=[]; +var _NOTIF_EVENTS=['finished','failed','cancelled','paused']; +function notifRenderList(entries){ + _notifRows=entries.map(function(e){return {url:e.url||'',events:e.events||[]};}); + notifRefreshDOM(); +} +function notifRefreshDOM(){ + var container=document.getElementById('notif-list'); + if(!container)return; + if(!_notifRows.length){ + container.innerHTML='
'+(T.settings_notif_empty||'No notifications configured.')+'
'; + return; + } + container.innerHTML=_notifRows.map(function(row,idx){ + var evChecks=_NOTIF_EVENTS.map(function(ev){ + var checked=row.events.indexOf(ev)>=0?'checked':''; + var lbl=T['settings_notif_ev_'+ev]||ev; + return ''; + }).join(' '); + return '
' + +'
' + +'' + +'' + +'' + +'
' + +'
'+evChecks+'
' + +'
' + +'
'; + }).join(''); +} +function _escHtml(s){return (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} +function notifAddRow(){ + _notifRows.push({url:'',events:['finished','failed']}); + notifRefreshDOM(); +} +function notifRemoveRow(idx){ + _notifRows.splice(idx,1); + notifRefreshDOM(); +} +function notifSetUrl(idx,val){ + if(_notifRows[idx])_notifRows[idx].url=val; +} +function notifToggleEvent(idx,ev){ + if(!_notifRows[idx])return; + var evs=_notifRows[idx].events; + var pos=evs.indexOf(ev); + if(pos>=0)evs.splice(pos,1);else evs.push(ev); +} +function notifCollect(){ + return _notifRows.filter(function(r){return r.url.trim();}).map(function(r){ + return {url:r.url.trim(),events:r.events}; + }); +} +function notifTest(idx){ + var row=_notifRows[idx]; + if(!row||!row.url.trim())return; + var btn=document.getElementById('notif-test-btn-'+idx); + var status=document.getElementById('notif-test-status-'+idx); + if(btn)btn.disabled=true; + if(status){status.textContent='…';status.style.color='var(--txt2)';} + post('/api/notifications/test',{url:row.url.trim()}).then(function(r){return r.json();}).then(function(d){ + if(btn)btn.disabled=false; + if(status){ + if(d&&d.status==='ok'){status.textContent='✓ '+(T.settings_notif_test_ok||'Sent');status.style.color='var(--ok)';} + else{status.textContent='✗ '+(d&&d.message?d.message:(T.settings_notif_test_fail||'Failed'));status.style.color='var(--err)';} + } + }).catch(function(e){ + if(btn)btn.disabled=false; + if(status){status.textContent='✗ '+e;status.style.color='var(--err)';} + }); +} + // ── Settings Modal ── var _updateTag=''; var _updateUrl=''; @@ -903,6 +982,7 @@ function openSettings(){ document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); 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 v=localStorage.getItem('pollInterval')||'2000'; document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); @@ -1272,6 +1352,7 @@ function saveSettings(){ auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, web_upload_warning:webUploadWarning, + notifications: notifCollect(), }).then(function(){ btn.textContent=T.update_restarting; setTimeout(function(){ diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 5023291..690d845 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -134,6 +134,16 @@ +
+ +
+
+ +
+
diff --git a/web/translations/de.json b/web/translations/de.json index 57a3520..b842725 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -243,5 +243,16 @@ "sf_new": "Neu", "ss_date": "↓ Datum", "ss_name": "A–Z Name", - "ss_dur": "⏱ Druckzeit" + "ss_dur": "⏱ Druckzeit", + "settings_notifications": "Benachrichtigungen", + "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", + "settings_notif_test_ok": "Gesendet", + "settings_notif_test_fail": "Fehlgeschlagen", + "settings_notif_ev_finished": "Fertig", + "settings_notif_ev_failed": "Fehler", + "settings_notif_ev_cancelled": "Abgebrochen", + "settings_notif_ev_paused": "Pausiert" } diff --git a/web/translations/en.json b/web/translations/en.json index 7458e63..f00ecd6 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -243,5 +243,16 @@ "sf_new": "New", "ss_date": "↓ Date", "ss_name": "A–Z Name", - "ss_dur": "⏱ Print time" + "ss_dur": "⏱ Print time", + "settings_notifications": "Notifications", + "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", + "settings_notif_test_ok": "Sent", + "settings_notif_test_fail": "Failed", + "settings_notif_ev_finished": "Finished", + "settings_notif_ev_failed": "Failed", + "settings_notif_ev_cancelled": "Cancelled", + "settings_notif_ev_paused": "Paused" } diff --git a/web/translations/es.json b/web/translations/es.json index 3c78f93..a455ec4 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -243,5 +243,16 @@ "sf_new": "Nuevo", "ss_date": "↓ Fecha", "ss_name": "A–Z Nombre", - "ss_dur": "⏱ Tiempo de impresión" + "ss_dur": "⏱ Tiempo de impresión", + "settings_notifications": "Notificaciones", + "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", + "settings_notif_test_ok": "Enviado", + "settings_notif_test_fail": "Fallido", + "settings_notif_ev_finished": "Terminado", + "settings_notif_ev_failed": "Error", + "settings_notif_ev_cancelled": "Cancelado", + "settings_notif_ev_paused": "Pausado" } diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index 867eb35..f0a8cca 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -243,5 +243,16 @@ "sf_new": "新", "ss_date": "↓ 日期", "ss_name": "A–Z 名称", - "ss_dur": "⏱ 打印时间" + "ss_dur": "⏱ 打印时间", + "settings_notifications": "通知", + "settings_notifications_hint": "通过 Apprise URL 发送通知 (discord://, telegram://, gotify://, slack://, …)", + "settings_notif_add": "添加通知", + "settings_notif_empty": "未配置通知。", + "settings_notif_test": "测试", + "settings_notif_test_ok": "已发送", + "settings_notif_test_fail": "失败", + "settings_notif_ev_finished": "完成", + "settings_notif_ev_failed": "失败", + "settings_notif_ev_cancelled": "已取消", + "settings_notif_ev_paused": "已暂停" }