forked from viewit/KX-Bridge-Release
feat: notificatons with apprise
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=[],
|
||||
|
||||
@@ -2,3 +2,4 @@ aiohttp>=3.9
|
||||
imageio-ffmpeg>=0.4.9
|
||||
requests>=2.30.0
|
||||
pycryptodome>=3.20.0
|
||||
apprise>=1.8
|
||||
|
||||
@@ -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='<div style="font-size:11px;color:var(--txt2);padding:4px 0" id="notif-empty">'+(T.settings_notif_empty||'No notifications configured.')+'</div>';
|
||||
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 '<label style="display:inline-flex;align-items:center;gap:3px;font-size:11px;cursor:pointer">'
|
||||
+'<input type="checkbox" data-notif-idx="'+idx+'" data-notif-ev="'+ev+'" '+checked
|
||||
+' onchange="notifToggleEvent('+idx+',\''+ev+'\')" style="width:auto;margin:0"> '+lbl+'</label>';
|
||||
}).join(' ');
|
||||
return '<div style="background:var(--raised);border-radius:6px;padding:8px;margin-bottom:6px">'
|
||||
+'<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px">'
|
||||
+'<input type="text" value="'+_escHtml(row.url)+'" placeholder="discord://… telegram://… gotify://…"'
|
||||
+' oninput="notifSetUrl('+idx+',this.value)" style="flex:1;font-family:var(--mono);font-size:11px">'
|
||||
+'<button onclick="notifTest('+idx+')" style="background:var(--raised2,var(--raised));border:1px solid var(--border);color:var(--txt);border-radius:4px;cursor:pointer;padding:2px 8px;font-size:11px" id="notif-test-btn-'+idx+'">'+(T.settings_notif_test||'Test')+'</button>'
|
||||
+'<button onclick="notifRemoveRow('+idx+')" style="background:none;border:none;color:var(--err);cursor:pointer;font-size:16px;line-height:1" title="remove">✕</button>'
|
||||
+'</div>'
|
||||
+'<div style="display:flex;flex-wrap:wrap;gap:8px">'+evChecks+'</div>'
|
||||
+'<div id="notif-test-status-'+idx+'" style="font-size:11px;margin-top:4px"></div>'
|
||||
+'</div>';
|
||||
}).join('');
|
||||
}
|
||||
function _escHtml(s){return (s||'').replace(/&/g,'&').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(){
|
||||
|
||||
@@ -134,6 +134,16 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="modal-section" id="modal-sec-notifications">Notifications</div>
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:8px" id="notif-hint"></div>
|
||||
<div id="notif-list" style="margin-bottom:8px"></div>
|
||||
<button class="btn btn-sm" id="btn-notif-add" onclick="notifAddRow()"
|
||||
style="background:var(--raised);color:var(--txt)">
|
||||
+ <span id="lbl-notif-add">Add notification</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="modal-section" id="modal-sec-version">Version</div>
|
||||
<div class="update-row">
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "已暂停"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user