forked from viewit/KX-Bridge-Release
Merge pull request 'Notifs enhance' (#3) from notifs-enhance into master
Reviewed-on: http://192.168.1.103:3000/fenopy/KX-Bridge-Release/pulls/3
This commit is contained in:
161
CLAUDE.md
Normal file
161
CLAUDE.md
Normal file
@@ -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<UiState> (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<UiState>`, 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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -902,34 +913,95 @@ 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 = {
|
||||
"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}"
|
||||
|
||||
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}")
|
||||
|
||||
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)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -988,7 +1060,11 @@ 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)
|
||||
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":
|
||||
self._notify("failed", _notif_filename)
|
||||
@@ -3788,8 +3864,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):
|
||||
@@ -3841,7 +3919,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", "progress"}
|
||||
entries = []
|
||||
for entry in incoming_notifs:
|
||||
if not isinstance(entry, dict):
|
||||
@@ -3850,13 +3928,22 @@ 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})
|
||||
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"]))
|
||||
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")
|
||||
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")
|
||||
@@ -4609,6 +4696,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 {}
|
||||
|
||||
@@ -312,8 +312,12 @@ 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('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,9 +892,9 @@ function drawChart(id,_,series){
|
||||
|
||||
// ── Notifications ──
|
||||
var _notifRows=[];
|
||||
var _NOTIF_EVENTS=['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||[]};});
|
||||
_notifRows=entries.map(function(e){return {url:e.url||'',events:e.events||[],include_image:!!e.include_image};});
|
||||
notifRefreshDOM();
|
||||
}
|
||||
function notifRefreshDOM(){
|
||||
@@ -908,6 +912,9 @@ function notifRefreshDOM(){
|
||||
+'<input type="checkbox" data-notif-idx="'+idx+'" data-notif-ev="'+ev+'" '+checked
|
||||
+' onchange="notifToggleEvent('+idx+',\''+ev+'\')" style="width:auto;margin:0"> '+lbl+'</label>';
|
||||
}).join(' ');
|
||||
var imgCheck='<label style="display:inline-flex;align-items:center;gap:3px;font-size:11px;cursor:pointer;margin-left:4px;padding-left:8px;border-left:1px solid var(--border)" title="'+(T.settings_notif_send_image||'Send image')+'">'
|
||||
+'<input type="checkbox" '+(row.include_image?'checked':'')+' onchange="notifToggleImage('+idx+')" style="width:auto;margin:0"> '
|
||||
+'📷 '+(T.settings_notif_send_image||'Image')+'</label>';
|
||||
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://…"'
|
||||
@@ -915,14 +922,14 @@ function notifRefreshDOM(){
|
||||
+'<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 style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">'+evChecks+imgCheck+'</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']});
|
||||
_notifRows.push({url:'',events:['finished','failed'],include_image:false});
|
||||
notifRefreshDOM();
|
||||
}
|
||||
function notifRemoveRow(idx){
|
||||
@@ -938,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){
|
||||
@@ -983,6 +993,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 +1365,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(){
|
||||
|
||||
@@ -142,6 +142,18 @@
|
||||
style="background:var(--raised);color:var(--txt)">
|
||||
+ <span id="lbl-notif-add">Add notification</span>
|
||||
</button>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-top:10px;font-size:12px">
|
||||
<span id="lbl-notif-interval" style="font-size:11px;font-weight:600;white-space:nowrap">Repeat interval</span>
|
||||
<span style="white-space:nowrap">
|
||||
<input type="number" id="s-notif-every-min" min="0" max="999" style="width:52px;display:inline-block">
|
||||
<span id="lbl-notif-min-unit"> min</span>
|
||||
</span>
|
||||
<span style="white-space:nowrap">
|
||||
<input type="number" id="s-notif-every-layers" min="0" max="9999" style="width:60px;display:inline-block">
|
||||
<span id="lbl-notif-layers-unit"> layers</span>
|
||||
</span>
|
||||
<span style="font-size:11px;color:var(--txt2)" id="lbl-notif-zero-off">(0 = off)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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 <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">Apprise-URLs</a> senden (discord://, telegram://, gotify://, slack://, …)",
|
||||
"settings_notif_add": "Benachrichtigung hinzufügen",
|
||||
"settings_notif_empty": "Keine Benachrichtigungen konfiguriert.",
|
||||
"settings_notif_test": "Test",
|
||||
@@ -254,5 +254,12 @@
|
||||
"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",
|
||||
"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)",
|
||||
"settings_notif_send_image": "Bild"
|
||||
}
|
||||
|
||||
@@ -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 <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">Apprise URLs</a> (discord://, telegram://, gotify://, slack://, …)",
|
||||
"settings_notif_add": "Add notification",
|
||||
"settings_notif_empty": "No notifications configured.",
|
||||
"settings_notif_test": "Test",
|
||||
@@ -254,5 +254,12 @@
|
||||
"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",
|
||||
"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)",
|
||||
"settings_notif_send_image": "Image"
|
||||
}
|
||||
|
||||
@@ -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 <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">URLs de Apprise</a> (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,12 @@
|
||||
"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",
|
||||
"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)",
|
||||
"settings_notif_send_image": "Imagen"
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
"ss_name": "A–Z 名称",
|
||||
"ss_dur": "⏱ 打印时间",
|
||||
"settings_notifications": "通知",
|
||||
"settings_notifications_hint": "通过 Apprise URL 发送通知 (discord://, telegram://, gotify://, slack://, …)",
|
||||
"settings_notifications_hint": "通过 <a href=\"https://appriseit.com/getting-started/universal-syntax/\" target=\"_blank\" rel=\"noopener\">Apprise URL</a> 发送通知 (discord://, telegram://, gotify://, slack://, …)",
|
||||
"settings_notif_add": "添加通知",
|
||||
"settings_notif_empty": "未配置通知。",
|
||||
"settings_notif_test": "测试",
|
||||
@@ -254,5 +254,12 @@
|
||||
"settings_notif_ev_finished": "完成",
|
||||
"settings_notif_ev_failed": "失败",
|
||||
"settings_notif_ev_cancelled": "已取消",
|
||||
"settings_notif_ev_paused": "已暂停"
|
||||
"settings_notif_ev_paused": "已暂停",
|
||||
"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 = 关闭)",
|
||||
"settings_notif_send_image": "图片"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user