From e1a4ce0b8ba0e32d701da0ed062e89260b6d5d15 Mon Sep 17 00:00:00 2001 From: OE6DXD Date: Mon, 16 Mar 2026 03:31:08 +0100 Subject: [PATCH] initialize generic rms-software repository Add the reusable RMS core application (server, web UI, plugins, tests, tools) with generic defaults, GPL licensing, and maintainer context documentation so deployments can consume this repo as software source independent of station-specific overlays. --- .env.example | 88 + .gitignore | 8 + LICENSE | 16 + README.md | 84 + WORKING_CONTEXT.md | 47 + package-lock.json | 49 + package.json | 21 + plugins/rms.auth.otp_email/index.js | 32 + plugins/rms.auth.otp_email/manifest.json | 20 + plugins/rms.auth.smtp_relay/index.js | 101 + plugins/rms.auth.smtp_relay/manifest.json | 27 + plugins/rms.debug.remote/index.js | 335 + plugins/rms.debug.remote/manifest.json | 94 + plugins/rms.help.basic/index.js | 89 + plugins/rms.help.basic/manifest.json | 15 + plugins/rms.microham/index.js | 601 ++ plugins/rms.microham/manifest.json | 34 + plugins/rms.openwebrx.bandmap/index.js | 311 + plugins/rms.openwebrx.bandmap/manifest.json | 42 + plugins/rms.openwebrx.guard/index.js | 160 + plugins/rms.openwebrx.guard/manifest.json | 24 + plugins/rms.rfroute.shell/index.js | 66 + plugins/rms.rfroute.shell/manifest.json | 56 + plugins/rms.rotor.hamlib/index.js | 93 + plugins/rms.rotor.hamlib/manifest.json | 51 + plugins/rms.station.access.policy/index.js | 143 + .../rms.station.access.policy/manifest.json | 69 + plugins/rms.station.shell/index.js | 122 + plugins/rms.station.shell/manifest.json | 21 + plugins/rms.tx.audio.core/index.js | 70 + plugins/rms.tx.audio.core/manifest.json | 16 + plugins/rms.tx.control.native/index.js | 158 + plugins/rms.tx.control.native/manifest.json | 59 + plugins/rms.tx.state.file/index.js | 56 + plugins/rms.tx.state.file/manifest.json | 17 + plugins/rms.vswr.nanovna/index.js | 70 + plugins/rms.vswr.nanovna/manifest.json | 46 + plugins/rms.vswr.native/index.js | 385 + plugins/rms.vswr.native/manifest.json | 23 + plugins/rms.vswr.report_reader/index.js | 165 + plugins/rms.vswr.report_reader/manifest.json | 20 + public/app.js | 4845 +++++++++++ public/favicon-32x32.png | Bin 0 -> 1053 bytes public/i18n/de.json | 108 + public/i18n/en.json | 108 + public/index.html | 519 ++ public/styles.css | 865 ++ public/uploads/logo-dark.png | Bin 0 -> 68 bytes public/uploads/logo-dark.svg | 204 + public/uploads/logo-light.svg | 204 + server/index.js | 7530 +++++++++++++++++ server/storage/custom-storage-template.js | 47 + server/storage/index.js | 60 + server/storage/providers/json.js | 102 + server/storage/providers/sqlite.js | 66 + test/auth-methods.integration.test.js | 1943 +++++ tools/check-windows-paths.ps1 | 53 + tools/migrate-json-to-sqlite.js | 53 + 58 files changed, 20611 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 WORKING_CONTEXT.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plugins/rms.auth.otp_email/index.js create mode 100644 plugins/rms.auth.otp_email/manifest.json create mode 100644 plugins/rms.auth.smtp_relay/index.js create mode 100644 plugins/rms.auth.smtp_relay/manifest.json create mode 100644 plugins/rms.debug.remote/index.js create mode 100644 plugins/rms.debug.remote/manifest.json create mode 100644 plugins/rms.help.basic/index.js create mode 100644 plugins/rms.help.basic/manifest.json create mode 100644 plugins/rms.microham/index.js create mode 100644 plugins/rms.microham/manifest.json create mode 100644 plugins/rms.openwebrx.bandmap/index.js create mode 100644 plugins/rms.openwebrx.bandmap/manifest.json create mode 100644 plugins/rms.openwebrx.guard/index.js create mode 100644 plugins/rms.openwebrx.guard/manifest.json create mode 100644 plugins/rms.rfroute.shell/index.js create mode 100644 plugins/rms.rfroute.shell/manifest.json create mode 100644 plugins/rms.rotor.hamlib/index.js create mode 100644 plugins/rms.rotor.hamlib/manifest.json create mode 100644 plugins/rms.station.access.policy/index.js create mode 100644 plugins/rms.station.access.policy/manifest.json create mode 100644 plugins/rms.station.shell/index.js create mode 100644 plugins/rms.station.shell/manifest.json create mode 100644 plugins/rms.tx.audio.core/index.js create mode 100644 plugins/rms.tx.audio.core/manifest.json create mode 100644 plugins/rms.tx.control.native/index.js create mode 100644 plugins/rms.tx.control.native/manifest.json create mode 100644 plugins/rms.tx.state.file/index.js create mode 100644 plugins/rms.tx.state.file/manifest.json create mode 100644 plugins/rms.vswr.nanovna/index.js create mode 100644 plugins/rms.vswr.nanovna/manifest.json create mode 100644 plugins/rms.vswr.native/index.js create mode 100644 plugins/rms.vswr.native/manifest.json create mode 100644 plugins/rms.vswr.report_reader/index.js create mode 100644 plugins/rms.vswr.report_reader/manifest.json create mode 100644 public/app.js create mode 100644 public/favicon-32x32.png create mode 100644 public/i18n/de.json create mode 100644 public/i18n/en.json create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 public/uploads/logo-dark.png create mode 100644 public/uploads/logo-dark.svg create mode 100644 public/uploads/logo-light.svg create mode 100644 server/index.js create mode 100644 server/storage/custom-storage-template.js create mode 100644 server/storage/index.js create mode 100644 server/storage/providers/json.js create mode 100644 server/storage/providers/sqlite.js create mode 100644 test/auth-methods.integration.test.js create mode 100644 tools/check-windows-paths.ps1 create mode 100644 tools/migrate-json-to-sqlite.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c315ab --- /dev/null +++ b/.env.example @@ -0,0 +1,88 @@ +PORT=8080 +DATA_DIR=./data +STORAGE_PROVIDER=json +STORAGE_SQLITE_PATH=./data/rms-storage.db +STORAGE_MODULE_PATH= +PLUGIN_DIR=./plugins +PRIMARY_EMAIL_DOMAIN=arcg.at +PUBLIC_BASE_URL= +SMTP_FROM=noreply@arcg.at +SMTP_REPLY_TO= +JWT_SECRET=change-me +JWT_ISSUER=rms-arcg +JWT_AUDIENCE=rms-clients +ACCESS_TOKEN_TTL_SEC=10800 +REFRESH_TOKEN_TTL_SEC=1209600 +SCRIPT_ROOT=/opt/remotestation +SCRIPT_ACTIVATE=/opt/remotestation/bin/activate.sh +SCRIPT_DEACTIVATE=/opt/remotestation/bin/deactivate.sh +SWR_CHECK_DURATION_MS=54000 +SWR_OVERVIEW_URL= +WEBSDR_URL= +ROTOR_CONTROL_URL= +RMS_ROTOR_DEV=/dev/rms-ftdi-uart +ROTOR_ROTCTL_MODEL=902 +VSWR_CHECK_CMD= +VSWR_METADATA_PATH= +VSWR_OVERVIEW_HTML_PATH= +VSWR_IMAGES_DIR_PATH= +VSWR_IMAGES_BASE_URL= +TX_STATE_PATH= +ROTOR_SET_TIMEOUT_MS=20000 +ROTOR_GET_TIMEOUT_MS=10000 +ROTOR_STATUS_RETRY_COUNT=6 +ROTOR_STATUS_RETRY_DELAY_MS=400 +ROTOR_POST_SET_STATUS_TIMEOUT_MS=5000 +ROTOR_SET_ENABLED=true +RFROUTE_CMD_TX= +RFROUTE_CMD_RX= +RFROUTE_CMD_ON= +RFROUTE_CMD_OFF= +RFROUTE_CMD_DRAHT= +RFROUTE_CMD_BEAM= +RFROUTE_CMD_WRTC= +ALLOW_NON_LINUX_CMDS=false +RMS_EXEC_MODE=dev +STATION_NAME=ARCG Stradnerkogel +ADMIN_EMAILS= +APPROVER_EMAILS= +AUTH_RATE_WINDOW_MS=600000 +AUTH_RATE_LIMIT=25 +ACTION_RATE_WINDOW_MS=60000 +ACTION_RATE_LIMIT=20 +AUTO_DISABLE_TX_BEFORE_ACTIVATION=false +STATION_MAX_USAGE_SEC=3600 +TX_ENABLE_CMD= +TX_DISABLE_CMD= +TX_STATUS_CMD= +TX_CONTROL_TIMEOUT_MS=20000 +OPENWEBRX_PATH=/sdr/ +OPENWEBRX_TICKET_TTL_SEC=120 +OPENWEBRX_START_CMD= +OPENWEBRX_STOP_CMD= +OPENWEBRX_CTRL_TIMEOUT_MS=20000 +OPENWEBRX_TX_POLL_MS=3000 +OPENWEBRX_PTT_BLOCKED_BAND_CONFIG_IDS=27,6,24 +MICROHAM_AUDIO_ENABLED=true +MICROHAM_AUDIO_ALSA_DEVICE=plughw:CARD=CODEC,DEV=0 +MICROHAM_AUDIO_INPUT_MIME=webm +MICROHAM_AUDIO_CHUNK_MS=100 +MICROHAM_AUDIO_STOP_ON_DISCONNECT=true +MICROHAM_AUDIO_SESSION_TIMEOUT_MS=120000 +MICROHAM_AUDIO_FFMPEG_PATH=ffmpeg +MICROHAM_AUDIO_FFMPEG_EXTRA_ARGS= +OPENWEBRX_BANDMAP_CSV_PATH= +OPENWEBRX_BAND_SET_CMD_TEMPLATE= +OPENWEBRX_CONFIG_PATH= +OPENWEBRX_BAND_STATE_PATH=./data/openwebrx-band-state.json +OPENWEBRX_BAND_TIMEOUT_MS=20000 +OPENWEBRX_PLUS_MANAGED_PROFILES=true +OPENWEBRX_PLUS_WATERFALL_AUTO_DEFAULT=true +MICROHAM_DEVICE=/dev/rms-microham-u3 +MICROHAM_SYNC_SETTINGS_FROM_ENV=false +MICROHAM_PTT_COMMANDS_ENABLED=false +MICROHAM_PTT_TIMEOUT_MS=5000 +MICROHAM_PTT_DOWN_CMD= +MICROHAM_PTT_UP_CMD= +OPENWEBRX_ACCESS_POLICY_FILE=./data/openwebrx-access-policy.txt +OPENWEBRX_PERSISTENT_USERS_FILE=./data/openwebrx-persistent-users.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f04d254 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +data/*.json +data/*.log +data/openwebrx-access-policy.txt +data/openwebrx-persistent-users.txt +!data/.gitkeep +logs/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b879dc3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +RMS Software +Copyright (C) 2026 + +SPDX-License-Identifier: GPL-3.0-only + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e6790e --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# RMS Software + +RMS Software is a generic remote-station control platform with a web UI, passwordless authentication, plugin-based hardware integration, and OpenWebRX+ orchestration. + +This repository is intentionally station-agnostic. Site-specific deployment, host bindings, and operational overrides belong in a separate deployment repository. + +## Core Features + +- Passwordless auth via email link and OTP methods +- JWT access/refresh session model with role-based permissions +- Plugin runtime with capability provider switching +- OpenWebRX+ session/ticket integration and managed band/profile support +- Station lifecycle controls (activate/deactivate, ownership, safety locks) +- Rotor, RF route, TX/PTT and VSWR integration via plugins +- Activity/audit logs and SSE event stream for live UI updates + +## Quick Start + +1. Copy `.env.example` to `.env`. +2. Install dependencies: + +```bash +npm ci +``` + +3. Start development mode: + +```bash +npm run dev +``` + +4. Open `http://localhost:8080`. + +## Plugin Overview + +Auth +- `rms.auth.smtp_relay`: email link challenge delivery through SMTP relay +- `rms.auth.otp_email`: OTP challenge delivery through email + +Station & Access +- `rms.station.shell`: station activation/deactivation command execution +- `rms.station.access.policy`: export effective access list for OpenWebRX policies + +OpenWebRX+ +- `rms.openwebrx.bandmap`: managed band/profile integration +- `rms.openwebrx.guard`: OpenWebRX safety and consistency guard + +TX/PTT/Audio +- `rms.tx.control.native`: native TX on/off command provider +- `rms.tx.state.file`: file-based TX state provider +- `rms.tx.audio.core`: core TX audio session management +- `rms.microham`: microHAM provider for PTT/audio related capabilities + +RF/Hardware +- `rms.rfroute.shell`: RF route switching via shell commands +- `rms.rotor.hamlib`: rotor control via hamlib/rotctl + +VSWR +- `rms.vswr.native`: native VSWR command provider +- `rms.vswr.nanovna`: NanoVNA-based VSWR provider +- `rms.vswr.report_reader`: reads and serves VSWR report artifacts + +Ops/Help +- `rms.debug.remote`: remote debug snapshot/log provider +- `rms.help.basic`: basic UI help content provider + +## Repository Boundaries + +This repository contains: +- generic software source (`server`, `public`, `plugins`) +- generic defaults (`.env.example`) + +This repository does not contain: +- station-specific hostnames, hardware paths, or production secrets +- deployment overlays for specific stations + +Recommended layering for deployments: +1. RMS Software defaults +2. Station-specific overlay repository +3. Server environment variables (highest priority) + +## License + +GPL-3.0-only. See `LICENSE`. diff --git a/WORKING_CONTEXT.md b/WORKING_CONTEXT.md new file mode 100644 index 0000000..24e2d98 --- /dev/null +++ b/WORKING_CONTEXT.md @@ -0,0 +1,47 @@ +# Working Context + +Last updated: 2026-03-16 + +## Purpose + +- This repository contains the generic RMS software stack. +- It is intentionally independent from a specific station deployment. +- Station-specific configuration and infrastructure binding belongs in separate overlay/deploy repositories. + +## Scope + +Included here: +- `server/` backend API and orchestration +- `public/` web UI +- `plugins/` plugin catalog and capabilities +- `test/` automated checks +- `tools/` local utility scripts + +Not included here: +- production secrets +- station hostnames and private endpoints +- station-specific bandmaps/bandplans/deploy service wiring + +## Configuration Layers + +Expected priority order: +1. RMS software defaults +2. Station overlay repository defaults +3. Runtime environment variables (highest priority) + +## Plugin Model + +- Plugins declare capabilities and optional UI controls in `manifest.json`. +- Providers can be switched per capability at runtime. +- Auth methods are plugin-based (`authMethod` in manifest). + +## Deploy Contract + +- Deploy repositories should fetch this repository as software source. +- Deploy repositories may overlay files for station-specific behavior. +- Deploy repositories must not patch in secrets; secrets come from env files on target systems. + +## Current Baseline + +- Access token default/minimum baseline is 3 hours. +- OpenWebRX+ integrations remain plugin- and deploy-driven; station-specific profile/bandplan data stays outside this repo. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1b1602d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "remotestation-arcg", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "remotestation-arcg", + "version": "0.1.0", + "dependencies": { + "nodemailer": "^8.0.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..489b87b --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "remotestation-arcg", + "version": "0.1.0", + "private": true, + "description": "Neue ARCG Weboberflaeche fuer die Amateurfunk-Remotestation", + "main": "server/index.js", + "scripts": { + "start": "node server/index.js", + "dev": "node server/index.js", + "prod": "node server/index.js", + "test": "node --test", + "migrate:json-to-sqlite": "node tools/migrate-json-to-sqlite.js" + }, + "dependencies": { + "nodemailer": "^8.0.1", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/plugins/rms.auth.otp_email/index.js b/plugins/rms.auth.otp_email/index.js new file mode 100644 index 0000000..ea60138 --- /dev/null +++ b/plugins/rms.auth.otp_email/index.js @@ -0,0 +1,32 @@ +async function createPlugin(ctx) { + return { + async execute(action, input) { + if (action !== "send_challenge") { + throw new Error(`Unknown action: ${action}`); + } + const payload = input && input.payload ? input.payload : {}; + const recipient = input && input.recipient ? String(input.recipient) : ""; + if (!recipient) { + throw new Error("recipient missing"); + } + const entry = { + at: new Date().toISOString(), + via: "rms.auth.otp_email", + to: recipient, + from: String(ctx.getSetting("from", ctx.env.SMTP_FROM || "noreply@arcg.at")), + subject: String(payload.subject || "ARCG OTP"), + text: String(payload.text || ""), + html: String(payload.html || "") + }; + await ctx.appendMailOutbox(entry); + return { ok: true, delivered: true, transport: "otp-email-plugin" }; + }, + async health() { + return { ok: true }; + } + }; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.auth.otp_email/manifest.json b/plugins/rms.auth.otp_email/manifest.json new file mode 100644 index 0000000..3c9b2bc --- /dev/null +++ b/plugins/rms.auth.otp_email/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "rms.auth.otp_email", + "name": "OTP per E-Mail", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [], + "authMethod": { + "id": "otp-email", + "type": "otp", + "label": "OTP (E-Mail)" + }, + "settingsSchema": { + "type": "object", + "properties": { + "from": { "type": "string" } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/plugins/rms.auth.smtp_relay/index.js b/plugins/rms.auth.smtp_relay/index.js new file mode 100644 index 0000000..8f0108a --- /dev/null +++ b/plugins/rms.auth.smtp_relay/index.js @@ -0,0 +1,101 @@ +let nodemailer = null; +try { + nodemailer = require("nodemailer"); +} catch { + nodemailer = null; +} + +async function createPlugin(ctx) { + let transporter = null; + let transportKey = ""; + + function readTransportConfig() { + const host = String(ctx.getSetting("host", ctx.env.SMTP_HOST || "")).trim(); + const portRaw = Number(ctx.getSetting("port", ctx.env.SMTP_PORT || 587)); + const secure = String(ctx.getSetting("secure", ctx.env.SMTP_SECURE || "false")) === "true"; + const authUser = String(ctx.getSetting("authUser", ctx.env.SMTP_USER || "")).trim(); + const authPass = String(ctx.getSetting("authPass", ctx.env.SMTP_PASS || "")).trim(); + const allowInvalidCert = String(ctx.getSetting("allowInvalidCert", ctx.env.SMTP_ALLOW_INVALID_CERT || "false")) === "true"; + return { + host, + port: Number.isFinite(portRaw) && portRaw > 0 ? portRaw : 587, + secure, + authUser, + authPass, + allowInvalidCert + }; + } + + async function deliverViaSmtp(entry) { + const config = readTransportConfig(); + if (!config.host || !nodemailer) { + return false; + } + const key = JSON.stringify(config); + if (!transporter || transportKey !== key) { + transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.authUser ? { user: config.authUser, pass: config.authPass } : undefined, + tls: config.allowInvalidCert ? { rejectUnauthorized: false } : undefined + }); + transportKey = key; + } + + await transporter.sendMail({ + from: entry.from, + to: entry.to, + replyTo: entry.replyTo || undefined, + subject: entry.subject, + text: entry.text, + html: entry.html || undefined + }); + return true; + } + + return { + async execute(action, input) { + if (action !== "send_challenge") { + throw new Error(`Unknown action: ${action}`); + } + const payload = input && input.payload ? input.payload : {}; + const recipient = input && input.recipient ? String(input.recipient) : ""; + if (!recipient) { + throw new Error("recipient missing"); + } + + const entry = { + at: new Date().toISOString(), + via: "rms.auth.smtp_relay", + to: recipient, + from: String(ctx.getSetting("from", ctx.env.SMTP_FROM || "noreply@arcg.at")), + replyTo: String(ctx.getSetting("replyTo", ctx.env.SMTP_REPLY_TO || "")), + subject: String(payload.subject || "ARCG Login"), + text: String(payload.text || ""), + html: String(payload.html || "") + }; + let delivered = false; + try { + delivered = await deliverViaSmtp(entry); + } catch (error) { + entry.smtpError = String(error && error.message ? error.message : error); + } + entry.delivered = delivered; + entry.transport = delivered ? "smtp" : "outbox-fallback"; + await ctx.appendMailOutbox(entry); + return { ok: true, delivered, transport: delivered ? "smtp" : "outbox-fallback" }; + }, + async health() { + const config = readTransportConfig(); + if (config.host && !nodemailer) { + return { ok: false, message: "nodemailer nicht installiert" }; + } + return { ok: true }; + } + }; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.auth.smtp_relay/manifest.json b/plugins/rms.auth.smtp_relay/manifest.json new file mode 100644 index 0000000..99f9c57 --- /dev/null +++ b/plugins/rms.auth.smtp_relay/manifest.json @@ -0,0 +1,27 @@ +{ + "id": "rms.auth.smtp_relay", + "name": "SMTP Relay Auth", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [], + "authMethod": { + "id": "smtp-link", + "type": "link", + "label": "per Mail" + }, + "settingsSchema": { + "type": "object", + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "secure": { "type": "boolean" }, + "authUser": { "type": "string" }, + "authPass": { "type": "string" }, + "from": { "type": "string" }, + "replyTo": { "type": "string" }, + "allowInvalidCert": { "type": "boolean" } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/plugins/rms.debug.remote/index.js b/plugins/rms.debug.remote/index.js new file mode 100644 index 0000000..1371359 --- /dev/null +++ b/plugins/rms.debug.remote/index.js @@ -0,0 +1,335 @@ +const fs = require("fs"); +const fsp = require("fs/promises"); +const path = require("path"); + +const DEFAULT_TOKEN = "dbg_8f4e5c9b71a64f2fa3e7c1d6b5a9e2f0_owrx"; + +async function createPlugin(ctx) { + return { + async execute(action, input = {}) { + if (action === "collectOwrxLogs") { + return collectLogs(ctx, "owrx", input); + } + if (action === "getOwrxSnapshot") { + return getSnapshot(ctx, "owrx"); + } + if (action === "clearOwrxLogs") { + return clearLogs(ctx, "owrx"); + } + if (action === "collectUsbLogs") { + return collectLogs(ctx, "usb", input); + } + if (action === "getUsbSnapshot") { + return getSnapshot(ctx, "usb"); + } + if (action === "clearUsbLogs") { + return clearLogs(ctx, "usb"); + } + if (action === "whichBinary") { + return whichBinary(ctx, input); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + const settings = resolveSettings(ctx); + const owrxSnapshot = await readSnapshot(ctx, "owrx"); + const usbSnapshot = await readSnapshot(ctx, "usb"); + const whichSnapshot = await readSnapshot(ctx, "which"); + return { + enabled: settings.enabled, + unitName: settings.unitName, + collectLines: settings.collectLines, + redactSensitive: settings.redactSensitive, + tokenConfigured: Boolean(settings.remoteToken), + owrxCollectedAt: owrxSnapshot && owrxSnapshot.collectedAt ? owrxSnapshot.collectedAt : null, + owrxKeptLines: numberOrZero(owrxSnapshot && owrxSnapshot.keptLines), + usbCollectedAt: usbSnapshot && usbSnapshot.collectedAt ? usbSnapshot.collectedAt : null, + usbKeptLines: numberOrZero(usbSnapshot && usbSnapshot.keptLines), + whichCollectedAt: whichSnapshot && whichSnapshot.collectedAt ? whichSnapshot.collectedAt : null + }; + }, + async health() { + return { ok: true }; + } + }; +} + +async function collectLogs(ctx, scope, input) { + const settings = resolveSettings(ctx); + if (!settings.enabled) { + return { + ok: false, + accepted: false, + message: "Debug endpoint deaktiviert" + }; + } + const lineCount = Number.isFinite(Number(input && input.lines)) + ? clamp(Math.trunc(Number(input.lines)), 50, 4000) + : settings.collectLines; + const collection = scope === "usb" + ? await collectUsb(ctx, lineCount) + : await collectOwrx(ctx, settings.unitName, lineCount); + const includePatterns = scope === "usb" ? settings.includePatternsUsb : settings.includePatterns; + const keptLines = filterLines(collection.lines, includePatterns, settings.redactSensitive); + const paths = resolvePaths(ctx, scope); + await fsp.mkdir(paths.debugDir, { recursive: true }); + const collectedAt = new Date().toISOString(); + await fsp.writeFile(paths.logFilePath, keptLines.join("\n") + (keptLines.length ? "\n" : ""), "utf8"); + const snapshot = { + ok: true, + scope, + collectedAt, + collectLines: lineCount, + totalLines: collection.lines.length, + keptLines: keptLines.length, + unitName: scope === "owrx" ? settings.unitName : undefined, + commands: collection.commands + }; + await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); + ctx.emit("debug.remote.collected", { + scope, + totalLines: collection.lines.length, + keptLines: keptLines.length + }); + return { + ok: true, + accepted: true, + message: `Debug-Log gesammelt (${scope}, ${keptLines.length}/${collection.lines.length} Zeilen)`, + scope, + collectedAt, + totalLines: collection.lines.length, + keptLines: keptLines.length, + outputPath: paths.logFilePath, + snapshotPath: paths.snapshotFilePath + }; +} + +async function collectOwrx(ctx, unitName, lineCount) { + const command = `journalctl -u ${unitName} -n ${lineCount} --no-pager -o short-iso`; + const result = await ctx.commandRunner(command, { timeoutMs: 12000 }); + const raw = String(result.stdout || result.stderr || ""); + return { + lines: raw.split(/\r?\n/).filter(Boolean), + commands: [{ command, ok: Boolean(result.ok), code: result.code }] + }; +} + +async function collectUsb(ctx, lineCount) { + const commands = [ + "lsusb", + "lsusb -t", + `journalctl -k -n ${lineCount} --no-pager -o short-iso`, + "dmesg -T | tail -n 300" + ]; + const lines = []; + const meta = []; + for (const command of commands) { + const result = await ctx.commandRunner(command, { timeoutMs: 12000 }); + const raw = String(result.stdout || result.stderr || "").trim(); + lines.push(`[cmd] ${command}`); + lines.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`); + if (raw) { + for (const line of raw.split(/\r?\n/)) { + lines.push(line); + } + } + lines.push(""); + meta.push({ command, ok: Boolean(result.ok), code: result.code }); + } + return { + lines: lines.filter((line, index, all) => line || (index > 0 && all[index - 1])), + commands: meta + }; +} + +async function getSnapshot(ctx, scope) { + const snapshot = await readSnapshot(ctx, scope); + return { + ok: true, + accepted: true, + message: "Debug-Snapshot geladen", + scope, + snapshot: snapshot || null + }; +} + +async function clearLogs(ctx, scope) { + const paths = resolvePaths(ctx, scope); + await fsp.mkdir(paths.debugDir, { recursive: true }); + await fsp.writeFile(paths.logFilePath, "", "utf8"); + const snapshot = { + ok: true, + scope, + collectedAt: new Date().toISOString(), + cleared: true, + totalLines: 0, + keptLines: 0 + }; + await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); + ctx.emit("debug.remote.cleared", { scope, cleared: true }); + return { + ok: true, + accepted: true, + message: "Debug-Log geloescht", + scope, + snapshot + }; +} + +async function whichBinary(ctx, input) { + const settings = resolveSettings(ctx); + if (!settings.enabled) { + return { + ok: false, + accepted: false, + message: "Debug endpoint deaktiviert" + }; + } + const name = String((input && (input.name || input.binary)) || "ffmpeg").trim().toLowerCase(); + if (!/^[a-z0-9._+-]{1,64}$/.test(name)) { + throw new Error("ungueltiger binary name"); + } + + const found = await resolveBinaryPath(ctx, name); + const paths = resolvePaths(ctx, "which"); + await fsp.mkdir(paths.debugDir, { recursive: true }); + const collectedAt = new Date().toISOString(); + const line = `${collectedAt} ${name} => ${found.path || "NOT_FOUND"}`; + await fsp.appendFile(paths.logFilePath, `${line}\n`, "utf8"); + const snapshot = { + ok: true, + scope: "which", + collectedAt, + name, + found: found.found, + path: found.path || null + }; + await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); + + return { + ok: true, + accepted: true, + scope: "which", + name, + found: found.found, + path: found.path || null, + outputPath: paths.logFilePath, + snapshotPath: paths.snapshotFilePath + }; +} + +async function resolveBinaryPath(ctx, name) { + if (process.platform === "win32") { + const whereResult = await ctx.commandRunner(`where ${name}`, { timeoutMs: 4000 }); + const pathLine = whereResult.ok ? firstLine(whereResult.stdout || "") : ""; + return { found: Boolean(pathLine), path: pathLine || "" }; + } + const commandResult = await ctx.commandRunner(`command -v ${name}`, { timeoutMs: 4000 }); + let pathLine = commandResult.ok ? firstLine(commandResult.stdout || "") : ""; + if (!pathLine) { + const whichResult = await ctx.commandRunner(`which ${name}`, { timeoutMs: 4000 }); + pathLine = whichResult.ok ? firstLine(whichResult.stdout || "") : ""; + } + return { found: Boolean(pathLine), path: pathLine || "" }; +} + +function firstLine(value) { + const lines = String(value || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return lines.length > 0 ? lines[0] : ""; +} + +function resolveSettings(ctx) { + const collectLinesRaw = Number(ctx.getSetting("collectLines", 800)); + const includePatternsRaw = String(ctx.getSetting("includePatterns", "")).trim(); + const includePatternsUsbRaw = String(ctx.getSetting("includePatternsUsb", "")).trim(); + return { + enabled: ctx.getSetting("enabled", true) !== false, + remoteToken: String(ctx.getSetting("remoteToken", DEFAULT_TOKEN)).trim(), + collectLines: Number.isFinite(collectLinesRaw) ? clamp(Math.trunc(collectLinesRaw), 100, 4000) : 800, + unitName: String(ctx.getSetting("unitName", "remotestation-arcg")).trim() || "remotestation-arcg", + redactSensitive: ctx.getSetting("redactSensitive", true) !== false, + includePatterns: includePatternsRaw + ? includePatternsRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + : defaultOwrxPatterns(), + includePatternsUsb: includePatternsUsbRaw + ? includePatternsUsbRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + : [] + }; +} + +function resolvePaths(ctx, scope) { + const safeScope = scope === "usb" ? "usb" : (scope === "which" ? "which" : "owrx"); + const dataDirRaw = String(ctx.env.DATA_DIR || "").trim(); + const dataDir = dataDirRaw + ? (path.isAbsolute(dataDirRaw) ? dataDirRaw : path.resolve(ctx.rootDir, dataDirRaw)) + : path.resolve(ctx.rootDir, "data"); + const debugDir = path.join(dataDir, "debug"); + return { + debugDir, + logFilePath: path.join(debugDir, `${safeScope}-debug.log`), + snapshotFilePath: path.join(debugDir, `${safeScope}-debug-snapshot.json`) + }; +} + +async function readSnapshot(ctx, scope) { + const paths = resolvePaths(ctx, scope); + if (!fs.existsSync(paths.snapshotFilePath)) { + return null; + } + try { + const raw = await fsp.readFile(paths.snapshotFilePath, "utf8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +function filterLines(lines, includePatterns, redactSensitive) { + const patterns = Array.isArray(includePatterns) + ? includePatterns.map((entry) => String(entry || "").toLowerCase()).filter(Boolean) + : []; + const out = []; + for (const line of lines) { + const normalized = String(line || ""); + const lower = normalized.toLowerCase(); + if (patterns.length > 0 && !patterns.some((entry) => lower.includes(entry))) { + continue; + } + out.push(redactSensitive ? redactLine(normalized) : normalized); + } + return out; +} + +function redactLine(line) { + let next = String(line || ""); + next = next.replace(/(accessToken=)[^\s&]+/gi, "$1[redacted]"); + next = next.replace(/(ticket=)[^\s&]+/gi, "$1[redacted]"); + next = next.replace(/(Authorization:\s*Bearer\s+)[A-Za-z0-9_\-.]+/gi, "$1[redacted]"); + next = next.replace(/(eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.)[A-Za-z0-9_\-]+/g, "$1[redacted]"); + return next; +} + +function defaultOwrxPatterns() { + return [ + "openwebrx/plugin/state", + "openwebrx/plugin/bands/select", + "openwebrx/plugin/audio/connect", + "openwebrx/rotor/status", + "upstream timed out", + "connection refused", + "prematurely closed" + ]; +} + +function numberOrZero(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.debug.remote/manifest.json b/plugins/rms.debug.remote/manifest.json new file mode 100644 index 0000000..7075ea2 --- /dev/null +++ b/plugins/rms.debug.remote/manifest.json @@ -0,0 +1,94 @@ +{ + "id": "rms.debug.remote", + "name": "RMS Debug Remote", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "admin.debug.remote" + ], + "settingsSchema": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "remoteToken": { "type": "string" }, + "collectLines": { "type": "integer", "minimum": 100, "maximum": 4000 }, + "unitName": { "type": "string" }, + "redactSensitive": { "type": "boolean" }, + "includePatterns": { "type": "string" }, + "includePatternsUsb": { "type": "string" } + }, + "additionalProperties": false + }, + "uiControls": [ + { + "controlId": "debug-remote-owrx", + "controlType": "switch.group", + "title": "Debug OpenWebRX", + "capability": "admin.debug.remote", + "actions": [ + { + "name": "collectOwrxLogs", + "inputSchema": { + "type": "object", + "properties": { + "lines": { "type": "integer", "minimum": 50, "maximum": 4000 } + }, + "additionalProperties": false + } + }, + { + "name": "getOwrxSnapshot", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "clearOwrxLogs", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "collectUsbLogs", + "inputSchema": { + "type": "object", + "properties": { + "lines": { "type": "integer", "minimum": 50, "maximum": 4000 } + }, + "additionalProperties": false + } + }, + { + "name": "getUsbSnapshot", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "clearUsbLogs", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "whichBinary", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "additionalProperties": false + } + } + ] + } + ] +} diff --git a/plugins/rms.help.basic/index.js b/plugins/rms.help.basic/index.js new file mode 100644 index 0000000..025a8d1 --- /dev/null +++ b/plugins/rms.help.basic/index.js @@ -0,0 +1,89 @@ +async function createPlugin() { + return { + async execute(action) { + if (action === "getContent") { + return buildHelpContent(); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + const content = buildHelpContent(); + return { + sections: content.sections.length, + version: content.version + }; + }, + async health() { + return { ok: true }; + } + }; +} + +function buildHelpContent() { + return { + version: 1, + title: "RMS Hilfe", + quickStart: { + title: "Schnellstart in 5 Schritten", + steps: [ + "Stationsstatus pruefen und sicherstellen, dass die Station frei ist.", + "Station aktivieren und auf den Abschluss des SWR-Checks warten.", + "Im OpenWebRX-Bereich 'OpenWebRX laden' waehlen und mit 'SDR oeffnen' starten.", + "QSO fuehren und TX nur bei Bedarf aktivieren.", + "Nach dem Betrieb TX deaktivieren und Station freigeben." + ] + }, + sections: [ + { + id: "station-use", + title: "Station uebernehmen und freigeben", + body: [ + "Die Station darf immer nur von einem Benutzer gleichzeitig aktiv genutzt werden.", + "Nach der Aktivierung siehst du den aktiven Benutzer, Startzeit und Restzeit.", + "Beende jede Session sauber ueber 'Station deaktivieren', damit nachfolgende Nutzer sofort starten koennen." + ] + }, + { + id: "openwebrx", + title: "OpenWebRX und TX Ablauf", + body: [ + "OpenWebRX ist nur fuer den aktuell aktiven Stationsbenutzer freigegeben.", + "Bandwechsel und Antennenroute erfolgen automatisch ueber die Bandmap und den Runtime-Pfad.", + "TX wird bewusst getrennt gesteuert: erst OpenWebRX starten, dann bei Bedarf TX aktivieren." + ] + }, + { + id: "safety", + title: "Sicherheitsregeln", + body: [ + "SWR-Checks sind gesperrt, solange die Station aktiv ist.", + "Wenn TX aktiv ist, sind sicherheitskritische Schaltvorgaenge blockiert.", + "Fehlermeldungen mit 409 zeigen in der Regel einen aktiven Schutzmechanismus an, nicht einen Systemfehler." + ] + }, + { + id: "troubleshooting", + title: "Hauefige Probleme", + body: [ + "Kein OpenWebRX Zugriff: pruefe, ob du der aktive Stationsbenutzer bist.", + "Schaltvorgang gesperrt: pruefe, ob TX noch aktiv ist und deaktiviere TX zuerst.", + "OpenWebRX endet nach laengerer Nutzung: Sessionzeit ist erreicht, Station erneut aktivieren.", + "Ruckeln oder Aussetzer: zuerst neu verbinden, dann ggf. Band erneut waehlen." + ] + }, + { + id: "operating-rules", + title: "Betriebsregeln", + body: [ + "Vor Senden immer Empfangslage und Bandbelegung plausibilisieren.", + "Unklare Anlagenzustande nie mit TX erzwingen, sondern zuerst pruefen oder Ruecksprache halten.", + "Die Station fair nutzen und bei laengeren Pausen zeitnah wieder freigeben." + ] + } + ] + }; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.help.basic/manifest.json b/plugins/rms.help.basic/manifest.json new file mode 100644 index 0000000..a8077cd --- /dev/null +++ b/plugins/rms.help.basic/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "rms.help.basic", + "name": "RMS Hilfe Basic", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "help.content.read" + ], + "settingsSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/plugins/rms.microham/index.js b/plugins/rms.microham/index.js new file mode 100644 index 0000000..d05c359 --- /dev/null +++ b/plugins/rms.microham/index.js @@ -0,0 +1,601 @@ +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); + +async function createPlugin(ctx) { + const state = { + pttActive: false, + audio: { + ffmpeg: null, + clients: new Set(), + running: false, + startedAt: null, + ownerUserId: null, + alsaDevice: null, + stopRequested: false, + lastError: null, + lastExit: null, + idleTimer: null + } + }; + + return { + async execute(action, input = {}) { + if (action === "pttDown") { + return pttSet(ctx, state, true, input); + } + if (action === "pttUp") { + return pttSet(ctx, state, false, input); + } + if (action === "pttStatus") { + return pttStatus(ctx, state); + } + if (action === "audioConnect") { + return audioConnect(ctx, state, input); + } + if (action === "backendStart") { + return audioConnect(ctx, state, input); + } + if (action === "audioDisconnect") { + return audioDisconnect(ctx, state, input); + } + if (action === "backendStop") { + return audioDisconnect(ctx, state, input); + } + if (action === "audioStatus") { + return audioStatus(ctx, state, input); + } + if (action === "backendStatus") { + return audioStatus(ctx, state, input); + } + if (action === "audioRegisterClient") { + return audioRegisterClient(ctx, state, input); + } + if (action === "backendRegisterClient") { + return audioRegisterClient(ctx, state, input); + } + if (action === "audioUnregisterClient") { + return audioUnregisterClient(ctx, state, input); + } + if (action === "backendUnregisterClient") { + return audioUnregisterClient(ctx, state, input); + } + if (action === "audioWriteChunk") { + return audioWriteChunk(ctx, state, input); + } + if (action === "backendWrite") { + return audioWriteChunk(ctx, state, input); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + return { + ptt: pttStatus(ctx, state), + audio: audioStatus(ctx, state, {}) + }; + }, + async stop() { + await stopAudioBackend(ctx, state, "plugin-stop"); + }, + async health() { + return { ok: true }; + } + }; +} + +function cfg(ctx) { + const device = String(ctx.getSetting("device", ctx.env.MICROHAM_DEVICE || "/dev/rms-microham-u3")).trim() || "/dev/rms-microham-u3"; + const pttCommandsEnabled = asBool(ctx.getSetting("pttCommandsEnabled", ctx.env.MICROHAM_PTT_COMMANDS_ENABLED || "false")); + const pttDownCommand = String(ctx.getSetting("pttDownCommand", ctx.env.MICROHAM_PTT_DOWN_CMD || "")).trim(); + const pttUpCommand = String(ctx.getSetting("pttUpCommand", ctx.env.MICROHAM_PTT_UP_CMD || "")).trim(); + const pttTimeoutMs = clampNum(ctx.getSetting("pttTimeoutMs", ctx.env.MICROHAM_PTT_TIMEOUT_MS || 5000), 1000, 60000, 5000); + const pttApplyBandState = asBool(ctx.getSetting("pttApplyBandState", ctx.env.MICROHAM_PTT_APPLY_BAND_STATE || "true")); + const pttRigctlModel = String(ctx.getSetting("pttRigctlModel", ctx.env.MICROHAM_PTT_RIGCTL_MODEL || "3023")).trim() || "3023"; + const pttRigctlBaud = String(ctx.getSetting("pttRigctlBaud", ctx.env.MICROHAM_PTT_RIGCTL_BAUD || "19200")).trim() || "19200"; + const pttRigctlSetConf = String(ctx.getSetting("pttRigctlSetConf", ctx.env.MICROHAM_PTT_RIGCTL_SETCONF || "rts_state=OFF,dtr_state=OFF")).trim() || "rts_state=OFF,dtr_state=OFF"; + + const audioEnabled = asBool(ctx.getSetting("audioEnabled", ctx.env.MICROHAM_AUDIO_ENABLED || "true")); + const audioAlsaDevice = String(ctx.getSetting("audioAlsaDevice", ctx.env.MICROHAM_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0")).trim() || "plughw:CARD=CODEC,DEV=0"; + const audioInputMime = String(ctx.getSetting("audioInputMime", ctx.env.MICROHAM_AUDIO_INPUT_MIME || "webm")).trim().toLowerCase() === "ogg" ? "ogg" : "webm"; + const audioStopOnDisconnect = asBool(ctx.getSetting("audioStopOnDisconnect", ctx.env.MICROHAM_AUDIO_STOP_ON_DISCONNECT || "true")); + const audioChunkMs = clampNum(ctx.getSetting("audioChunkMs", ctx.env.MICROHAM_AUDIO_CHUNK_MS || 100), 40, 2000, 100); + const audioSessionTimeoutMs = clampNum(ctx.getSetting("audioSessionTimeoutMs", ctx.env.MICROHAM_AUDIO_SESSION_TIMEOUT_MS || 120000), 1000, 3600000, 120000); + const audioFfmpegPath = String(ctx.getSetting("audioFfmpegPath", ctx.env.MICROHAM_AUDIO_FFMPEG_PATH || "")).trim(); + const audioFfmpegExtraArgs = String(ctx.getSetting("audioFfmpegExtraArgs", ctx.env.MICROHAM_AUDIO_FFMPEG_EXTRA_ARGS || "")).trim(); + + return { + device, + pttCommandsEnabled, + pttDownCommand, + pttUpCommand, + pttTimeoutMs, + pttApplyBandState, + pttRigctlModel, + pttRigctlBaud, + pttRigctlSetConf, + audioEnabled, + audioAlsaDevice, + audioInputMime, + audioStopOnDisconnect, + audioChunkMs, + audioSessionTimeoutMs, + audioFfmpegPath, + audioFfmpegExtraArgs + }; +} + +function pttStatus(ctx, state) { + const c = cfg(ctx); + return { + active: Boolean(state.pttActive), + commandConfigured: Boolean(c.pttCommandsEnabled && c.pttDownCommand && c.pttUpCommand), + device: c.device, + enabled: c.pttCommandsEnabled + }; +} + +async function pttSet(ctx, state, down, input = {}) { + const c = cfg(ctx); + if (!c.pttCommandsEnabled) { + throw new Error("MICROHAM_PTT_COMMANDS_ENABLED must be true"); + } + const useBatchDown = Boolean(down && c.pttApplyBandState); + const template = down ? c.pttDownCommand : c.pttUpCommand; + if (!template && !useBatchDown) { + throw new Error(down ? "MICROHAM_PTT_DOWN_CMD missing" : "MICROHAM_PTT_UP_CMD missing"); + } + if (!/^\/dev\/[A-Za-z0-9._\/-]+$/.test(c.device)) { + throw new Error(`invalid microham device path: ${c.device}`); + } + let command = ""; + if (useBatchDown) { + command = buildRigctlBatchPttDownCommand(c, input); + } else { + command = renderPttCommandTemplate(template, c.device, input); + } + const result = await ctx.commandRunner(command, { timeoutMs: c.pttTimeoutMs }); + if (!result.ok) { + throw new Error(result.stderr || result.error || `ptt ${down ? "down" : "up"} command failed`); + } + state.pttActive = Boolean(down); + return { + ok: true, + active: state.pttActive, + direction: down ? "down" : "up" + }; +} + +function buildRigctlBatchPttDownCommand(c, input) { + const bandState = input && input.bandState && typeof input.bandState === "object" ? input.bandState : {}; + const centerFreqHz = Number.isFinite(Number(bandState.centerFreqHz)) ? Math.floor(Number(bandState.centerFreqHz)) : null; + const startMod = String(bandState.startMod || "").trim().toLowerCase(); + const rigMode = mapStartModToRigMode(startMod); + if (centerFreqHz === null || !rigMode) { + throw new Error("live frequency/mode unavailable for rigctl batch PTT down"); + } + + const model = String(c.pttRigctlModel || "3023").trim(); + const baud = String(c.pttRigctlBaud || "19200").trim(); + const setConf = String(c.pttRigctlSetConf || "rts_state=OFF,dtr_state=OFF").trim(); + if (!/^\d+$/.test(model)) { + throw new Error(`invalid rigctl model: ${model}`); + } + if (!/^\d+$/.test(baud)) { + throw new Error(`invalid rigctl baud: ${baud}`); + } + if (!/^[A-Za-z0-9_=,.-]+$/.test(setConf)) { + throw new Error(`invalid rigctl set-conf: ${setConf}`); + } + + return `printf '%s\\n' 'F ${centerFreqHz}' 'M ${rigMode} 0' 'T 1' | rigctl -m${model} -r ${c.device} -s ${baud} --set-conf=${setConf}`; +} + +function renderPttCommandTemplate(template, device, input) { + const bandState = input && input.bandState && typeof input.bandState === "object" ? input.bandState : {}; + const centerFreqHz = Number.isFinite(Number(bandState.centerFreqHz)) ? Math.floor(Number(bandState.centerFreqHz)) : null; + const startMod = String(bandState.startMod || "").trim().toLowerCase(); + const rigMode = mapStartModToRigMode(startMod); + + if (template.includes("{centerFreqHz}") && centerFreqHz === null) { + throw new Error("center frequency unavailable for PTT command"); + } + if ((template.includes("{rigMode}") || template.includes("{mode}")) && !rigMode) { + throw new Error("start mode unavailable for PTT command"); + } + + const replacements = { + "{device}": device, + "{pttDevice}": device, + "{microhamDevice}": device, + "{centerFreqHz}": centerFreqHz !== null ? String(centerFreqHz) : "", + "{frequencyHz}": centerFreqHz !== null ? String(centerFreqHz) : "", + "{freqHz}": centerFreqHz !== null ? String(centerFreqHz) : "", + "{centerFreqKHz}": centerFreqHz !== null ? String(Math.floor(centerFreqHz / 1000)) : "", + "{startMod}": startMod, + "{rigMode}": rigMode, + "{mode}": rigMode + }; + + let command = String(template || ""); + for (const [key, value] of Object.entries(replacements)) { + command = command.replaceAll(key, value); + } + return command; +} + +function mapStartModToRigMode(startMod) { + const value = String(startMod || "").trim().toLowerCase(); + if (!value) { + return ""; + } + if (value === "usb") { + return "USB"; + } + if (value === "lsb") { + return "LSB"; + } + if (value === "am") { + return "AM"; + } + if (value === "fm" || value === "wfm" || value === "nfm") { + return "FM"; + } + if (value === "cw") { + return "CW"; + } + if (value === "cwr") { + return "CWR"; + } + return value.toUpperCase(); +} + +function audioStatus(ctx, state, input) { + const c = cfg(ctx); + const callerUserId = String((input && input.userId) || "").trim() || null; + let mode = "disconnected"; + if (!c.audioEnabled) { + mode = "disabled"; + } else if (state.audio.running) { + mode = "running"; + } else if (state.audio.lastError) { + mode = "error"; + } + return { + providerId: "rms.microham", + providerEnabled: true, + enabled: c.audioEnabled, + state: mode, + running: Boolean(state.audio.running), + clients: state.audio.clients.size, + ownerUserId: state.audio.ownerUserId || null, + ownerMatchesCaller: Boolean(state.audio.ownerUserId && callerUserId && state.audio.ownerUserId === callerUserId), + startedAt: state.audio.startedAt, + lastError: state.audio.lastError, + lastExit: state.audio.lastExit, + ffmpegPath: resolveFfmpegPath(c.audioFfmpegPath), + alsaDevice: state.audio.alsaDevice || c.audioAlsaDevice, + chunkMs: c.audioChunkMs, + wsPath: "/v1/openwebrx/plugin/audio/ws" + }; +} + +async function audioConnect(ctx, state, input) { + const userId = String((input && input.userId) || "").trim(); + if (!userId) { + throw new Error("microham audio requires userId"); + } + await ensureAudioBackendForOwner(ctx, state, userId, String((input && input.reason) || "api-connect") || "api-connect"); + return { ok: true, audio: audioStatus(ctx, state, { userId }) }; +} + +async function audioDisconnect(ctx, state, input) { + await stopAudioBackend(ctx, state, String((input && input.reason) || "api-disconnect") || "api-disconnect"); + return { ok: true, audio: audioStatus(ctx, state, { userId: String((input && input.userId) || "") }) }; +} + +async function audioRegisterClient(ctx, state, input) { + const ws = input && input.ws; + if (!ws) { + throw new Error("audioRegisterClient missing ws"); + } + const userId = String((input && input.userId) || "").trim(); + if (!userId) { + throw new Error("audioRegisterClient missing userId"); + } + await ensureAudioBackendForOwner(ctx, state, userId, String((input && input.reason) || "ws-connect") || "ws-connect"); + state.audio.clients.add(ws); + clearIdleTimer(state); + return { ok: true, clients: state.audio.clients.size }; +} + +async function audioUnregisterClient(ctx, state, input) { + const ws = input && input.ws; + if (ws) { + state.audio.clients.delete(ws); + } + const c = cfg(ctx); + if (state.audio.clients.size === 0 && c.audioStopOnDisconnect) { + await stopAudioBackend(ctx, state, String((input && input.reason) || "ws-disconnect") || "ws-disconnect"); + } else if (state.audio.clients.size === 0) { + scheduleIdleStop(ctx, state); + } + return { ok: true, clients: state.audio.clients.size }; +} + +async function audioWriteChunk(ctx, state, input) { + const ws = input && input.ws; + const userId = String((input && input.userId) || "").trim(); + if (!userId) { + return { ok: false, skipped: true }; + } + if (!state.audio.running || !state.audio.ffmpeg || !state.audio.ffmpeg.stdin || state.audio.ffmpeg.stdin.destroyed) { + await ensureAudioBackendForOwner(ctx, state, userId, "ws-message"); + } + if (ws && !state.audio.clients.has(ws)) { + state.audio.clients.add(ws); + } + const proc = state.audio.ffmpeg; + if (!proc || !proc.stdin || proc.stdin.destroyed) { + return { ok: false, skipped: true }; + } + try { + proc.stdin.write(input && input.chunk ? input.chunk : Buffer.alloc(0)); + } catch { + // ignore chunk failure + } + return { ok: true }; +} + +function scheduleIdleStop(ctx, state) { + clearIdleTimer(state); + const c = cfg(ctx); + state.audio.idleTimer = setTimeout(() => { + stopAudioBackend(ctx, state, "idle-timeout").catch(() => {}); + }, c.audioSessionTimeoutMs); + if (state.audio.idleTimer && typeof state.audio.idleTimer.unref === "function") { + state.audio.idleTimer.unref(); + } +} + +function clearIdleTimer(state) { + if (!state.audio.idleTimer) { + return; + } + clearTimeout(state.audio.idleTimer); + state.audio.idleTimer = null; +} + +async function ensureAudioBackendForOwner(ctx, state, ownerUserId, reason) { + const c = cfg(ctx); + if (!c.audioEnabled) { + throw new Error("MICROHAM_AUDIO_ENABLED=false"); + } + if (state.audio.running) { + if (state.audio.ownerUserId && state.audio.ownerUserId !== ownerUserId) { + const hasClients = state.audio.clients.size > 0; + if (!hasClients) { + await stopAudioBackend(ctx, state, "owner-handover"); + await waitMs(100); + } + } + if (state.audio.running && state.audio.ownerUserId && state.audio.ownerUserId !== ownerUserId) { + throw new Error("TX Audio wird bereits von einem anderen Benutzer verwendet"); + } + state.audio.ownerUserId = ownerUserId; + clearIdleTimer(state); + return; + } + + clearIdleTimer(state); + const startupErrors = []; + const candidates = [...new Set([c.audioAlsaDevice, "default", "plughw:0,0"].map((s) => String(s || "").trim()).filter(Boolean))]; + for (const candidateDevice of candidates) { + state.audio.lastError = null; + let proc; + try { + proc = spawnAudioFfmpeg(c, candidateDevice); + } catch (error) { + startupErrors.push(`${candidateDevice}: ${String(error && error.message ? error.message : error)}`); + continue; + } + state.audio.ffmpeg = proc; + state.audio.running = true; + state.audio.startedAt = new Date().toISOString(); + state.audio.ownerUserId = ownerUserId; + state.audio.alsaDevice = candidateDevice; + state.audio.stopRequested = false; + + let stderrBuffer = ""; + if (proc.stderr) { + proc.stderr.on("data", (chunk) => { + const text = String(chunk || ""); + stderrBuffer = `${stderrBuffer}${text}`.slice(-4000); + if (!state.audio.stopRequested && text.trim()) { + state.audio.lastError = text.trim(); + } + }); + } + + proc.on("error", (error) => { + if (!state.audio.stopRequested) { + state.audio.lastError = String(error && error.message ? error.message : error); + } + }); + + proc.on("close", (code, signal) => { + state.audio.lastExit = { + at: new Date().toISOString(), + code: Number.isFinite(Number(code)) ? Number(code) : null, + signal: signal || null, + stderr: stderrBuffer || null + }; + state.audio.running = false; + state.audio.ffmpeg = null; + state.audio.startedAt = null; + state.audio.ownerUserId = null; + state.audio.alsaDevice = null; + state.audio.stopRequested = false; + clearIdleTimer(state); + for (const client of state.audio.clients) { + try { + client.close(1011, "audio backend closed"); + } catch { + // ignore + } + } + state.audio.clients.clear(); + }); + + await waitMs(180); + if (state.audio.running) { + ctx.emit("microham.audio.start", { reason, alsaDevice: candidateDevice }); + return; + } + startupErrors.push(`${candidateDevice}: ${state.audio.lastError || "start failed"}`); + } + state.audio.alsaDevice = null; + throw new Error(startupErrors.length > 0 ? startupErrors.join(" | ") : "microHAM Audio Backend konnte nicht gestartet werden"); +} + +async function stopAudioBackend(ctx, state, reason) { + clearIdleTimer(state); + const proc = state.audio.ffmpeg; + state.audio.stopRequested = true; + if (!proc || !state.audio.running) { + resetAudioState(state); + return; + } + + for (const client of state.audio.clients) { + try { + client.close(1000, "audio disconnected"); + } catch { + // ignore + } + } + state.audio.clients.clear(); + + try { + if (proc.stdin && !proc.stdin.destroyed) { + proc.stdin.end(); + } + } catch { + // ignore + } + + await waitMs(150); + if (state.audio.running && !proc.killed) { + try { + proc.kill("SIGTERM"); + } catch { + // ignore + } + } + state.audio.lastError = null; + ctx.emit("microham.audio.stop", { reason }); +} + +function resetAudioState(state) { + state.audio.running = false; + state.audio.ffmpeg = null; + state.audio.ownerUserId = null; + state.audio.alsaDevice = null; + state.audio.stopRequested = false; + state.audio.lastError = null; + for (const client of state.audio.clients) { + try { + client.close(1000, "audio disconnected"); + } catch { + // ignore + } + } + state.audio.clients.clear(); +} + +function spawnAudioFfmpeg(c, alsaDevice) { + const ffmpegPath = resolveFfmpegPath(c.audioFfmpegPath); + if (!ffmpegPath) { + throw new Error("ffmpeg binary not found (set MICROHAM_AUDIO_FFMPEG_PATH)"); + } + const args = [ + "-hide_banner", + "-loglevel", "warning", + "-fflags", "+nobuffer", + "-flags", "low_delay", + "-thread_queue_size", "1024", + "-f", c.audioInputMime, + "-i", "pipe:0", + "-ac", "2", + "-f", "alsa", + alsaDevice + ]; + const extra = splitCommand(c.audioFfmpegExtraArgs); + if (extra.length > 0) { + args.splice(args.length - 3, 0, ...extra); + } + return spawn(ffmpegPath, args, { + stdio: ["pipe", "pipe", "pipe"], + cwd: process.cwd(), + env: process.env + }); +} + +function resolveFfmpegPath(configured) { + const value = String(configured || "").trim(); + const linuxCandidates = ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/bin/ffmpeg"]; + if (value) { + if (value.includes(path.sep) || value.includes("/")) { + if (fs.existsSync(value)) { + return value; + } + const fallbackName = path.basename(value) || "ffmpeg"; + for (const candidate of linuxCandidates) { + if (fs.existsSync(candidate) && path.basename(candidate) === fallbackName) { + return candidate; + } + } + return fallbackName; + } + if (process.platform === "linux") { + for (const candidate of linuxCandidates) { + if (fs.existsSync(candidate) && path.basename(candidate) === value) { + return candidate; + } + } + } + return value; + } + if (process.platform === "linux") { + for (const candidate of linuxCandidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + return "ffmpeg"; +} + +function splitCommand(commandString) { + return String(commandString || "").match(/(?:[^\s"]+|"[^"]*")+/g)?.map((part) => part.replace(/^"|"$/g, "")) || []; +} + +function clampNum(value, min, max, fallback) { + const n = Number(value); + if (!Number.isFinite(n)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.floor(n))); +} + +function asBool(value) { + const v = String(value || "").trim().toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "on"; +} + +async function waitMs(ms) { + await new Promise((resolve) => setTimeout(resolve, Number(ms) || 0)); +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.microham/manifest.json b/plugins/rms.microham/manifest.json new file mode 100644 index 0000000..5814e8a --- /dev/null +++ b/plugins/rms.microham/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "rms.microham", + "name": "microHAM", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "microham.ptt", + "microham.audio", + "tx.audio.backend" + ], + "settingsSchema": { + "type": "object", + "properties": { + "device": { "type": "string" }, + "pttCommandsEnabled": { "type": "boolean" }, + "pttDownCommand": { "type": "string" }, + "pttUpCommand": { "type": "string" }, + "pttTimeoutMs": { "type": "integer", "minimum": 1000 }, + "pttApplyBandState": { "type": "boolean" }, + "pttRigctlModel": { "type": "string" }, + "pttRigctlBaud": { "type": "string" }, + "pttRigctlSetConf": { "type": "string" }, + "audioEnabled": { "type": "boolean" }, + "audioAlsaDevice": { "type": "string" }, + "audioInputMime": { "type": "string", "enum": ["webm", "ogg"] }, + "audioStopOnDisconnect": { "type": "boolean" }, + "audioChunkMs": { "type": "integer", "minimum": 40 }, + "audioSessionTimeoutMs": { "type": "integer", "minimum": 1000 }, + "audioFfmpegPath": { "type": "string" }, + "audioFfmpegExtraArgs": { "type": "string" } + }, + "additionalProperties": false + } +} diff --git a/plugins/rms.openwebrx.bandmap/index.js b/plugins/rms.openwebrx.bandmap/index.js new file mode 100644 index 0000000..1c97838 --- /dev/null +++ b/plugins/rms.openwebrx.bandmap/index.js @@ -0,0 +1,311 @@ +const fs = require("fs"); +const path = require("path"); + +async function createPlugin(ctx) { + return { + async execute(action, input) { + if (action === "getBands") { + return getBands(ctx); + } + if (action === "setBand") { + return setBand(ctx, input || {}); + } + if (action === "getState") { + return getState(ctx); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + return getState(ctx); + }, + async health() { + return { ok: true }; + } + }; +} + +function getBands(ctx) { + const bands = readBandmap(ctx); + const state = readState(ctx); + return { + ok: true, + selectedBand: state.selectedBand || null, + selectedCenterFreqHz: state.centerFreqHz || null, + selectedSampRate: Number.isFinite(Number(state.sampRate)) ? Number(state.sampRate) : null, + selectedRfGain: normalizeRfGain(state.rfGain), + bands + }; +} + +async function setBand(ctx, input) { + const requested = String(input.band || input.setfreq || "").trim(); + if (!requested) { + throw new Error("Band fehlt"); + } + + const bands = readBandmap(ctx); + const requestedKey = normalizeBandKey(requested); + const selected = bands.find((entry) => { + const bandKey = normalizeBandKey(entry.band); + const labelKey = normalizeBandKey(entry.label); + return bandKey === requestedKey || labelKey === requestedKey; + }); + if (!selected) { + throw new Error(`Band nicht gefunden: ${requested}`); + } + + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : !(process.platform === "linux" || String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") === "true"); + + const setCommandTemplate = String(ctx.getSetting("setCommandTemplate", ctx.env.OPENWEBRX_BAND_SET_CMD_TEMPLATE || "")).trim(); + const configFilePath = resolvePath(String(ctx.getSetting("configFilePath", ctx.env.OPENWEBRX_CONFIG_PATH || ""))); + const timeoutMs = Number(ctx.getSetting("timeoutMs", ctx.env.OPENWEBRX_BAND_TIMEOUT_MS || 20000)); + + if (setCommandTemplate) { + const command = applyTemplate(setCommandTemplate, selected); + if (!simulate) { + const result = await ctx.commandRunner(command, { timeoutMs }); + if (!result.ok) { + throw new Error(result.stderr || result.error || "OpenWebRX Bandwechsel fehlgeschlagen"); + } + } + } else if (configFilePath) { + if (!simulate) { + if (fs.existsSync(configFilePath)) { + const raw = await fs.promises.readFile(configFilePath, "utf8"); + let updated = raw; + updated = replaceScalarNumericPythonAssignment(updated, "start_freq", String(selected.centerFreqHz)); + if (updated === raw && !/^[ \t]*start_freq\s*=/m.test(raw)) { + updated = `${raw.trimEnd()}\nstart_freq = ${selected.centerFreqHz}\n`; + } + if (Number.isFinite(selected.sampRate)) { + const beforeSampRate = updated; + updated = replaceScalarNumericPythonAssignment(updated, "samp_rate", String(selected.sampRate)); + if (updated === beforeSampRate && !/^[ \t]*samp_rate\s*=/m.test(updated)) { + updated = `${updated.trimEnd()}\nsamp_rate = ${selected.sampRate}\n`; + } + } + updated = replaceScalarNumericPythonAssignment(updated, "center_freq", String(selected.centerFreqHz)); + updated = replaceScalarNumericPythonAssignment(updated, "shown_center_freq", String(selected.centerFreqHz)); + await fs.promises.writeFile(configFilePath, updated); + } + } + } + + const state = { + selectedBand: selected.band, + selectedLabel: selected.label, + antennaRoute: selected.antennaRoute || null, + startMod: selected.startMod || null, + sampRate: selected.sampRate || null, + rfGain: normalizeRfGain(selected.rfGain), + centerFreqHz: selected.centerFreqHz, + inputMHz: selected.inputMHz, + updatedAt: new Date().toISOString(), + source: "rms.openwebrx.bandmap" + }; + await writeState(ctx, state); + + return { + ok: true, + ...state, + skipped: simulate, + message: simulate + ? `OpenWebRX Band ${selected.label} simuliert (${ctx.execMode || "dev"})` + : `OpenWebRX Band auf ${selected.label} gesetzt` + }; +} + +function getState(ctx) { + const bands = readBandmap(ctx); + const state = readState(ctx); + const selectedBand = state.selectedBand || null; + let antennaRoute = state.antennaRoute || null; + let startMod = state.startMod || null; + let sampRate = Number.isFinite(Number(state.sampRate)) ? Number(state.sampRate) : null; + let rfGain = normalizeRfGain(state.rfGain); + if (selectedBand && (!antennaRoute || !startMod || !sampRate || rfGain === null)) { + const selected = bands.find((entry) => String(entry.band) === String(selectedBand)); + if (selected) { + if (!antennaRoute) { + antennaRoute = selected.antennaRoute || null; + } + if (!startMod) { + startMod = selected.startMod || null; + } + if (!sampRate && Number.isFinite(selected.sampRate)) { + sampRate = selected.sampRate; + } + if (rfGain === null) { + rfGain = normalizeRfGain(selected.rfGain); + } + } + } + return { + ok: true, + selectedBand, + selectedLabel: state.selectedLabel || null, + centerFreqHz: state.centerFreqHz || null, + antennaRoute, + startMod, + sampRate, + rfGain, + bands + }; +} + +function readBandmap(ctx) { + const csvPath = resolvePath(String(ctx.getSetting("csvPath", ctx.env.OPENWEBRX_BANDMAP_CSV_PATH || "")).trim()); + const fallback = String(ctx.env.OPENWEBRX_BANDMAP || "").trim(); + let raw = ""; + if (csvPath && fs.existsSync(csvPath)) { + raw = fs.readFileSync(csvPath, "utf8"); + } else if (fallback) { + raw = fallback.split(",").join("\n"); + } else { + raw = "80;3650000;80m\n40;7150000;40m\n20;14300000;20m\n15;21250000;15m\n10;28400000;10m\n2;144384000;2m"; + } + + const entries = []; + const lines = raw.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + const parts = trimmed.split(";").map((entry) => entry.trim()); + if (parts.length < 2) { + continue; + } + const inputMHz = Number(parts[0]); + const centerFreqHz = Number(parts[1]); + if (!Number.isFinite(inputMHz) || !Number.isFinite(centerFreqHz)) { + continue; + } + const label = parts[2] || `${parts[0]} MHz`; + const parsedSampRate = Number(parts[5]); + const sampRate = Number.isFinite(parsedSampRate) + ? parsedSampRate + : defaultSampleRateFor(centerFreqHz); + const rfGain = normalizeRfGain(parts[7]); + entries.push({ + band: String(parts[0]), + inputMHz, + centerFreqHz, + label, + antennaRoute: parts[3] ? String(parts[3]).trim().toLowerCase() : "", + startMod: parts[4] ? String(parts[4]).trim().toLowerCase() : "", + sampRate, + rfGain + }); + } + return entries; +} + +function applyTemplate(template, selected) { + return String(template) + .replaceAll("{band}", selected.band) + .replaceAll("{inputMHz}", String(selected.inputMHz)) + .replaceAll("{centerFreqHz}", String(selected.centerFreqHz)) + .replaceAll("{sampRate}", String(selected.sampRate || "")) + .replaceAll("{rfGain}", selected.rfGain === null ? "" : String(selected.rfGain)) + .replaceAll("{label}", selected.label) + .replaceAll("{antenna}", selected.antennaRoute || "") + .replaceAll("{antennaRoute}", selected.antennaRoute || ""); +} + +function normalizeRfGain(value) { + if (value === undefined || value === null) { + return null; + } + const text = String(value).trim(); + if (!text) { + return null; + } + if (text.toLowerCase() === "auto") { + return "auto"; + } + const numeric = Number(text); + return Number.isFinite(numeric) ? numeric : null; +} + +function defaultSampleRateFor(centerFreqHz) { + const freq = Number(centerFreqHz); + if (!Number.isFinite(freq)) { + return 384000; + } + if (freq >= 88000000) { + return 768000; + } + if (freq >= 24000000) { + return 456000; + } + if (freq >= 14000000) { + return 384000; + } + if (freq >= 7000000) { + return 256000; + } + return 384000; +} + +function normalizeBandKey(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+/g, " "); +} + +function replaceScalarNumericPythonAssignment(raw, key, valueLiteral) { + const escapedKey = escapeRegExp(key); + const linePattern = new RegExp(`^(\\s*${escapedKey}\\s*=\\s*)([^\\n#]*)(\\s*(?:#.*)?)$`, "m"); + const match = raw.match(linePattern); + if (!match) { + return raw; + } + const rhs = String(match[2] || "").trim(); + if (!rhs || rhs.startsWith("[")) { + return raw; + } + if (!/^[-+]?\d+(?:\.\d+)?$/.test(rhs)) { + return raw; + } + return raw.replace(linePattern, `$1${valueLiteral}$3`); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function readState(ctx) { + const stateFilePath = resolvePath(String(ctx.getSetting("stateFilePath", ctx.env.OPENWEBRX_BAND_STATE_PATH || "./data/openwebrx-band-state.json"))); + if (!stateFilePath || !fs.existsSync(stateFilePath)) { + return {}; + } + try { + return JSON.parse(fs.readFileSync(stateFilePath, "utf8")); + } catch { + return {}; + } +} + +async function writeState(ctx, state) { + const stateFilePath = resolvePath(String(ctx.getSetting("stateFilePath", ctx.env.OPENWEBRX_BAND_STATE_PATH || "./data/openwebrx-band-state.json"))); + if (!stateFilePath) { + return; + } + await fs.promises.mkdir(path.dirname(stateFilePath), { recursive: true }); + await fs.promises.writeFile(stateFilePath, JSON.stringify(state, null, 2)); +} + +function resolvePath(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) return ""; + if (path.isAbsolute(trimmed)) return trimmed; + return path.resolve(process.cwd(), trimmed); +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.openwebrx.bandmap/manifest.json b/plugins/rms.openwebrx.bandmap/manifest.json new file mode 100644 index 0000000..159a09c --- /dev/null +++ b/plugins/rms.openwebrx.bandmap/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "rms.openwebrx.bandmap", + "name": "OpenWebRX Bandmap", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "openwebrx.band.read", + "openwebrx.band.set" + ], + "settingsSchema": { + "type": "object", + "properties": { + "csvPath": { "type": "string" }, + "configFilePath": { "type": "string" }, + "setCommandTemplate": { "type": "string" }, + "stateFilePath": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1000 } + }, + "additionalProperties": false + }, + "uiControls": [ + { + "controlId": "openwebrx-bandmap", + "controlType": "switch.group", + "title": "OpenWebRX Band", + "capability": "openwebrx.band.set", + "actions": [ + { + "name": "setBand", + "inputSchema": { + "type": "object", + "properties": { + "band": { "type": "string" } + }, + "required": ["band"], + "additionalProperties": false + } + } + ] + } + ] +} diff --git a/plugins/rms.openwebrx.guard/index.js b/plugins/rms.openwebrx.guard/index.js new file mode 100644 index 0000000..edac88e --- /dev/null +++ b/plugins/rms.openwebrx.guard/index.js @@ -0,0 +1,160 @@ +const crypto = require("crypto"); + +async function createPlugin(ctx) { + const tickets = new Map(); + + return { + async execute(action, input) { + if (action === "issueAccess") { + return issueAccess(ctx, tickets, input || {}); + } + if (action === "verifyAccess") { + return verifyAccess(tickets, input || {}); + } + if (action === "revokeOwner") { + return revokeOwner(tickets, input || {}); + } + if (action === "serviceStart") { + return controlService(ctx, true); + } + if (action === "serviceStop") { + return controlService(ctx, false); + } + if (action === "ensureSdrPath") { + return ensureSdrPath(ctx); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + pruneExpired(tickets); + return { + activeTickets: tickets.size + }; + }, + async health() { + return { ok: true }; + } + }; +} + +function issueAccess(ctx, tickets, input) { + pruneExpired(tickets); + const userId = String(input.userId || "").trim(); + const ownerUserId = String(input.ownerUserId || "").trim(); + if (!userId || !ownerUserId || userId !== ownerUserId) { + throw new Error("OpenWebRX Zugriff nur fuer aktiven Besitzer"); + } + const ttlSec = Number(ctx.getSetting("ticketTtlSec", ctx.env.OPENWEBRX_TICKET_TTL_SEC || 3600)); + const ttlExpiresAtMs = Date.now() + Math.max(10, ttlSec) * 1000; + const stationEndsAtMs = Date.parse(String(input.stationEndsAt || "")); + const expiresAtMs = Number.isFinite(stationEndsAtMs) + ? Math.min(ttlExpiresAtMs, stationEndsAtMs) + : ttlExpiresAtMs; + const ticket = crypto.randomBytes(24).toString("base64url"); + tickets.set(ticket, { + userId, + ownerUserId, + createdAt: new Date().toISOString(), + expiresAtMs + }); + const openWebRxPath = String(ctx.getSetting("upstreamPath", ctx.env.OPENWEBRX_PATH || "/openwebrx/")).trim() || "/openwebrx/"; + return { + ticket, + expiresAt: new Date(expiresAtMs).toISOString(), + iframeUrl: openWebRxPath, + userId + }; +} + +function verifyAccess(tickets, input) { + pruneExpired(tickets); + const ticket = String(input.ticket || "").trim(); + if (!ticket || !tickets.has(ticket)) { + return { ok: false }; + } + const entry = tickets.get(ticket); + if (!entry || entry.expiresAtMs <= Date.now()) { + tickets.delete(ticket); + return { ok: false }; + } + return { + ok: true, + userId: entry.userId, + ownerUserId: entry.ownerUserId, + expiresAt: new Date(entry.expiresAtMs).toISOString() + }; +} + +function revokeOwner(tickets, input) { + const ownerUserId = String(input.ownerUserId || "").trim(); + if (!ownerUserId) { + return { ok: true, revoked: 0 }; + } + let revoked = 0; + for (const [ticket, entry] of tickets.entries()) { + if (entry.ownerUserId === ownerUserId) { + tickets.delete(ticket); + revoked += 1; + } + } + return { ok: true, revoked }; +} + +async function controlService(ctx, start) { + const command = String(ctx.getSetting(start ? "startCommand" : "stopCommand", start ? (ctx.env.OPENWEBRX_START_CMD || "") : (ctx.env.OPENWEBRX_STOP_CMD || ""))).trim(); + if (!command) { + return { ok: true, skipped: true, message: "Kein OpenWebRX Service-Kommando gesetzt" }; + } + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : !(process.platform === "linux" || String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") === "true"); + if (simulate) { + return { + ok: true, + skipped: true, + message: `OpenWebRX ${start ? "start" : "stop"} simuliert (${ctx.execMode || "dev"})` + }; + } + const timeoutMs = Number(ctx.getSetting("timeoutMs", ctx.env.OPENWEBRX_CTRL_TIMEOUT_MS || 20000)); + const result = await ctx.commandRunner(command, { timeoutMs }); + if (!result.ok) { + throw new Error(result.stderr || result.error || `OpenWebRX ${start ? "start" : "stop"} failed`); + } + return { ok: true, message: `OpenWebRX ${start ? "gestartet" : "gestoppt"}` }; +} + +async function ensureSdrPath(ctx) { + const command = String(ctx.getSetting("ensureSdrCommand", ctx.env.OPENWEBRX_ENSURE_SDR_CMD || "")).trim(); + if (!command) { + return { ok: true, skipped: true, message: "Kein OpenWebRX SDR-Pfad-Kommando gesetzt" }; + } + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : !(process.platform === "linux" || String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") === "true"); + if (simulate) { + return { + ok: true, + skipped: true, + message: `OpenWebRX SDR-Pfad nur simuliert (${ctx.execMode || "dev"})` + }; + } + const timeoutMs = Number(ctx.getSetting("timeoutMs", ctx.env.OPENWEBRX_CTRL_TIMEOUT_MS || 20000)); + const result = await ctx.commandRunner(command, { timeoutMs }); + if (!result.ok) { + throw new Error(result.stderr || result.error || "OpenWebRX SDR-Pfad setzen fehlgeschlagen"); + } + return { ok: true, message: "OpenWebRX SDR-Pfad auf SDR gesetzt" }; +} + +function pruneExpired(tickets) { + const now = Date.now(); + for (const [ticket, entry] of tickets.entries()) { + if (!entry || entry.expiresAtMs <= now) { + tickets.delete(ticket); + } + } +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.openwebrx.guard/manifest.json b/plugins/rms.openwebrx.guard/manifest.json new file mode 100644 index 0000000..ccb6c11 --- /dev/null +++ b/plugins/rms.openwebrx.guard/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "rms.openwebrx.guard", + "name": "OpenWebRX Guard", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "openwebrx.access.issue", + "openwebrx.access.verify", + "openwebrx.service.control" + ], + "settingsSchema": { + "type": "object", + "properties": { + "ticketTtlSec": { "type": "integer", "minimum": 10, "maximum": 3600 }, + "upstreamPath": { "type": "string" }, + "ensureSdrCommand": { "type": "string" }, + "startCommand": { "type": "string" }, + "stopCommand": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1000 } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/plugins/rms.rfroute.shell/index.js b/plugins/rms.rfroute.shell/index.js new file mode 100644 index 0000000..745d866 --- /dev/null +++ b/plugins/rms.rfroute.shell/index.js @@ -0,0 +1,66 @@ +async function createPlugin(ctx) { + let currentRoute = String(ctx.getSetting("defaultRoute", ctx.env.RFROUTE_DEFAULT || "rx")); + const allowed = new Set(["tx", "rx", "on", "off", "draht", "beam", "wrtc"]); + + return { + async execute(action, input) { + if (action !== "setRoute") { + throw new Error(`Unknown action: ${action}`); + } + const route = String(input && input.route || "").toLowerCase(); + if (!allowed.has(route)) { + throw new Error("ungueltige Route"); + } + const map = { + tx: ctx.env.RFROUTE_CMD_TX, + rx: ctx.env.RFROUTE_CMD_RX, + on: ctx.env.RFROUTE_CMD_ON, + off: ctx.env.RFROUTE_CMD_OFF, + draht: ctx.env.RFROUTE_CMD_DRAHT, + beam: ctx.env.RFROUTE_CMD_BEAM, + wrtc: ctx.env.RFROUTE_CMD_WRTC + }; + const command = String(map[route] || "").trim(); + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : (process.platform !== "linux" && String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") !== "true"); + if (!command && !simulate) { + throw new Error(`RFROUTE_CMD_${route.toUpperCase()} fehlt`); + } + if (command) { + if (simulate) { + currentRoute = route; + return { + ok: true, + skipped: true, + message: `RF Route ${route} simuliert (${ctx.execMode || "dev"})` + }; + } + const result = await ctx.commandRunner(command, { + timeoutMs: Number(ctx.getSetting("timeoutMs", ctx.env.RFROUTE_TIMEOUT_MS || 15000)) + }); + if (!result.ok) { + throw new Error(result.stderr || result.error || "rfroute command failed"); + } + } + currentRoute = route; + return { + ok: true, + message: `Route auf ${route} gesetzt` + }; + }, + async getStatus() { + return { + current: currentRoute, + options: ["tx", "rx", "on", "off", "draht", "beam", "wrtc"] + }; + }, + async health() { + return { ok: true }; + } + }; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.rfroute.shell/manifest.json b/plugins/rms.rfroute.shell/manifest.json new file mode 100644 index 0000000..b0b1688 --- /dev/null +++ b/plugins/rms.rfroute.shell/manifest.json @@ -0,0 +1,56 @@ +{ + "id": "rms.rfroute.shell", + "name": "RF Route Shell", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "rfroute.set", + "rfroute.read" + ], + "settingsSchema": { + "type": "object", + "properties": { + "defaultRoute": { + "type": "string", + "enum": ["tx", "rx", "on", "off", "draht", "beam", "wrtc"] + }, + "timeoutMs": { "type": "integer", "minimum": 1000 } + }, + "additionalProperties": false + }, + "uiControls": [ + { + "controlId": "rfroute-main", + "controlType": "switch.group", + "title": "RF Route", + "capability": "rfroute.set", + "actions": [ + { + "name": "setRoute", + "inputSchema": { + "type": "object", + "properties": { + "route": { + "type": "string", + "enum": ["tx", "rx", "on", "off", "draht", "beam", "wrtc"] + } + }, + "required": ["route"], + "additionalProperties": false + } + } + ], + "viewSchema": { + "options": [ + { "value": "tx", "label": "TX" }, + { "value": "rx", "label": "RX" }, + { "value": "on", "label": "TRX ON" }, + { "value": "off", "label": "TRX OFF" }, + { "value": "draht", "label": "Draht" }, + { "value": "beam", "label": "Beam" }, + { "value": "wrtc", "label": "WRTC" } + ] + } + } + ] +} diff --git a/plugins/rms.rotor.hamlib/index.js b/plugins/rms.rotor.hamlib/index.js new file mode 100644 index 0000000..34d727c --- /dev/null +++ b/plugins/rms.rotor.hamlib/index.js @@ -0,0 +1,93 @@ +async function createPlugin(ctx) { + let lastAzimuth = Number(ctx.getSetting("defaultAzimuth", ctx.env.ROTOR_DEFAULT_AZIMUTH || 0)); + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : (process.platform !== "linux" && String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") !== "true"); + + return { + async execute(action, input) { + if (action === "getAzimuth") { + return getRotorStatus(ctx, simulate, () => lastAzimuth, (value) => { + lastAzimuth = value; + }); + } + if (action !== "setAzimuth") { + throw new Error(`Unknown action: ${action}`); + } + const target = Number(input && input.target); + if (!Number.isFinite(target) || target < 0 || target > 360) { + throw new Error("target muss zwischen 0 und 360 sein"); + } + + const template = String(ctx.getSetting("setTemplate", ctx.env.ROTOR_SET_CMD_TEMPLATE || "")).trim(); + if (template) { + if (simulate) { + lastAzimuth = target; + return { + ok: true, + skipped: true, + message: `Rotor auf ${target} Grad simuliert (${ctx.execMode || "dev"})` + }; + } + const command = template.replace(/\{az\}/g, String(target)); + const result = await ctx.commandRunner(command, { + timeoutMs: Number(ctx.env.ROTOR_SET_TIMEOUT_MS || 20000) + }); + if (!result.ok) { + throw new Error(result.stderr || result.error || "Rotor set failed"); + } + } + + lastAzimuth = target; + ctx.emit("ui.control.state.changed", { + controlId: "rotor-main", + data: { azimuth: lastAzimuth, moving: false } + }); + return { + ok: true, + message: `Rotor auf ${target} Grad gesetzt` + }; + }, + async getStatus() { + return getRotorStatus(ctx, simulate, () => lastAzimuth, (value) => { + lastAzimuth = value; + }); + }, + async health() { + return { ok: true }; + } + }; +} + +async function getRotorStatus(ctx, simulate, getLastAzimuth, setLastAzimuth) { + const getCmd = String(ctx.getSetting("getCommand", ctx.env.ROTOR_GET_CMD || "")).trim(); + if (getCmd) { + if (simulate) { + return { + azimuth: getLastAzimuth(), + moving: false, + min: 0, + max: 360 + }; + } + const result = await ctx.commandRunner(getCmd, { + timeoutMs: Number(ctx.env.ROTOR_GET_TIMEOUT_MS || 10000) + }); + if (result.ok) { + const parsed = Number(String(result.stdout).split(/\s+/)[0]); + if (Number.isFinite(parsed)) { + setLastAzimuth(parsed < 0 ? parsed + 360 : parsed); + } + } + } + return { + azimuth: getLastAzimuth(), + moving: false, + min: 0, + max: 360 + }; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.rotor.hamlib/manifest.json b/plugins/rms.rotor.hamlib/manifest.json new file mode 100644 index 0000000..90a018a --- /dev/null +++ b/plugins/rms.rotor.hamlib/manifest.json @@ -0,0 +1,51 @@ +{ + "id": "rms.rotor.hamlib", + "name": "Rotor Hamlib", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "rotor.read", + "rotor.set" + ], + "settingsSchema": { + "type": "object", + "properties": { + "setTemplate": { "type": "string" }, + "getCommand": { "type": "string" }, + "defaultAzimuth": { "type": "number", "minimum": 0, "maximum": 360 } + }, + "additionalProperties": false + }, + "uiControls": [ + { + "controlId": "rotor-main", + "controlType": "rotor.azimuth", + "title": "Rotorsteuerung", + "capability": "rotor.set", + "actions": [ + { + "name": "setAzimuth", + "inputSchema": { + "type": "object", + "properties": { + "target": { + "type": "number", + "minimum": 0, + "maximum": 360 + } + }, + "required": [ + "target" + ], + "additionalProperties": false + } + } + ], + "viewSchema": { + "showCompass": true, + "presets": [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330], + "stepButtons": [1, 5, 10] + } + } + ] +} diff --git a/plugins/rms.station.access.policy/index.js b/plugins/rms.station.access.policy/index.js new file mode 100644 index 0000000..9c97b95 --- /dev/null +++ b/plugins/rms.station.access.policy/index.js @@ -0,0 +1,143 @@ +const fs = require("fs"); +const path = require("path"); + +async function createPlugin(ctx) { + let activeOwnerEmail = null; + + return { + async execute(action, input) { + if (action === "getPolicy") { + return getPolicy(ctx, activeOwnerEmail); + } + if (action === "addPersistentUser") { + const email = normalizeEmail(input && input.email); + if (!email) { + throw new Error("E-Mail fehlt"); + } + const persistent = await readPersistentUsers(ctx); + if (!persistent.includes(email)) { + persistent.push(email); + persistent.sort(); + await writePersistentUsers(ctx, persistent); + } + await writePolicyFile(ctx, persistent, activeOwnerEmail); + return { ok: true, persistentUsers: persistent, ownerEmail: activeOwnerEmail }; + } + if (action === "removePersistentUser") { + const email = normalizeEmail(input && input.email); + if (!email) { + throw new Error("E-Mail fehlt"); + } + const persistent = await readPersistentUsers(ctx); + const next = persistent.filter((entry) => entry !== email); + await writePersistentUsers(ctx, next); + await writePolicyFile(ctx, next, activeOwnerEmail); + return { ok: true, persistentUsers: next, ownerEmail: activeOwnerEmail }; + } + if (action === "syncOwner") { + activeOwnerEmail = normalizeEmail(input && input.ownerEmail); + const persistent = await readPersistentUsers(ctx); + await writePolicyFile(ctx, persistent, activeOwnerEmail); + return { ok: true, ownerEmail: activeOwnerEmail, persistentUsers: persistent }; + } + if (action === "clearOwner") { + activeOwnerEmail = null; + const persistent = await readPersistentUsers(ctx); + await writePolicyFile(ctx, persistent, activeOwnerEmail); + return { ok: true, ownerEmail: null, persistentUsers: persistent }; + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + const policy = await getPolicy(ctx, activeOwnerEmail); + return { + ok: true, + ownerEmail: policy.ownerEmail, + persistentCount: policy.persistentUsers.length, + effectiveCount: policy.effectiveUsers.length + }; + }, + async health() { + return { ok: true }; + } + }; +} + +async function getPolicy(ctx, ownerEmail) { + const persistentUsers = await readPersistentUsers(ctx); + const effectiveUsers = buildEffectiveUsers(persistentUsers, ownerEmail); + return { + ok: true, + ownerEmail: ownerEmail || null, + persistentUsers, + effectiveUsers + }; +} + +function buildEffectiveUsers(persistentUsers, ownerEmail) { + const set = new Set(persistentUsers); + if (ownerEmail) { + set.add(ownerEmail); + } + return Array.from(set).sort(); +} + +async function readPersistentUsers(ctx) { + const filePath = persistentFilePath(ctx); + if (!filePath || !fs.existsSync(filePath)) { + return []; + } + const raw = await fs.promises.readFile(filePath, "utf8"); + const entries = raw + .split(/\r?\n/) + .map((line) => normalizeEmail(line)) + .filter(Boolean); + return Array.from(new Set(entries)).sort(); +} + +async function writePersistentUsers(ctx, users) { + const filePath = persistentFilePath(ctx); + if (!filePath) { + return; + } + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const payload = users.length ? `${users.join("\n")}\n` : ""; + await fs.promises.writeFile(filePath, payload, "utf8"); +} + +async function writePolicyFile(ctx, persistentUsers, ownerEmail) { + const filePath = policyFilePath(ctx); + if (!filePath) { + return; + } + const effectiveUsers = buildEffectiveUsers(persistentUsers, ownerEmail); + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + const payload = effectiveUsers.length ? `${effectiveUsers.join("\n")}\n` : ""; + await fs.promises.writeFile(filePath, payload, "utf8"); +} + +function policyFilePath(ctx) { + return resolvePath(String(ctx.getSetting("policyFilePath", ctx.env.OPENWEBRX_ACCESS_POLICY_FILE || "./data/openwebrx-access-policy.txt"))); +} + +function persistentFilePath(ctx) { + return resolvePath(String(ctx.getSetting("persistentFilePath", ctx.env.OPENWEBRX_PERSISTENT_USERS_FILE || "./data/openwebrx-persistent-users.txt"))); +} + +function normalizeEmail(value) { + const email = String(value || "").trim().toLowerCase(); + if (!email) return ""; + if (!email.includes("@")) return ""; + return email; +} + +function resolvePath(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) return ""; + if (path.isAbsolute(trimmed)) return trimmed; + return path.resolve(process.cwd(), trimmed); +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.station.access.policy/manifest.json b/plugins/rms.station.access.policy/manifest.json new file mode 100644 index 0000000..2cac65b --- /dev/null +++ b/plugins/rms.station.access.policy/manifest.json @@ -0,0 +1,69 @@ +{ + "id": "rms.station.access.policy", + "name": "Station Access Policy", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "station.access.policy.read", + "admin.station.access.policy.write" + ], + "settingsSchema": { + "type": "object", + "properties": { + "policyFilePath": { "type": "string" }, + "persistentFilePath": { "type": "string" } + }, + "additionalProperties": false + }, + "uiControls": [ + { + "controlId": "station-access-policy", + "controlType": "switch.group", + "title": "Station Access Policy", + "capability": "admin.station.access.policy.write", + "actions": [ + { + "name": "addPersistentUser", + "inputSchema": { + "type": "object", + "properties": { + "email": { "type": "string" } + }, + "required": ["email"], + "additionalProperties": false + } + }, + { + "name": "removePersistentUser", + "inputSchema": { + "type": "object", + "properties": { + "email": { "type": "string" } + }, + "required": ["email"], + "additionalProperties": false + } + }, + { + "name": "syncOwner", + "inputSchema": { + "type": "object", + "properties": { + "ownerEmail": { "type": "string" } + }, + "required": ["ownerEmail"], + "additionalProperties": false + } + }, + { + "name": "clearOwner", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + ] + } + ] +} diff --git a/plugins/rms.station.shell/index.js b/plugins/rms.station.shell/index.js new file mode 100644 index 0000000..48fd528 --- /dev/null +++ b/plugins/rms.station.shell/index.js @@ -0,0 +1,122 @@ +const path = require("path"); +const fs = require("fs"); + +async function createPlugin(ctx) { + return { + async execute(action, input) { + if (action === "activate") { + return runConfigured(ctx, "SCRIPT_ACTIVATE", input && input.userEmail); + } + if (action === "deactivate") { + return runConfigured(ctx, "SCRIPT_DEACTIVATE", input && input.userEmail); + } + throw new Error(`Unknown action: ${action}`); + }, + async health() { + return { ok: true }; + } + }; +} + +async function runConfigured(ctx, key, userEmail) { + const configuredKey = key === "SCRIPT_ACTIVATE" ? "scriptActivate" : "scriptDeactivate"; + const command = String(ctx.getSetting(configuredKey, ctx.env[key] || "")).trim(); + if (!command) { + return { + ok: true, + skipped: true, + message: `Kein Kommando in ${key} gesetzt` + }; + } + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : (process.platform !== "linux" && String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") !== "true"); + if (simulate) { + return { + ok: true, + skipped: true, + message: `${key} nur simuliert (${ctx.execMode || "dev"})` + }; + } + const fallbackCheck = resolveMissingDefaultScriptFallback(ctx, key, command); + if (fallbackCheck.skip) { + return { + ok: true, + skipped: true, + message: fallbackCheck.message + }; + } + const commandToRun = fallbackCheck.command || command; + const cwdSetting = ctx.getSetting("scriptRoot", ctx.env.SCRIPT_ROOT || "../Remotestation-Bestand-Submodules/sk"); + const cwd = path.resolve(ctx.rootDir, String(cwdSetting)); + const timeoutMs = Number(ctx.getSetting("timeoutMs", ctx.env.STATION_SCRIPT_TIMEOUT_MS || 180000)); + const result = await ctx.commandRunner(commandToRun, { + cwd, + env: { + RMS_USER_EMAIL: userEmail || "", + RMS_ACTION: key === "SCRIPT_ACTIVATE" ? "activate" : "deactivate" + }, + timeoutMs + }); + if (!result.ok) { + throw new Error(result.stderr || result.error || `${key} failed`); + } + return { + ok: true, + message: `${key} ausgefuehrt` + }; +} + +function resolveMissingDefaultScriptFallback(ctx, key, command) { + const parts = splitCommand(command); + if (parts.length === 0) { + return { skip: true, message: `${key} leer` }; + } + const executable = parts[0]; + const defaultPath = key === "SCRIPT_ACTIVATE" + ? "/opt/remotestation/bin/activate.sh" + : "/opt/remotestation/bin/deactivate.sh"; + if (executable !== defaultPath || fs.existsSync(executable)) { + return { skip: false, command }; + } + + const fallback = findRuntimeFallbackScript(ctx, key); + if (fallback) { + return { skip: false, command: [fallback].concat(parts.slice(1)).join(" ") }; + } + + return { + skip: true, + message: `${key} Standardskript fehlt (${defaultPath}), Aktion ohne externes Skript fortgesetzt` + }; +} + +function findRuntimeFallbackScript(ctx, key) { + const fileName = key === "SCRIPT_ACTIVATE" ? "activate.sh" : "deactivate.sh"; + const scriptRoot = String(ctx.env.SCRIPT_ROOT || "/opt/remotestation").trim() || "/opt/remotestation"; + const candidates = [ + path.join(scriptRoot, "src", "tx-runtime", "bin", fileName), + path.join(scriptRoot, "src", "tx-runtime", fileName), + path.join(scriptRoot, "src", "openWebTrX", "bin", fileName), + path.join(scriptRoot, "src", "openWebTrX", fileName), + path.resolve(ctx.rootDir, "../Remotestation-Bestand-Submodules/sk/bin", fileName) + ]; + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // ignore invalid candidate + } + } + return null; +} + +function splitCommand(commandString) { + return commandString.match(/(?:[^\s\"]+|\"[^\"]*\")+/g)?.map((part) => part.replace(/^\"|\"$/g, "")) || []; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.station.shell/manifest.json b/plugins/rms.station.shell/manifest.json new file mode 100644 index 0000000..608ce2a --- /dev/null +++ b/plugins/rms.station.shell/manifest.json @@ -0,0 +1,21 @@ +{ + "id": "rms.station.shell", + "name": "Station Shell Control", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "station.activate", + "station.deactivate" + ], + "settingsSchema": { + "type": "object", + "properties": { + "scriptActivate": { "type": "string" }, + "scriptDeactivate": { "type": "string" }, + "scriptRoot": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1000 } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/plugins/rms.tx.audio.core/index.js b/plugins/rms.tx.audio.core/index.js new file mode 100644 index 0000000..a0d3532 --- /dev/null +++ b/plugins/rms.tx.audio.core/index.js @@ -0,0 +1,70 @@ +async function createPlugin(ctx) { + return { + async execute(action, input = {}) { + const backendCapability = String(ctx.getSetting("backendCapability", "tx.audio.backend") || "tx.audio.backend").trim() || "tx.audio.backend"; + const mappedAction = mapAction(action); + if (!mappedAction) { + throw new Error(`Unknown action: ${action}`); + } + const result = await ctx.executeCapability(backendCapability, mappedAction, input, { skipTxSafety: true }); + if (action === "audioStatus") { + return normalizeAudioStatus(result, input); + } + return result; + }, + async health() { + return { ok: true }; + } + }; +} + +function mapAction(action) { + switch (String(action || "")) { + case "audioConnect": + return "backendStart"; + case "audioDisconnect": + return "backendStop"; + case "audioStatus": + return "backendStatus"; + case "audioRegisterClient": + return "backendRegisterClient"; + case "audioUnregisterClient": + return "backendUnregisterClient"; + case "audioWriteChunk": + return "backendWrite"; + default: + return null; + } +} + +function normalizeAudioStatus(result, input) { + const raw = result && typeof result === "object" ? result : {}; + const userId = String((input && input.userId) || ""); + const running = Boolean(raw.running); + const ownerUserId = raw.ownerUserId ? String(raw.ownerUserId) : null; + const enabled = raw.enabled !== false; + return { + enabled, + state: raw.state || (enabled ? (running ? "running" : "disconnected") : "disabled"), + running, + clients: Number.isFinite(Number(raw.clients)) ? Number(raw.clients) : 0, + ownerUserId, + ownerMatchesCaller: Boolean(ownerUserId && userId && ownerUserId === userId), + startedAt: raw.startedAt || null, + lastError: raw.lastError || null, + lastExit: raw.lastExit || null, + ffmpegPath: raw.ffmpegPath || null, + alsaDevice: raw.alsaDevice || null, + chunkMs: Number.isFinite(Number(raw.chunkMs)) ? Number(raw.chunkMs) : null, + wsPath: raw.wsPath || null, + backend: { + providerId: raw.providerId || null, + providerEnabled: raw.providerEnabled !== false, + state: raw.state || null + } + }; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.tx.audio.core/manifest.json b/plugins/rms.tx.audio.core/manifest.json new file mode 100644 index 0000000..02748ee --- /dev/null +++ b/plugins/rms.tx.audio.core/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "rms.tx.audio.core", + "name": "TX Audio Core", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "tx.audio" + ], + "settingsSchema": { + "type": "object", + "properties": { + "backendCapability": { "type": "string", "default": "tx.audio.backend" } + }, + "additionalProperties": false + } +} diff --git a/plugins/rms.tx.control.native/index.js b/plugins/rms.tx.control.native/index.js new file mode 100644 index 0000000..1450e33 --- /dev/null +++ b/plugins/rms.tx.control.native/index.js @@ -0,0 +1,158 @@ +const fs = require("fs"); +const path = require("path"); + +async function createPlugin(ctx) { + return { + async execute(action) { + if (action === "enableTx") { + return setTxState(ctx, true); + } + if (action === "disableTx") { + return setTxState(ctx, false); + } + if (action === "getTxState") { + return getTxState(ctx); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + return getTxState(ctx); + }, + async health() { + return { ok: true }; + } + }; +} + +async function setTxState(ctx, nextActive) { + const stateFilePath = resolvePath(String(ctx.getSetting("stateFilePath", defaultStateFilePath(ctx)))); + const timeoutMs = Number(ctx.getSetting("timeoutMs", ctx.env.TX_CONTROL_TIMEOUT_MS || 20000)); + const settingValue = String(ctx.getSetting(nextActive ? "enableCommand" : "disableCommand", "")).trim(); + const envValue = String(nextActive ? (ctx.env.TX_ENABLE_CMD || defaultEnableCommand()) : (ctx.env.TX_DISABLE_CMD || defaultDisableCommand())).trim(); + const command = settingValue || envValue; + + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : !(process.platform === "linux" || String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") === "true"); + + if (!command && !simulate) { + throw new Error(nextActive ? "TX enable command missing" : "TX disable command missing"); + } + + const normalizedCommand = String(command || "").trim().toLowerCase(); + const commandIsNoopSuccess = normalizedCommand === "true" || normalizedCommand === ":"; + + if (command && !simulate && !commandIsNoopSuccess) { + const result = await ctx.commandRunner(command, { timeoutMs }); + if (!result.ok) { + throw new Error(result.stderr || result.error || `TX ${nextActive ? "enable" : "disable"} failed`); + } + } + + await writeStateFile(stateFilePath, { + txActive: nextActive, + updatedAt: new Date().toISOString(), + source: "rms.tx.control.native" + }); + + return { + ok: true, + txActive: nextActive, + updatedAt: new Date().toISOString(), + source: "rms.tx.control.native", + skipped: (simulate && Boolean(command)) || commandIsNoopSuccess, + message: simulate ? `TX ${nextActive ? "ON" : "OFF"} simuliert (${ctx.execMode || "dev"})` : `TX ${nextActive ? "ON" : "OFF"}` + }; +} + +async function getTxState(ctx) { + const statusCommand = String(ctx.getSetting("statusCommand", ctx.env.TX_STATUS_CMD || defaultStatusCommand())).trim(); + const stateFilePath = resolvePath(String(ctx.getSetting("stateFilePath", defaultStateFilePath(ctx)))); + const timeoutMs = Number(ctx.getSetting("timeoutMs", ctx.env.TX_CONTROL_TIMEOUT_MS || 20000)); + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : !(process.platform === "linux" || String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") === "true"); + + if (statusCommand && !simulate) { + const result = await ctx.commandRunner(statusCommand, { timeoutMs }); + if (result.ok) { + const text = String(result.stdout || "").trim().toLowerCase(); + if (text === "1" || text === "true" || text === "on") { + return { + txActive: true, + source: "rms.tx.control.native", + updatedAt: new Date().toISOString() + }; + } + if (text === "0" || text === "false" || text === "off") { + return { + txActive: false, + source: "rms.tx.control.native", + updatedAt: new Date().toISOString() + }; + } + } + } + + const fallback = { + txActive: false, + source: "rms.tx.control.native", + updatedAt: null + }; + if (!stateFilePath || !fs.existsSync(stateFilePath)) { + return fallback; + } + try { + const raw = fs.readFileSync(stateFilePath, "utf8"); + const parsed = JSON.parse(raw); + return { + txActive: Boolean(parsed && parsed.txActive), + source: parsed && parsed.source ? parsed.source : "rms.tx.control.native", + updatedAt: parsed && parsed.updatedAt ? parsed.updatedAt : null, + details: parsed && typeof parsed === "object" ? parsed : null + }; + } catch { + return fallback; + } +} + +async function writeStateFile(filePath, state) { + if (!filePath) return; + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, JSON.stringify(state, null, 2)); +} + +function defaultStateFilePath(ctx) { + const explicit = String(ctx.env.TX_STATE_PATH || "").trim(); + if (explicit) { + return explicit; + } + const dataDir = String(ctx.env.DATA_DIR || "").trim(); + if (dataDir) { + return path.join(dataDir, "tx-state.json"); + } + return "./data/tx-state.json"; +} + +function defaultEnableCommand() { + return "/opt/remotestation/bin/tx-control.sh enable"; +} + +function defaultDisableCommand() { + return "/opt/remotestation/bin/tx-control.sh disable"; +} + +function defaultStatusCommand() { + return "/opt/remotestation/bin/tx-control.sh status"; +} + +function resolvePath(value) { + const v = String(value || "").trim(); + if (!v) return ""; + if (path.isAbsolute(v)) return v; + return path.resolve(process.cwd(), v); +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.tx.control.native/manifest.json b/plugins/rms.tx.control.native/manifest.json new file mode 100644 index 0000000..7d10786 --- /dev/null +++ b/plugins/rms.tx.control.native/manifest.json @@ -0,0 +1,59 @@ +{ + "id": "rms.tx.control.native", + "name": "TX Control Native", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "tx.control", + "tx.state.read" + ], + "settingsSchema": { + "type": "object", + "properties": { + "enableCommand": { "type": "string" }, + "disableCommand": { "type": "string" }, + "statusCommand": { "type": "string" }, + "stateFilePath": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1000 } + }, + "additionalProperties": false + }, + "uiControls": [ + { + "controlId": "tx-control-native", + "controlType": "switch.group", + "title": "TX Steuerung", + "capability": "tx.control", + "actions": [ + { + "name": "enableTx", + "inputSchema": { + "type": "object", + "properties": { + "source": { "type": "string", "default": "plugin-ui" } + }, + "additionalProperties": false + } + }, + { + "name": "disableTx", + "inputSchema": { + "type": "object", + "properties": { + "source": { "type": "string", "default": "plugin-ui" } + }, + "additionalProperties": false + } + }, + { + "name": "getTxState", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + ] + } + ] +} diff --git a/plugins/rms.tx.state.file/index.js b/plugins/rms.tx.state.file/index.js new file mode 100644 index 0000000..a90ba69 --- /dev/null +++ b/plugins/rms.tx.state.file/index.js @@ -0,0 +1,56 @@ +const fs = require("fs"); +const path = require("path"); + +async function createPlugin(ctx) { + return { + async execute(action) { + if (action !== "getTxState") { + throw new Error(`Unknown action: ${action}`); + } + return readTxState(ctx); + }, + async getStatus() { + return readTxState(ctx); + }, + async health() { + return { ok: true }; + } + }; +} + +function readTxState(ctx) { + const stateFilePath = resolvePath(String(ctx.getSetting("stateFilePath", ctx.env.TX_STATE_PATH || "./data/tx-state.json"))); + const fallback = { + txActive: false, + source: "tx-state-file", + updatedAt: null, + path: stateFilePath + }; + if (!stateFilePath || !fs.existsSync(stateFilePath)) { + return fallback; + } + try { + const raw = fs.readFileSync(stateFilePath, "utf8"); + const parsed = JSON.parse(raw); + return { + txActive: Boolean(parsed && parsed.txActive), + source: "tx-state-file", + updatedAt: parsed && parsed.updatedAt ? parsed.updatedAt : null, + path: stateFilePath, + details: parsed && typeof parsed === "object" ? parsed : null + }; + } catch { + return fallback; + } +} + +function resolvePath(value) { + const v = String(value || "").trim(); + if (!v) return ""; + if (path.isAbsolute(v)) return v; + return path.resolve(process.cwd(), v); +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.tx.state.file/manifest.json b/plugins/rms.tx.state.file/manifest.json new file mode 100644 index 0000000..c7cde71 --- /dev/null +++ b/plugins/rms.tx.state.file/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "rms.tx.state.file", + "name": "TX State File", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "tx.state.read" + ], + "settingsSchema": { + "type": "object", + "properties": { + "stateFilePath": { "type": "string" } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/plugins/rms.vswr.nanovna/index.js b/plugins/rms.vswr.nanovna/index.js new file mode 100644 index 0000000..59d5ad0 --- /dev/null +++ b/plugins/rms.vswr.nanovna/index.js @@ -0,0 +1,70 @@ +const fs = require("fs"); + +async function createPlugin(ctx) { + return { + async execute(action) { + if (action === "runCheck") { + return runCheck(ctx); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + return readStatus(ctx); + }, + async health() { + return { ok: true }; + } + }; +} + +async function runCheck(ctx) { + const command = String(ctx.getSetting("checkCommand", ctx.env.VSWR_CHECK_CMD || "")).trim(); + if (!command) { + return { + ok: true, + skipped: true, + message: "VSWR_CHECK_CMD nicht gesetzt" + }; + } + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : (process.platform !== "linux" && String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") !== "true"); + if (simulate) { + return { + ok: true, + skipped: true, + message: `VSWR Check simuliert (${ctx.execMode || "dev"})`, + status: readStatus(ctx) + }; + } + const result = await ctx.commandRunner(command, { + timeoutMs: Number(ctx.getSetting("timeoutMs", ctx.env.VSWR_CHECK_TIMEOUT_MS || 240000)) + }); + if (!result.ok) { + throw new Error(result.stderr || result.error || "VSWR check failed"); + } + return { + ok: true, + message: "VSWR check abgeschlossen", + status: readStatus(ctx) + }; +} + +function readStatus(ctx) { + const metadataPath = String(ctx.getSetting("metadataPath", ctx.env.VSWR_METADATA_PATH || "")).trim(); + if (!metadataPath || !fs.existsSync(metadataPath)) { + return { + status: "UNKNOWN", + path: metadataPath || null + }; + } + const raw = fs.readFileSync(metadataPath, "utf8").trim(); + return { + status: raw || "UNKNOWN", + path: metadataPath + }; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.vswr.nanovna/manifest.json b/plugins/rms.vswr.nanovna/manifest.json new file mode 100644 index 0000000..3a2af0c --- /dev/null +++ b/plugins/rms.vswr.nanovna/manifest.json @@ -0,0 +1,46 @@ +{ + "id": "rms.vswr.nanovna", + "name": "NanoVNA VSWR", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "vswr.run", + "vswr.read" + ], + "settingsSchema": { + "type": "object", + "properties": { + "checkCommand": { "type": "string" }, + "metadataPath": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 1000 }, + "expectedDurationMs": { "type": "integer", "minimum": 1000 } + }, + "additionalProperties": false + }, + "uiControls": [ + { + "controlId": "vswr-status", + "controlType": "links.panel", + "title": "VSWR", + "capability": "vswr.read", + "actions": [ + { + "name": "runCheck", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + } + ], + "viewSchema": { + "links": [ + { + "id": "swr", + "label": "SWR Uebersicht" + } + ] + } + } + ] +} diff --git a/plugins/rms.vswr.native/index.js b/plugins/rms.vswr.native/index.js new file mode 100644 index 0000000..e46594b --- /dev/null +++ b/plugins/rms.vswr.native/index.js @@ -0,0 +1,385 @@ +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_BANDS = [ + { band: "160m", startHz: 1810000, endHz: 2000000 }, + { band: "80m", startHz: 3500000, endHz: 3800000 }, + { band: "40m", startHz: 7000000, endHz: 7200000 }, + { band: "20m", startHz: 14000000, endHz: 14350000 }, + { band: "17m", startHz: 18068000, endHz: 18168000 }, + { band: "15m", startHz: 21000000, endHz: 21450000 }, + { band: "12m", startHz: 24890000, endHz: 24990000 }, + { band: "10m", startHz: 28200000, endHz: 29000000 } +]; + +async function createPlugin(ctx) { + return { + async execute(action) { + if (action === "runCheck") { + return runCheck(ctx); + } + if (action === "getReport") { + return readReport(ctx); + } + throw new Error(`Unknown action: ${action}`); + }, + async getStatus() { + const report = readReport(ctx); + return { + source: report.source, + generatedAt: report.generatedAt, + overallStatus: report.overallStatus, + bands: report.bands.length + }; + }, + async health() { + return { ok: true }; + } + }; +} + +async function runCheck(ctx) { + const dataDir = String(ctx.env.DATA_DIR || "/opt/remotestation-arcg/shared/data").trim() || "/opt/remotestation-arcg/shared/data"; + const tracePath = resolvePath(String(ctx.env.VSWR_NATIVE_TRACE_PATH || `${dataDir}/vswr/native-run.log`)); + const reportPath = resolvePath(String(ctx.getSetting("reportJsonPath", ctx.env.VSWR_REPORT_JSON_PATH || `${dataDir}/vswr/swr-report.json`))); + const outputBaseDir = resolvePath(String(ctx.getSetting("outputBaseDir", ctx.env.VSWR_OUTPUT_BASE_DIR || `${dataDir}/vswr/output`))); + const timeoutMs = Number(ctx.getSetting("timeoutMsPerBand", ctx.env.VSWR_TIMEOUT_MS_PER_BAND || 45000)); + const bands = readBands(ctx); + const cmdTemplate = String( + ctx.getSetting( + "nanovnaCommandTemplate", + ctx.env.NANOVNA_COMMAND_TEMPLATE || ctx.env.VSWR_CHECK_CMD || "" + ) + ).trim(); + const multiBandInSingleRun = cmdTemplate && !/[{](band|startHz|endHz|bandDir)[}]/.test(cmdTemplate); + let batchCommandError = ""; + + const report = { + source: "native-controller", + generatedAt: new Date().toISOString(), + overallStatus: "OK", + overviewUrl: String(ctx.env.SWR_OVERVIEW_URL || "") || null, + bands: [] + }; + + await fs.promises.mkdir(outputBaseDir, { recursive: true }); + appendTrace(tracePath, `run-start cmdTemplate=${cmdTemplate || ""} multiBand=${multiBandInSingleRun ? "yes" : "no"}`); + + const simulate = typeof ctx.simulateHardware === "boolean" + ? ctx.simulateHardware + : !(process.platform === "linux" || String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") === "true"); + const canRunHardware = !simulate; + if (multiBandInSingleRun && canRunHardware) { + appendTrace(tracePath, `batch-run command=${cmdTemplate}`); + let result = await ctx.commandRunner(cmdTemplate, { + timeoutMs: timeoutMs * Math.max(1, bands.length) + }); + if (!result.ok && Number(result.code) === 127) { + const trimmed = String(cmdTemplate || "").trim(); + const firstToken = trimmed.split(/\s+/, 1)[0] || ""; + if (firstToken && firstToken.endsWith(".sh") && fs.existsSync(firstToken)) { + const fallbackCommand = `/bin/bash ${quotePathForCmd(firstToken)}`; + appendTrace(tracePath, `batch-retry fallback=${fallbackCommand}`); + result = await ctx.commandRunner(fallbackCommand, { + timeoutMs: timeoutMs * Math.max(1, bands.length) + }); + } + } + appendTrace(tracePath, `batch-result ok=${result.ok ? "yes" : "no"} code=${result.code == null ? "-" : String(result.code)}`); + if (!result.ok) { + const errorText = String(result.stderr || result.error || "").trim(); + if (errorText) { + appendTrace(tracePath, `batch-stderr ${errorText.slice(0, 400)}`); + } + } + if (!result.ok) { + batchCommandError = String(result.stderr || result.error || `command exited with code ${result.code}` || "VSWR batch check failed"); + report.overallStatus = "FAILED"; + } + } + + for (const bandCfg of bands) { + const bandDir = path.join(outputBaseDir, bandCfg.band); + await fs.promises.mkdir(bandDir, { recursive: true }); + const entry = { + band: bandCfg.band, + startHz: bandCfg.startHz, + endHz: bandCfg.endHz, + status: "UNKNOWN", + imageUrl: defaultBandImageUrl(ctx, bandCfg.band), + updatedAt: new Date().toISOString(), + error: null + }; + + if (!cmdTemplate || !canRunHardware) { + entry.status = "OK"; + report.bands.push(entry); + continue; + } + + if (multiBandInSingleRun) { + entry.status = "UNKNOWN"; + report.bands.push(entry); + continue; + } + + const command = cmdTemplate + .replaceAll("{band}", bandCfg.band) + .replaceAll("{startHz}", String(bandCfg.startHz)) + .replaceAll("{endHz}", String(bandCfg.endHz)) + .replaceAll("{bandDir}", quotePathForCmd(bandDir)); + + const result = await ctx.commandRunner(command, { timeoutMs }); + appendTrace(tracePath, `band=${bandCfg.band} ok=${result.ok ? "yes" : "no"} code=${result.code == null ? "-" : String(result.code)}`); + if (result.ok) { + entry.status = "OK"; + } else { + entry.status = "FAILED"; + entry.error = String(result.stderr || result.error || "check failed"); + report.overallStatus = "FAILED"; + } + report.bands.push(entry); + } + + if (multiBandInSingleRun) { + const legacyReportPath = resolvePath(String(ctx.env.VSWR_LEGACY_REPORT_JSON_PATH || `${dataDir}/vswr/legacy-report.json`)); + const overviewHtmlPath = resolvePath(String(ctx.env.VSWR_OVERVIEW_HTML_PATH || `${dataDir}/vswr/index.html`)); + let legacyBands = null; + let overviewBands = null; + if (legacyReportPath && fs.existsSync(legacyReportPath)) { + try { + const parsed = JSON.parse(String(fs.readFileSync(legacyReportPath, "utf8") || "{}")); + if (parsed && Array.isArray(parsed.bands)) { + legacyBands = new Map(parsed.bands.map((entry) => [String(entry.band || "").toLowerCase(), entry])); + if (parsed.overallStatus) { + report.overallStatus = String(parsed.overallStatus).toUpperCase(); + } + } + } catch { + // ignore malformed legacy report + } + } + if (!legacyBands && overviewHtmlPath && fs.existsSync(overviewHtmlPath)) { + overviewBands = parseOverviewStatuses(String(fs.readFileSync(overviewHtmlPath, "utf8") || "")); + } + + const metadataPath = String(ctx.env.VSWR_METADATA_PATH || "").trim(); + if (metadataPath && fs.existsSync(metadataPath)) { + const raw = String(fs.readFileSync(metadataPath, "utf8") || "").trim().toUpperCase(); + if (raw === "FAILED") { + report.overallStatus = "FAILED"; + } else if (raw === "OK") { + report.overallStatus = "OK"; + } else { + report.overallStatus = "UNKNOWN"; + } + } + + for (const entry of report.bands) { + const legacy = legacyBands ? legacyBands.get(String(entry.band || "").toLowerCase()) : null; + if (legacy && legacy.status) { + entry.status = String(legacy.status).toUpperCase(); + entry.error = legacy.error ? String(legacy.error) : null; + } else if (overviewBands && overviewBands.has(String(entry.band || "").toLowerCase())) { + entry.status = overviewBands.get(String(entry.band || "").toLowerCase()) || "UNKNOWN"; + } else { + const expected = resolveImagePath(outputBaseDir, entry.band); + entry.status = fs.existsSync(expected) ? "OK" : "UNKNOWN"; + } + if (entry.status !== "OK" && report.overallStatus === "OK") { + report.overallStatus = "UNKNOWN"; + } + if (batchCommandError && entry.status !== "OK") { + entry.error = batchCommandError; + } + } + + if (batchCommandError && report.overallStatus === "OK") { + report.overallStatus = "FAILED"; + } + } + + await fs.promises.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.promises.writeFile(reportPath, JSON.stringify(report, null, 2)); + appendTrace(tracePath, `run-finish overall=${report.overallStatus}`); + return { + ok: true, + report + }; +} + +function readReport(ctx) { + const dataDir = String(ctx.env.DATA_DIR || "/opt/remotestation-arcg/shared/data").trim() || "/opt/remotestation-arcg/shared/data"; + const reportPath = resolvePath(String(ctx.getSetting("reportJsonPath", ctx.env.VSWR_REPORT_JSON_PATH || `${dataDir}/vswr/swr-report.json`))); + if (!fs.existsSync(reportPath)) { + const base = { + source: "native-controller", + generatedAt: null, + overallStatus: "UNKNOWN", + overviewUrl: String(ctx.env.SWR_OVERVIEW_URL || "") || null, + bands: DEFAULT_BANDS.map((band) => ({ + band: band.band, + startHz: band.startHz, + endHz: band.endHz, + status: "UNKNOWN", + imageUrl: defaultBandImageUrl(ctx, band.band), + updatedAt: null, + error: null + })) + }; + return applyLegacyStatusFallback(base, dataDir, ctx); + } + try { + const raw = fs.readFileSync(reportPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.bands)) { + return applyLegacyStatusFallback(parsed, dataDir, ctx); + } + } catch { + // ignore parse issues + } + const fallback = { + source: "native-controller", + generatedAt: null, + overallStatus: "UNKNOWN", + overviewUrl: String(ctx.env.SWR_OVERVIEW_URL || "") || null, + bands: [] + }; + return applyLegacyStatusFallback(fallback, dataDir, ctx); +} + +function readBands(ctx) { + const raw = String(ctx.getSetting("bandsJson", ctx.env.VSWR_BANDS_JSON || "")).trim(); + if (!raw) return DEFAULT_BANDS; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length === 0) { + return DEFAULT_BANDS; + } + const bands = []; + for (const entry of parsed) { + if (!entry || !entry.band || !entry.startHz || !entry.endHz) continue; + bands.push({ + band: String(entry.band), + startHz: Number(entry.startHz), + endHz: Number(entry.endHz) + }); + } + return bands.length ? bands : DEFAULT_BANDS; + } catch { + return DEFAULT_BANDS; + } +} + +function defaultBandImageUrl(ctx, band) { + const base = String(ctx.getSetting("publicImagesBaseUrl", ctx.env.VSWR_IMAGES_BASE_URL || "")).trim(); + if (!base) return null; + return `${base.replace(/\/$/, "")}/${band}.png`; +} + +function resolvePath(value) { + const v = String(value || "").trim(); + if (!v) return ""; + if (path.isAbsolute(v)) return v; + return path.resolve(process.cwd(), v); +} + +function quotePathForCmd(value) { + const text = String(value || ""); + return text.includes(" ") ? `"${text}"` : text; +} + +function resolveImagePath(outputBaseDir, band) { + const base = path.resolve(outputBaseDir, ".."); + return path.join(base, "images", `${band}.png`); +} + +function appendTrace(tracePath, line) { + if (!tracePath) return; + try { + fs.mkdirSync(path.dirname(tracePath), { recursive: true }); + fs.appendFileSync(tracePath, `[${new Date().toISOString()}] ${line}\n`); + } catch { + // ignore trace write failures + } +} + +function applyLegacyStatusFallback(report, dataDir, ctx) { + const next = { + source: report && report.source ? report.source : "native-controller", + generatedAt: report && report.generatedAt ? report.generatedAt : null, + overallStatus: report && report.overallStatus ? String(report.overallStatus).toUpperCase() : "UNKNOWN", + overviewUrl: report && Object.prototype.hasOwnProperty.call(report, "overviewUrl") + ? report.overviewUrl + : (String(ctx.env.SWR_OVERVIEW_URL || "") || null), + bands: Array.isArray(report && report.bands) + ? report.bands.map((entry) => ({ ...entry })) + : [] + }; + + const legacyReportPath = resolvePath(String(ctx.env.VSWR_LEGACY_REPORT_JSON_PATH || `${dataDir}/vswr/legacy-report.json`)); + if (legacyReportPath && fs.existsSync(legacyReportPath)) { + try { + const parsed = JSON.parse(String(fs.readFileSync(legacyReportPath, "utf8") || "{}")); + if (parsed && Array.isArray(parsed.bands) && shouldApplyLegacyFallback(parsed, next)) { + const legacyMap = new Map(parsed.bands.map((entry) => [String(entry.band || "").toLowerCase(), entry])); + if (parsed.generatedAt) { + next.generatedAt = String(parsed.generatedAt); + } + if (parsed.overallStatus) { + next.overallStatus = String(parsed.overallStatus).toUpperCase(); + } + for (const bandEntry of next.bands) { + const legacy = legacyMap.get(String(bandEntry.band || "").toLowerCase()); + if (!legacy) continue; + if (legacy.status) { + bandEntry.status = String(legacy.status).toUpperCase(); + } + if (Object.prototype.hasOwnProperty.call(legacy, "error")) { + bandEntry.error = legacy.error ? String(legacy.error) : null; + } + } + } + } catch { + // ignore malformed legacy report + } + } + + return next; +} + +function shouldApplyLegacyFallback(legacy, current) { + const legacyAt = parseTimestamp(legacy && legacy.generatedAt); + const currentAt = parseTimestamp(current && current.generatedAt); + if (legacyAt == null) { + return currentAt == null; + } + if (currentAt == null) { + return true; + } + return legacyAt >= currentAt; +} + +function parseTimestamp(value) { + if (!value) return null; + const ts = Date.parse(String(value)); + return Number.isFinite(ts) ? ts : null; +} + +function parseOverviewStatuses(html) { + const map = new Map(); + if (!html) return map; + const pattern = /
  • \s*([0-9]{1,3}m)\s*-\s*]*>\s*(OK|FAILED|UNKNOWN)\s*<\/span>/gi; + for (const match of html.matchAll(pattern)) { + const band = String(match[1] || "").toLowerCase(); + const status = String(match[2] || "").toUpperCase(); + if (band) { + map.set(band, status); + } + } + return map; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.vswr.native/manifest.json b/plugins/rms.vswr.native/manifest.json new file mode 100644 index 0000000..ead9a6a --- /dev/null +++ b/plugins/rms.vswr.native/manifest.json @@ -0,0 +1,23 @@ +{ + "id": "rms.vswr.native", + "name": "VSWR Native Controller", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "vswr.run", + "vswr.report.read" + ], + "settingsSchema": { + "type": "object", + "properties": { + "nanovnaCommandTemplate": { "type": "string" }, + "bandsJson": { "type": "string" }, + "outputBaseDir": { "type": "string" }, + "reportJsonPath": { "type": "string" }, + "publicImagesBaseUrl": { "type": "string" }, + "timeoutMsPerBand": { "type": "integer", "minimum": 1000 } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/plugins/rms.vswr.report_reader/index.js b/plugins/rms.vswr.report_reader/index.js new file mode 100644 index 0000000..03b1158 --- /dev/null +++ b/plugins/rms.vswr.report_reader/index.js @@ -0,0 +1,165 @@ +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_BANDS = ["160m", "80m", "40m", "20m", "17m", "15m", "12m", "10m"]; + +async function createPlugin(ctx) { + return { + async execute(action) { + if (action !== "getReport") { + throw new Error(`Unknown action: ${action}`); + } + return readReport(ctx); + }, + async getStatus() { + const report = readReport(ctx); + return { + overallStatus: report.overallStatus, + generatedAt: report.generatedAt, + bands: report.bands.length + }; + }, + async health() { + return { ok: true }; + } + }; +} + +function readReport(ctx) { + const defaultBase = "/var/www/html/stradnerkogel.arcg.at/vswr"; + const htmlPath = String(ctx.getSetting("overviewHtmlPath", ctx.env.VSWR_OVERVIEW_HTML_PATH || `${defaultBase}/index.html`)).trim(); + const metadataPath = String(ctx.getSetting("metadataPath", ctx.env.VSWR_METADATA_PATH || `${defaultBase}/metadata.txt`)).trim(); + const imagesDirPath = String(ctx.getSetting("imagesDirPath", ctx.env.VSWR_IMAGES_DIR_PATH || `${defaultBase}/images`)).trim(); + const publicImagesBaseUrl = String( + ctx.getSetting("publicImagesBaseUrl", ctx.env.VSWR_IMAGES_BASE_URL || deriveImagesBaseUrl(ctx.env.SWR_OVERVIEW_URL || "")) + ).trim(); + + const report = { + source: "report-reader", + generatedAt: null, + overallStatus: "UNKNOWN", + overviewUrl: String(ctx.env.SWR_OVERVIEW_URL || "") || null, + bands: [] + }; + + if (metadataPath && fs.existsSync(metadataPath)) { + const raw = String(fs.readFileSync(metadataPath, "utf8") || "").trim(); + if (raw) { + report.overallStatus = raw.toUpperCase(); + } + try { + report.generatedAt = fs.statSync(metadataPath).mtime.toISOString(); + } catch { + // ignore + } + } + + const bandMap = new Map(); + for (const band of DEFAULT_BANDS) { + bandMap.set(band, { + band, + status: "UNKNOWN", + imageUrl: publicImagesBaseUrl ? `${publicImagesBaseUrl.replace(/\/$/, "")}/${band}.png` : null + }); + } + + if (htmlPath && fs.existsSync(htmlPath)) { + const html = String(fs.readFileSync(htmlPath, "utf8") || ""); + const overallMatch = /automatic\s+check\s+of\s+bands\s*\(([^)]+)\)\s*:\s*]*>\s*([^<]+)\s*<\/span>/i.exec(html); + if (overallMatch) { + report.generatedAt = normalizeDate(overallMatch[1]) || report.generatedAt; + report.overallStatus = String(overallMatch[3] || overallMatch[2] || report.overallStatus).toUpperCase(); + } + + const liRegex = /
  • \s*([0-9]{2,3}m)\s*-\s*]*>\s*([^<]+)\s*<\/span>/gi; + let match; + while ((match = liRegex.exec(html)) !== null) { + const band = String(match[1] || "").toLowerCase(); + const normalizedBand = DEFAULT_BANDS.find((entry) => entry.toLowerCase() === band) || match[1]; + const status = String(match[3] || match[2] || "UNKNOWN").toUpperCase(); + const prev = bandMap.get(normalizedBand) || { band: normalizedBand, status: "UNKNOWN", imageUrl: null }; + bandMap.set(normalizedBand, { + ...prev, + status + }); + } + + const imgRegex = /]*src="([^"]+)"[^>]*alt="[^"]*?(\d{2,3}m)[^"]*"/gi; + while ((match = imgRegex.exec(html)) !== null) { + const src = String(match[1] || "").trim(); + const band = String(match[2] || "").trim(); + const prev = bandMap.get(band) || { band, status: "UNKNOWN", imageUrl: null }; + bandMap.set(band, { + ...prev, + imageUrl: absolutizeImageUrl(src, publicImagesBaseUrl) + }); + } + } + + if (imagesDirPath && fs.existsSync(imagesDirPath)) { + for (const band of DEFAULT_BANDS) { + const imgPath = path.join(imagesDirPath, `${band}.png`); + if (!fs.existsSync(imgPath)) { + continue; + } + const prev = bandMap.get(band) || { band, status: "UNKNOWN", imageUrl: null }; + if (!prev.imageUrl && publicImagesBaseUrl) { + prev.imageUrl = `${publicImagesBaseUrl.replace(/\/$/, "")}/${band}.png`; + } + try { + const stat = fs.statSync(imgPath); + prev.updatedAt = stat.mtime.toISOString(); + } catch { + // ignore + } + bandMap.set(band, prev); + } + } + + report.bands = Array.from(bandMap.values()); + return report; +} + +function deriveImagesBaseUrl(overviewUrl) { + if (!overviewUrl) { + return ""; + } + return `${String(overviewUrl).replace(/\/$/, "")}/images`; +} + +function absolutizeImageUrl(src, baseUrl) { + if (!src) return null; + if (/^https?:\/\//i.test(src)) { + return src; + } + const base = String(baseUrl || "").replace(/\/$/, ""); + if (!base) { + return src; + } + if (src.startsWith("/")) { + return src; + } + if (src.startsWith("images/")) { + return `${base}/${src.slice("images/".length)}`; + } + return `${base}/${src}`; +} + +function normalizeDate(raw) { + const value = String(raw || "").trim(); + if (!value) return null; + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toISOString(); + } + const normalized = value.replace(/\//g, "-"); + const parsedNormalized = new Date(normalized); + if (!Number.isNaN(parsedNormalized.getTime())) { + return parsedNormalized.toISOString(); + } + return null; +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.vswr.report_reader/manifest.json b/plugins/rms.vswr.report_reader/manifest.json new file mode 100644 index 0000000..259799c --- /dev/null +++ b/plugins/rms.vswr.report_reader/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "rms.vswr.report_reader", + "name": "VSWR Report Reader", + "version": "1.0.0", + "apiVersion": "1.0", + "capabilities": [ + "vswr.report.read" + ], + "settingsSchema": { + "type": "object", + "properties": { + "overviewHtmlPath": { "type": "string" }, + "metadataPath": { "type": "string" }, + "imagesDirPath": { "type": "string" }, + "publicImagesBaseUrl": { "type": "string" } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..72c4933 --- /dev/null +++ b/public/app.js @@ -0,0 +1,4845 @@ +const OPENWEBRX_TX_POLL_MS_DEFAULT = 3000; +const SWR_EXPECTED_DURATION_SEC_DEFAULT = 40; +const SWR_DETAIL_REFRESH_MS = 20000; +const DEFAULT_UI_LANGUAGE = "de"; +const SUPPORTED_UI_LANGUAGES = ["de", "en"]; +const LANGUAGE_STORAGE_KEY = "rms-lang"; + +let activationWatchTimer = null; +let activationWatchInFlight = false; +let activationPending = false; +let remainingUsageTimer = null; +let resumeRefreshInFlight = false; + +const state = { + user: null, + status: null, + system: { + maintenanceMode: false, + maintenanceMessage: "", + updatedAt: null, + branding: { + logoLightUrl: null, + logoDarkUrl: null + } + }, + accessToken: localStorage.getItem("rms-access-token") || "", + refreshToken: localStorage.getItem("rms-refresh-token") || "", + controls: [], + plugins: [], + providers: {}, + capabilities: [], + authMethods: [], + users: [], + helpContent: null, + swrReport: null, + activityEntries: [], + activityFilter: { + query: "", + type: "all" + }, + approvals: [], + approvalsFilter: { + mode: "open", + query: "", + status: "all" + }, + usersFilter: { + query: "", + role: "all", + status: "all" + }, + openWebRx: { + sessionUrl: "", + sessionTicket: "", + expiresAt: null, + bands: [], + selectedBand: "", + txActive: null, + powerCommandConfigured: true, + pttCommandConfigured: true, + rotor: { + azimuth: null, + rawAzimuth: null, + moving: false, + stale: false, + updatedAt: null, + min: 0, + max: 360 + }, + busy: false, + pollMs: OPENWEBRX_TX_POLL_MS_DEFAULT, + pttPressed: false + }, + i18n: { + language: localStorage.getItem(LANGUAGE_STORAGE_KEY) || DEFAULT_UI_LANGUAGE, + dictionaries: {} + } +}; + +const els = { + authForm: document.getElementById("authForm"), + email: document.getElementById("email"), + loginBtn: document.getElementById("loginBtn"), + authMethodSelect: document.getElementById("authMethodSelect"), + otpWrap: document.getElementById("otpWrap"), + otpCode: document.getElementById("otpCode"), + verifyOtpBtn: document.getElementById("verifyOtpBtn"), + authMessage: document.getElementById("authMessage"), + brandLogo: document.getElementById("brandLogo"), + brandFallback: document.getElementById("brandFallback"), + maintenanceBanner: document.getElementById("maintenanceBanner"), + stationName: document.getElementById("stationName"), + usageStatus: document.getElementById("usageStatus"), + activeBy: document.getElementById("activeBy"), + startedAt: document.getElementById("startedAt"), + endsAt: document.getElementById("endsAt"), + remainingUsage: document.getElementById("remainingUsage"), + stationOnlinePill: document.getElementById("stationOnlinePill"), + activateBtn: document.getElementById("activateBtn"), + deactivateBtn: document.getElementById("deactivateBtn"), + refreshBtn: document.getElementById("refreshBtn"), + reservationPanel: document.getElementById("reservationPanel"), + reserveNextBtn: document.getElementById("reserveNextBtn"), + reservationList: document.getElementById("reservationList"), + reservationMessage: document.getElementById("reservationMessage"), + logoutBtn: document.getElementById("logoutBtn"), + activationProgress: document.getElementById("activationProgress"), + progressText: document.getElementById("progressText"), + progressFill: document.getElementById("progressFill"), + progressEta: document.getElementById("progressEta"), + activationProgressSwr: document.getElementById("activationProgressSwr"), + progressTextSwr: document.getElementById("progressTextSwr"), + progressFillSwr: document.getElementById("progressFillSwr"), + progressEtaSwr: document.getElementById("progressEtaSwr"), + stationLinks: document.getElementById("stationLinks"), + swrLink: document.getElementById("swrLink"), + openwebrxLink: document.getElementById("openwebrxLink"), + websdrLink: document.getElementById("websdrLink"), + rotorLink: document.getElementById("rotorLink"), + openwebrxPanel: document.getElementById("openwebrxPanel"), + controlsPanel: document.getElementById("controlsPanel"), + openwebrxOpenBtn: document.getElementById("openwebrxOpenBtn"), + openwebrxBandSelect: document.getElementById("openwebrxBandSelect"), + openwebrxBandSetBtn: document.getElementById("openwebrxBandSetBtn"), + openwebrxEnableTxBtn: document.getElementById("openwebrxEnableTxBtn"), + openwebrxCloseBtn: document.getElementById("openwebrxCloseBtn"), + openwebrxMessage: document.getElementById("openwebrxMessage"), + openwebrxSessionAccess: document.getElementById("openwebrxSessionAccess"), + openwebrxSessionLink: document.getElementById("openwebrxSessionLink"), + openwebrxCopyLinkBtn: document.getElementById("openwebrxCopyLinkBtn"), + openwebrxSessionTicket: document.getElementById("openwebrxSessionTicket"), + openwebrxTxStatePill: document.getElementById("openwebrxTxStatePill"), + rotorCurrent: document.getElementById("rotorCurrent"), + rotorCompass: document.getElementById("rotorCompass"), + rotorCompassArrow: document.getElementById("rotorCompassArrow"), + rotorTarget: document.getElementById("rotorTarget"), + rotorSetBtn: document.getElementById("rotorSetBtn"), + rotorPresets: document.getElementById("rotorPresets"), + controlsMessage: document.getElementById("controlsMessage"), + statusMessage: document.getElementById("statusMessage"), + swrSummaryGeneratedAt: document.getElementById("swrSummaryGeneratedAt"), + swrSummaryOverall: document.getElementById("swrSummaryOverall"), + swrSummaryBands: document.getElementById("swrSummaryBands"), + swrSummaryMessage: document.getElementById("swrSummaryMessage"), + refreshSwrBtn: document.getElementById("refreshSwrBtn"), + runSwrCheckBtn: document.getElementById("runSwrCheckBtn"), + swrPageGeneratedAt: document.getElementById("swrPageGeneratedAt"), + swrPageOverall: document.getElementById("swrPageOverall"), + swrPageBands: document.getElementById("swrPageBands"), + swrPageMessage: document.getElementById("swrPageMessage"), + refreshSwrPageBtn: document.getElementById("refreshSwrPageBtn"), + runSwrCheckPageBtn: document.getElementById("runSwrCheckPageBtn"), + themeToggle: document.getElementById("themeToggle"), + authView: document.getElementById("authView"), + rmsView: document.getElementById("rmsView"), + pageRms: document.getElementById("pageRms"), + pageSwr: document.getElementById("pageSwr"), + pageUser: document.getElementById("pageUser"), + pageHelp: document.getElementById("pageHelp"), + pagePlugins: document.getElementById("pagePlugins"), + pagePluginConfig: document.getElementById("pagePluginConfig"), + pageProviders: document.getElementById("pageProviders"), + pageAdmin: document.getElementById("pageAdmin"), + pageUsers: document.getElementById("pageUsers"), + pageApprovals: document.getElementById("pageApprovals"), + pageActivity: document.getElementById("pageActivity"), + userMenuButton: document.getElementById("userMenuButton"), + userMenu: document.getElementById("userMenu"), + menuRms: document.getElementById("menuRms"), + menuSwr: document.getElementById("menuSwr"), + menuUser: document.getElementById("menuUser"), + menuHelp: document.getElementById("menuHelp"), + menuPlugins: document.getElementById("menuPlugins"), + menuPluginConfig: document.getElementById("menuPluginConfig"), + menuProviders: document.getElementById("menuProviders"), + menuUsers: document.getElementById("menuUsers"), + menuApprovals: document.getElementById("menuApprovals"), + menuActivity: document.getElementById("menuActivity"), + menuAdmin: document.getElementById("menuAdmin"), + languageMenuButton: document.getElementById("languageMenuButton"), + languageMenu: document.getElementById("languageMenu"), + menuLanguageSelect: document.getElementById("menuLanguageSelect"), + settingsEmail: document.getElementById("settingsEmail"), + settingsRole: document.getElementById("settingsRole"), + settingsAuthMethodSelect: document.getElementById("settingsAuthMethodSelect"), + settingsLanguageSelect: document.getElementById("settingsLanguageSelect"), + settingsSaveAuthMethodBtn: document.getElementById("settingsSaveAuthMethodBtn"), + settingsSaveLanguageBtn: document.getElementById("settingsSaveLanguageBtn"), + settingsThemeBtn: document.getElementById("settingsThemeBtn"), + settingsRefreshBtn: document.getElementById("settingsRefreshBtn"), + pageTitle: document.getElementById("pageTitle"), + pageHint: document.getElementById("pageHint"), + pageCrumb: document.getElementById("pageCrumb"), + currentUserLink: document.getElementById("currentUserLink"), + mobileNav: document.getElementById("mobileNav"), + mobileNavRms: document.getElementById("mobileNavRms"), + mobileNavSwr: document.getElementById("mobileNavSwr"), + mobileNavUser: document.getElementById("mobileNavUser"), + mobileNavHelp: document.getElementById("mobileNavHelp"), + mobileNavPlugins: document.getElementById("mobileNavPlugins"), + mobileNavPluginConfig: document.getElementById("mobileNavPluginConfig"), + mobileNavUsers: document.getElementById("mobileNavUsers"), + mobileNavApprovals: document.getElementById("mobileNavApprovals"), + mobileNavActivity: document.getElementById("mobileNavActivity"), + mobileNavAdmin: document.getElementById("mobileNavAdmin"), + adminCard: document.getElementById("adminCard"), + setOnlineBtn: document.getElementById("setOnlineBtn"), + setOfflineBtn: document.getElementById("setOfflineBtn"), + forceReleaseBtn: document.getElementById("forceReleaseBtn"), + refreshAuditBtn: document.getElementById("refreshAuditBtn"), + roleEmail: document.getElementById("roleEmail"), + setRoleAdminBtn: document.getElementById("setRoleAdminBtn"), + setRoleOperatorBtn: document.getElementById("setRoleOperatorBtn"), + adminMessage: document.getElementById("adminMessage"), + auditLog: document.getElementById("auditLog"), + pluginControls: document.getElementById("pluginControls"), + pluginMessage: document.getElementById("pluginMessage"), + pluginsConfigCard: document.getElementById("pluginsConfigCard"), + pluginsAdminConfig: document.getElementById("pluginsAdminConfig"), + refreshPluginsPageBtn: document.getElementById("refreshPluginsPageBtn"), + providersCapabilityMatrix: document.getElementById("providersCapabilityMatrix"), + providersAdminConfig: document.getElementById("providersAdminConfig"), + providersMessage: document.getElementById("providersMessage"), + refreshProvidersBtn: document.getElementById("refreshProvidersBtn"), + usersAdmin: document.getElementById("usersAdmin"), + usersMessage: document.getElementById("usersMessage"), + usersFilterQuery: document.getElementById("usersFilterQuery"), + usersFilterRole: document.getElementById("usersFilterRole"), + usersFilterStatus: document.getElementById("usersFilterStatus"), + refreshUsersBtn: document.getElementById("refreshUsersBtn"), + approvalsList: document.getElementById("approvalsList"), + approvalsMessage: document.getElementById("approvalsMessage"), + approvalsFilterQuery: document.getElementById("approvalsFilterQuery"), + approvalsFilterStatus: document.getElementById("approvalsFilterStatus"), + approvalsFilterOpenBtn: document.getElementById("approvalsFilterOpenBtn"), + approvalsFilterAllBtn: document.getElementById("approvalsFilterAllBtn"), + refreshApprovalsBtn: document.getElementById("refreshApprovalsBtn"), + activityLogList: document.getElementById("activityLogList"), + activityMessage: document.getElementById("activityMessage"), + activityFilterQuery: document.getElementById("activityFilterQuery"), + activityFilterType: document.getElementById("activityFilterType"), + refreshActivityBtn: document.getElementById("refreshActivityBtn"), + helpTitle: document.getElementById("helpTitle"), + helpQuickStartTitle: document.getElementById("helpQuickStartTitle"), + helpQuickStartSteps: document.getElementById("helpQuickStartSteps"), + helpSections: document.getElementById("helpSections"), + helpMessage: document.getElementById("helpMessage"), + refreshHelpBtn: document.getElementById("refreshHelpBtn"), + maintenanceStatePill: document.getElementById("maintenanceStatePill"), + maintenanceMessageInput: document.getElementById("maintenanceMessageInput"), + maintenanceEnableBtn: document.getElementById("maintenanceEnableBtn"), + maintenanceDisableBtn: document.getElementById("maintenanceDisableBtn"), + logoLightFile: document.getElementById("logoLightFile"), + logoDarkFile: document.getElementById("logoDarkFile"), + uploadLogoLightBtn: document.getElementById("uploadLogoLightBtn"), + uploadLogoDarkBtn: document.getElementById("uploadLogoDarkBtn"), + removeLogoLightBtn: document.getElementById("removeLogoLightBtn"), + removeLogoDarkBtn: document.getElementById("removeLogoDarkBtn") +}; + +init().catch((error) => { + renderMessage(els.authMessage, error.message || "Initialisierung fehlgeschlagen", true); +}); + +async function init() { + await initI18n(); + loadTheme(); + hydrateFilterStateFromUrl(); + bindEvents(); + await refreshPublicSystemStatus(); + await refreshPublicAuthMethods(); + await handleEmailTokenFromUrl(); + await refreshCurrentUser(); + await ensureLanguageFromUserPreference(); + applyRoute(true); + if (state.user) { + await refreshStatus(); + await refreshSwrReport(); + await refreshControls(); + await refreshPlugins(); + await refreshUsers(); + await refreshApprovals(); + await refreshActivityLog(); + await refreshHelpContent(); + connectEvents(); + } + + setInterval(() => { + if (!state.user) { + refreshPublicSystemStatus().catch(() => {}); + return; + } + const route = currentRoute(); + if (route === "/rms/swr" || route === "/rms") { + refreshStatus().catch(() => {}); + refreshSwrReport().catch(() => {}); + } + }, SWR_DETAIL_REFRESH_MS); +} + +async function initI18n() { + const initial = normalizeLanguage(state.i18n.language); + state.i18n.language = initial; + await ensureLanguageDictionary("de"); + if (initial !== "de") { + await ensureLanguageDictionary(initial); + } + applyI18n(); +} + +function normalizeLanguage(value) { + const next = String(value || "").trim().toLowerCase(); + return SUPPORTED_UI_LANGUAGES.includes(next) ? next : DEFAULT_UI_LANGUAGE; +} + +async function ensureLanguageDictionary(language) { + const normalized = normalizeLanguage(language); + if (state.i18n.dictionaries[normalized]) { + return state.i18n.dictionaries[normalized]; + } + try { + const response = await fetch(`/i18n/${encodeURIComponent(normalized)}.json`, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Failed to load language ${normalized}`); + } + const parsed = await response.json(); + state.i18n.dictionaries[normalized] = parsed && typeof parsed === "object" ? parsed : {}; + } catch { + state.i18n.dictionaries[normalized] = {}; + } + return state.i18n.dictionaries[normalized]; +} + +function languageDictionary(language) { + return state.i18n.dictionaries[normalizeLanguage(language)] || {}; +} + +function translateLiteral(text) { + const source = String(text || ""); + if (!source) { + return source; + } + const current = languageDictionary(state.i18n.language); + const currentLiterals = current && current.literals && typeof current.literals === "object" ? current.literals : {}; + if (Object.prototype.hasOwnProperty.call(currentLiterals, source)) { + return String(currentLiterals[source]); + } + const de = languageDictionary("de"); + const deLiterals = de && de.literals && typeof de.literals === "object" ? de.literals : {}; + if (Object.prototype.hasOwnProperty.call(deLiterals, source)) { + return String(deLiterals[source]); + } + return source; +} + +const originalTextNodes = new WeakMap(); +const originalElementAttributes = new WeakMap(); + +function applyI18n(root = document.body) { + if (!root) { + return; + } + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node || !node.nodeValue || !node.nodeValue.trim()) { + return NodeFilter.FILTER_REJECT; + } + const parent = node.parentElement; + if (!parent) { + return NodeFilter.FILTER_REJECT; + } + if (["SCRIPT", "STYLE", "CODE", "PRE"].includes(parent.tagName)) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + const textNodes = []; + let node = walker.nextNode(); + while (node) { + textNodes.push(node); + node = walker.nextNode(); + } + for (const textNode of textNodes) { + const original = originalTextNodes.has(textNode) + ? originalTextNodes.get(textNode) + : String(textNode.nodeValue); + if (!originalTextNodes.has(textNode)) { + originalTextNodes.set(textNode, original); + } + const leading = original.match(/^\s*/)?.[0] || ""; + const trailing = original.match(/\s*$/)?.[0] || ""; + const trimmed = original.trim(); + textNode.nodeValue = trimmed ? `${leading}${translateLiteral(trimmed)}${trailing}` : original; + } + const attrNames = ["placeholder", "title", "aria-label"]; + const elements = root.querySelectorAll("*"); + for (const el of elements) { + if (!originalElementAttributes.has(el)) { + originalElementAttributes.set(el, {}); + } + const originalAttrs = originalElementAttributes.get(el); + for (const attr of attrNames) { + const value = el.getAttribute(attr); + if (!value) { + continue; + } + if (!Object.prototype.hasOwnProperty.call(originalAttrs, attr)) { + originalAttrs[attr] = value; + } + el.setAttribute(attr, translateLiteral(originalAttrs[attr])); + } + } + renderLanguageSelectors(); +} + +function localeForDate() { + return state.i18n.language === "en" ? "en-GB" : "de-AT"; +} + +async function setLanguage(language, options = {}) { + const normalized = normalizeLanguage(language); + await ensureLanguageDictionary(normalized); + state.i18n.language = normalized; + if (options.persist !== false) { + localStorage.setItem(LANGUAGE_STORAGE_KEY, normalized); + } + applyI18n(); + renderStatus(); + renderSwrPanels(); + renderHelpContent(); + if (options.saveUserDefault) { + await savePreferredLanguage(); + } +} + +async function ensureLanguageFromUserPreference() { + if (!state.user || !state.user.preferredLanguage) { + return; + } + const preferred = normalizeLanguage(state.user.preferredLanguage); + if (preferred === normalizeLanguage(state.i18n.language)) { + renderLanguageSelectors(); + return; + } + await setLanguage(preferred, { persist: true, saveUserDefault: false }); +} + +function renderLanguageSelectors() { + const dictionary = languageDictionary(state.i18n.language); + const locales = dictionary && dictionary.locales && typeof dictionary.locales === "object" + ? dictionary.locales + : { de: "Deutsch", en: "English" }; + const options = SUPPORTED_UI_LANGUAGES.map((lang) => ({ + value: lang, + label: locales[lang] || lang.toUpperCase() + })); + for (const select of [els.menuLanguageSelect, els.settingsLanguageSelect]) { + if (!select) { + continue; + } + const currentValue = select.value; + select.innerHTML = ""; + for (const optionData of options) { + const option = document.createElement("option"); + option.value = optionData.value; + option.textContent = optionData.label; + select.appendChild(option); + } + select.value = normalizeLanguage(state.i18n.language || currentValue || DEFAULT_UI_LANGUAGE); + } +} + +function bindEvents() { + const on = (el, event, handler) => { + if (el) { + el.addEventListener(event, handler); + } + }; + + els.authForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await requestAccess(); + }); + + els.activateBtn.addEventListener("click", async () => { + await activateStation(); + }); + + els.deactivateBtn.addEventListener("click", async () => { + await releaseStation(); + }); + + els.refreshBtn.addEventListener("click", async () => { + await refreshStatus(); + await refreshSwrReport(); + await refreshControls(); + }); + + if (els.reserveNextBtn) { + els.reserveNextBtn.addEventListener("click", async () => { + await reserveNextSlot(); + }); + } + + els.logoutBtn.addEventListener("click", async () => { + await logout(); + }); + + els.themeToggle.addEventListener("click", () => { + toggleTheme(); + }); + + els.settingsThemeBtn.addEventListener("click", () => { + toggleTheme(); + }); + + if (els.settingsSaveAuthMethodBtn) { + els.settingsSaveAuthMethodBtn.addEventListener("click", async () => { + await savePreferredAuthMethod(); + }); + } + + if (els.settingsSaveLanguageBtn) { + els.settingsSaveLanguageBtn.addEventListener("click", async () => { + await savePreferredLanguage(); + }); + } + + if (els.menuLanguageSelect) { + els.menuLanguageSelect.addEventListener("change", async () => { + await setLanguage(els.menuLanguageSelect.value, { persist: true, saveUserDefault: false }); + setLanguageMenuOpen(false); + }); + } + + if (els.settingsLanguageSelect) { + els.settingsLanguageSelect.addEventListener("change", async () => { + await setLanguage(els.settingsLanguageSelect.value, { persist: true, saveUserDefault: false }); + }); + } + + els.settingsRefreshBtn.addEventListener("click", async () => { + await refreshPublicSystemStatus(); + await refreshStatus(); + await refreshSwrReport(); + await refreshControls(); + if (isAdmin()) { + await refreshPlugins(); + await refreshUsers(); + } + if (canSeeApprovals()) { + await refreshApprovals(); + } + if (canSeeActivityLog()) { + await refreshActivityLog(); + } + await refreshHelpContent(); + }); + + els.userMenuButton.addEventListener("click", (event) => { + if (!state.user) { + return; + } + event.stopPropagation(); + setLanguageMenuOpen(false); + setUserMenuOpen(els.userMenu.hidden); + }); + if (els.languageMenuButton) { + els.languageMenuButton.addEventListener("click", (event) => { + event.stopPropagation(); + setUserMenuOpen(false); + setLanguageMenuOpen(els.languageMenu ? els.languageMenu.hidden : false); + }); + } + if (els.languageMenu) { + els.languageMenu.addEventListener("click", (event) => { + event.stopPropagation(); + }); + } + on(els.menuRms, "click", () => navigateRmsPage("rms")); + on(els.menuSwr, "click", () => navigateRmsPage("swr")); + on(els.menuUser, "click", () => navigateRmsPage("user")); + on(els.menuHelp, "click", () => navigateRmsPage("help")); + on(els.menuPlugins, "click", () => navigateRmsPage("plugins")); + on(els.menuPluginConfig, "click", () => navigateRmsPage("plugin-config")); + on(els.menuProviders, "click", () => navigateRmsPage("providers")); + on(els.menuUsers, "click", () => navigateRmsPage("users")); + on(els.menuApprovals, "click", () => navigateRmsPage("approvals")); + on(els.menuActivity, "click", () => navigateRmsPage("activity")); + on(els.menuAdmin, "click", () => navigateRmsPage("admin")); + on(els.mobileNavRms, "click", () => navigateRmsPage("rms")); + on(els.mobileNavSwr, "click", () => navigateRmsPage("swr")); + on(els.mobileNavUser, "click", () => navigateRmsPage("user")); + on(els.mobileNavHelp, "click", () => navigateRmsPage("help")); + on(els.mobileNavPlugins, "click", () => navigateRmsPage("plugins")); + on(els.mobileNavPluginConfig, "click", () => navigateRmsPage("plugin-config")); + on(els.mobileNavUsers, "click", () => navigateRmsPage("users")); + on(els.mobileNavApprovals, "click", () => navigateRmsPage("approvals")); + on(els.mobileNavActivity, "click", () => navigateRmsPage("activity")); + on(els.mobileNavAdmin, "click", () => navigateRmsPage("admin")); + if (els.currentUserLink) { + els.currentUserLink.addEventListener("click", (event) => { + event.preventDefault(); + navigateRmsPage("user"); + }); + } + document.addEventListener("click", () => { + setUserMenuOpen(false); + setLanguageMenuOpen(false); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + setUserMenuOpen(false); + setLanguageMenuOpen(false); + } + }); + + window.addEventListener("popstate", () => { + hydrateFilterStateFromUrl(); + applyRoute(true); + }); + + window.addEventListener("pageshow", () => { + void refreshFrontendOnResume(); + }); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + void refreshFrontendOnResume(); + } + }); + + if (els.refreshProvidersBtn) { + els.refreshProvidersBtn.addEventListener("click", async () => { + await refreshPlugins(); + await refreshControls(); + }); + } + if (els.refreshSwrBtn) { + els.refreshSwrBtn.addEventListener("click", async () => { + await refreshSwrReport(); + }); + } + if (els.refreshSwrPageBtn) { + els.refreshSwrPageBtn.addEventListener("click", async () => { + await refreshSwrReport(); + }); + } + if (els.runSwrCheckBtn) { + els.runSwrCheckBtn.addEventListener("click", async () => { + await runManualSwrCheck(); + }); + } + if (els.runSwrCheckPageBtn) { + els.runSwrCheckPageBtn.addEventListener("click", async () => { + await runManualSwrCheck(); + }); + } + if (els.openwebrxOpenBtn) { + els.openwebrxOpenBtn.addEventListener("click", async () => { + await openOpenWebRxSession(); + }); + } + if (els.openwebrxLink) { + els.openwebrxLink.addEventListener("click", async (event) => { + event.preventDefault(); + await openOpenWebRxExternal(); + }); + } + if (els.openwebrxCopyLinkBtn) { + els.openwebrxCopyLinkBtn.addEventListener("click", async () => { + await copyOpenWebRxLink(); + }); + } + if (els.openwebrxEnableTxBtn) { + els.openwebrxEnableTxBtn.addEventListener("click", async (event) => { + if (event) { + event.preventDefault(); + } + await toggleOpenWebRxTxPower(); + }); + } + if (els.rotorSetBtn) { + els.rotorSetBtn.addEventListener("click", async () => { + await setOpenWebRxRotor(); + }); + } + if (els.rotorPresets) { + els.rotorPresets.addEventListener("click", async (event) => { + const target = event && event.target ? event.target.closest("button[data-azimuth]") : null; + if (!target || !els.rotorTarget) { + return; + } + const azimuth = Number(target.getAttribute("data-azimuth")); + if (!Number.isFinite(azimuth)) { + return; + } + els.rotorTarget.value = String(Math.round(azimuth)); + await setOpenWebRxRotor(); + }); + } + if (els.openwebrxBandSetBtn) { + els.openwebrxBandSetBtn.addEventListener("click", async () => { + await setOpenWebRxBand(); + }); + } + if (els.openwebrxCloseBtn) { + els.openwebrxCloseBtn.addEventListener("click", async () => { + await closeOpenWebRxSession(); + await refreshStatus(); + }); + } + if (els.refreshPluginsPageBtn) { + els.refreshPluginsPageBtn.addEventListener("click", async () => { + await refreshPlugins(); + await refreshControls(); + }); + } + + if (els.refreshUsersBtn) { + els.refreshUsersBtn.addEventListener("click", async () => { + await refreshUsers(); + }); + } + if (els.usersFilterQuery) { + els.usersFilterQuery.addEventListener("input", () => { + state.usersFilter.query = els.usersFilterQuery.value.trim().toLowerCase(); + renderUsersAdmin(); + updateRouteQueryForCurrentPage(); + }); + } + if (els.usersFilterRole) { + els.usersFilterRole.addEventListener("change", () => { + state.usersFilter.role = els.usersFilterRole.value; + renderUsersAdmin(); + updateRouteQueryForCurrentPage(); + }); + } + if (els.usersFilterStatus) { + els.usersFilterStatus.addEventListener("change", () => { + state.usersFilter.status = els.usersFilterStatus.value; + renderUsersAdmin(); + updateRouteQueryForCurrentPage(); + }); + } + + if (els.refreshApprovalsBtn) { + els.refreshApprovalsBtn.addEventListener("click", async () => { + await refreshApprovals(); + }); + } + if (els.refreshActivityBtn) { + els.refreshActivityBtn.addEventListener("click", async () => { + await refreshActivityLog(); + }); + } + if (els.refreshHelpBtn) { + els.refreshHelpBtn.addEventListener("click", async () => { + await refreshHelpContent(); + }); + } + if (els.activityFilterQuery) { + els.activityFilterQuery.addEventListener("input", () => { + state.activityFilter.query = els.activityFilterQuery.value.trim().toLowerCase(); + renderActivityLog(); + updateRouteQueryForCurrentPage(); + }); + } + if (els.activityFilterType) { + els.activityFilterType.addEventListener("change", () => { + state.activityFilter.type = els.activityFilterType.value; + renderActivityLog(); + updateRouteQueryForCurrentPage(); + }); + } + if (els.approvalsFilterOpenBtn) { + els.approvalsFilterOpenBtn.addEventListener("click", () => { + state.approvalsFilter.mode = "open"; + renderApprovals(); + updateRouteQueryForCurrentPage(); + }); + } + if (els.approvalsFilterAllBtn) { + els.approvalsFilterAllBtn.addEventListener("click", () => { + state.approvalsFilter.mode = "all"; + renderApprovals(); + updateRouteQueryForCurrentPage(); + }); + } + if (els.approvalsFilterQuery) { + els.approvalsFilterQuery.addEventListener("input", () => { + state.approvalsFilter.query = els.approvalsFilterQuery.value.trim().toLowerCase(); + renderApprovals(); + updateRouteQueryForCurrentPage(); + }); + } + if (els.approvalsFilterStatus) { + els.approvalsFilterStatus.addEventListener("change", () => { + state.approvalsFilter.status = els.approvalsFilterStatus.value; + renderApprovals(); + updateRouteQueryForCurrentPage(); + }); + } + + if (els.maintenanceEnableBtn) { + els.maintenanceEnableBtn.addEventListener("click", async () => { + await setMaintenanceMode(true); + }); + } + if (els.maintenanceDisableBtn) { + els.maintenanceDisableBtn.addEventListener("click", async () => { + await setMaintenanceMode(false); + }); + } + if (els.uploadLogoLightBtn) { + els.uploadLogoLightBtn.addEventListener("click", async () => { + await uploadBrandLogo("light", els.logoLightFile); + }); + } + if (els.uploadLogoDarkBtn) { + els.uploadLogoDarkBtn.addEventListener("click", async () => { + await uploadBrandLogo("dark", els.logoDarkFile); + }); + } + if (els.removeLogoLightBtn) { + els.removeLogoLightBtn.addEventListener("click", async () => { + await removeBrandLogo("light"); + }); + } + if (els.removeLogoDarkBtn) { + els.removeLogoDarkBtn.addEventListener("click", async () => { + await removeBrandLogo("dark"); + }); + } + + if (els.verifyOtpBtn) { + els.verifyOtpBtn.addEventListener("click", async () => { + await verifyOtpCode(); + }); + } + + const adminUnsupported = [ + els.setOnlineBtn, + els.setOfflineBtn, + els.forceReleaseBtn, + els.refreshAuditBtn, + els.setRoleAdminBtn, + els.setRoleOperatorBtn + ]; + for (const btn of adminUnsupported) { + on(btn, "click", () => { + renderMessage(els.adminMessage, "Dieser Admin-Bereich wird auf die neue Plugin-Admin-API migriert.", true); + }); + } +} + +async function refreshFrontendOnResume() { + if (resumeRefreshInFlight) { + return; + } + resumeRefreshInFlight = true; + try { + await refreshPublicSystemStatus(); + await refreshPublicAuthMethods(); + await refreshCurrentUser(); + applyRoute(true); + if (!state.user) { + return; + } + await refreshStatus(); + await refreshSwrReport(); + await refreshControls(); + const route = currentRoute(); + if (route === "/rms/plugins" || route === "/rms/plugin-konfig" || route === "/rms/providers") { + await refreshPlugins(); + } + if (route === "/rms/users") { + await refreshUsers(); + } + if (route === "/rms/freigaben") { + await refreshApprovals(); + } + if (route === "/rms/aktivitaet") { + await refreshActivityLog(); + } + if (route === "/rms/hilfe") { + await refreshHelpContent(); + } + connectEvents(); + } catch { + // best effort resume refresh + } finally { + resumeRefreshInFlight = false; + } +} + +async function requestAccess() { + clearMessages("auth"); + const email = els.email.value.trim(); + const method = els.authMethodSelect.value; + try { + const result = await api("/v1/auth/request-access", { + method: "POST", + body: { email, method }, + authRequired: false + }); + els.otpWrap.hidden = result.challengeType !== "otp"; + renderMessage(els.authMessage, result.message || "Bitte E-Mail pruefen.", false, true); + } catch (error) { + if (error && error.status === 404) { + try { + const fallback = await api("/v1/auth/login", { + method: "POST", + body: { email, method }, + authRequired: false + }); + els.otpWrap.hidden = fallback.challengeType !== "otp"; + renderMessage(els.authMessage, fallback.message || "Bitte E-Mail pruefen.", false, true); + return; + } catch { + renderMessage(els.authMessage, "Login-Endpunkt nicht gefunden. Bitte Backend/Server neu starten.", true); + return; + } + } + renderMessage(els.authMessage, error.message, true); + } +} + +async function verifyOtpCode() { + clearMessages("auth"); + try { + const result = await api("/v1/auth/verify-email", { + method: "POST", + body: { + email: els.email.value.trim(), + code: els.otpCode.value.trim() + }, + authRequired: false + }); + if (result.accessToken) { + setTokens(result.accessToken, result.refreshToken); + state.user = result.user; + await ensureLanguageFromUserPreference(); + els.otpCode.value = ""; + els.otpWrap.hidden = true; + applyRoute(); + updateUserUi(); + await refreshStatus(); + await refreshSwrReport(); + await refreshControls(); + await refreshPlugins(); + await refreshUsers(); + await refreshApprovals(); + await refreshActivityLog(); + connectEvents(); + renderMessage(els.authMessage, `Willkommen ${result.user.email}`, false, true); + return; + } + renderMessage(els.authMessage, result.message || "Code bestaetigt.", false, true); + } catch (error) { + renderMessage(els.authMessage, error.message, true); + } +} + +async function handleEmailTokenFromUrl() { + const url = new URL(window.location.href); + if (url.searchParams.get("requestApproval") === "1") { + const email = (url.searchParams.get("email") || "").trim(); + if (email) { + els.email.value = email; + try { + const result = await api("/v1/auth/request-approval", { + method: "POST", + body: { email }, + authRequired: false + }); + renderMessage(els.authMessage, result.message || "Freigabe angefordert.", false, true); + } catch (error) { + renderMessage(els.authMessage, error.message, true); + } + } + url.searchParams.delete("requestApproval"); + url.searchParams.delete("email"); + window.history.replaceState({}, "", `${url.pathname}${url.search}`); + } + + const token = url.searchParams.get("verifyToken") || url.searchParams.get("loginToken"); + if (!token) { + return; + } + clearMessages("auth"); + try { + const result = await api("/v1/auth/verify-email", { + method: "POST", + body: { token }, + authRequired: false + }); + if (result.accessToken) { + setTokens(result.accessToken, result.refreshToken); + state.user = result.user; + await ensureLanguageFromUserPreference(); + renderMessage(els.authMessage, `Willkommen ${result.user.email}`, false, true); + url.searchParams.delete("verifyToken"); + url.searchParams.delete("loginToken"); + window.history.replaceState({}, "", `${url.pathname}${url.search}`); + } else { + renderMessage(els.authMessage, result.message || "Link verarbeitet.", false, true); + } + } catch (error) { + renderMessage(els.authMessage, error.message, true); + } +} + +async function refreshPublicSystemStatus() { + try { + const result = await api("/v1/public/system", { authRequired: false }); + state.system = { + maintenanceMode: Boolean(result.maintenanceMode), + maintenanceMessage: result.maintenanceMessage || "", + updatedAt: result.updatedAt || null, + branding: normalizeBranding(result.branding) + }; + } catch { + state.system = { + maintenanceMode: false, + maintenanceMessage: "", + updatedAt: null, + branding: normalizeBranding(null) + }; + } + renderMaintenanceBanner(); + renderBranding(); +} + +async function refreshPublicAuthMethods() { + try { + const result = await api("/v1/public/auth-methods", { authRequired: false }); + state.authMethods = Array.isArray(result.methods) ? result.methods : []; + } catch { + state.authMethods = []; + } + renderAuthMethods(); +} + +function renderAuthMethods() { + if (!els.authMethodSelect) { + return; + } + els.authMethodSelect.innerHTML = ""; + if (!state.authMethods.length) { + const option = document.createElement("option"); + option.value = "smtp-link"; + option.textContent = "per Mail"; + els.authMethodSelect.appendChild(option); + els.authMethodSelect.value = "smtp-link"; + renderUserSettingsAuthMethods(); + return; + } + for (const method of state.authMethods) { + const option = document.createElement("option"); + option.value = method.id; + option.textContent = method.label; + els.authMethodSelect.appendChild(option); + } + if (state.authMethods.some((method) => method.id === "smtp-link")) { + els.authMethodSelect.value = "smtp-link"; + } + renderUserSettingsAuthMethods(); +} + +function renderUserSettingsAuthMethods() { + if (!els.settingsAuthMethodSelect || !els.settingsSaveAuthMethodBtn) { + return; + } + const select = els.settingsAuthMethodSelect; + select.innerHTML = ""; + const userMethods = new Set(Array.isArray(state.user && state.user.enabledAuthMethods) ? state.user.enabledAuthMethods : []); + const methods = state.authMethods.filter((method) => userMethods.has(method.id)); + if (!methods.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Keine Methode verfuegbar"; + select.appendChild(option); + select.disabled = true; + els.settingsSaveAuthMethodBtn.disabled = true; + return; + } + for (const method of methods) { + const option = document.createElement("option"); + option.value = method.id; + option.textContent = method.label; + select.appendChild(option); + } + if (state.user && methods.some((method) => method.id === state.user.primaryAuthMethod)) { + select.value = state.user.primaryAuthMethod; + } else if (methods.some((method) => method.id === "smtp-link")) { + select.value = "smtp-link"; + } + select.disabled = false; + els.settingsSaveAuthMethodBtn.disabled = false; +} + +async function savePreferredAuthMethod() { + const primaryMethod = els.settingsAuthMethodSelect ? els.settingsAuthMethodSelect.value : ""; + if (!primaryMethod) return; + try { + const result = await api("/v1/me/auth-method", { + method: "PUT", + body: { primaryMethod } + }); + state.user = result.user; + renderUserSettingsAuthMethods(); + renderMessage(els.authMessage, "Praeferierte Authentifizierung gespeichert.", false, true); + } catch (error) { + renderMessage(els.authMessage, error.message, true); + } +} + +async function savePreferredLanguage() { + if (!state.user) { + return; + } + const preferredLanguage = normalizeLanguage(els.settingsLanguageSelect ? els.settingsLanguageSelect.value : state.i18n.language); + try { + const result = await api("/v1/me/language", { + method: "PUT", + body: { preferredLanguage } + }); + state.user = result.user; + await setLanguage(preferredLanguage, { persist: true, saveUserDefault: false }); + renderMessage(els.authMessage, translateLiteral("Praeferierte Sprache gespeichert."), false, true); + } catch (error) { + renderMessage(els.authMessage, error.message, true); + } +} + +function renderMaintenanceBanner() { + const active = Boolean(state.system.maintenanceMode); + els.maintenanceBanner.hidden = !active; + els.maintenanceBanner.textContent = active ? (state.system.maintenanceMessage || translateLiteral("Wartungsmodus aktiv")) : ""; + els.maintenanceBanner.className = active ? "message error" : "message"; + if (els.maintenanceStatePill) { + els.maintenanceStatePill.textContent = active ? translateLiteral("Aktiv") : translateLiteral("Inaktiv"); + els.maintenanceStatePill.classList.toggle("offline", active); + els.maintenanceStatePill.classList.toggle("ok", !active); + } + if (els.maintenanceMessageInput && !els.maintenanceMessageInput.value) { + els.maintenanceMessageInput.value = state.system.maintenanceMessage || ""; + } +} + +function renderBranding() { + if (!els.brandLogo || !els.brandFallback) { + return; + } + const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark"; + const branding = normalizeBranding(state.system && state.system.branding); + const logoUrl = theme === "light" ? branding.logoLightUrl : branding.logoDarkUrl; + if (logoUrl) { + const sep = logoUrl.includes("?") ? "&" : "?"; + els.brandLogo.src = `${logoUrl}${sep}v=${encodeURIComponent(String(state.system.updatedAt || ""))}`; + els.brandLogo.hidden = false; + els.brandFallback.hidden = true; + } else { + els.brandLogo.hidden = true; + els.brandLogo.removeAttribute("src"); + els.brandFallback.hidden = false; + } +} + +function normalizeBranding(value) { + const branding = value && typeof value === "object" ? value : {}; + return { + logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null, + logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null + }; +} + +async function uploadBrandLogo(theme, inputEl) { + clearMessages("admin"); + try { + const file = inputEl && inputEl.files && inputEl.files[0]; + if (!file) { + renderMessage(els.adminMessage, "Bitte zuerst ein Bild auswaehlen.", true); + return; + } + const dataUrl = await fileToDataUrl(file); + const result = await api("/v1/admin/branding/logo", { + method: "PUT", + body: { + theme, + dataUrl, + fileName: file.name + } + }); + state.system.branding = normalizeBranding(result.branding); + state.system.updatedAt = result.updatedAt || new Date().toISOString(); + renderBranding(); + if (inputEl) { + inputEl.value = ""; + } + renderMessage(els.adminMessage, `${theme === "light" ? "Light" : "Dark"}-Logo gespeichert.`, false, true); + } catch (error) { + renderMessage(els.adminMessage, error.message, true); + } +} + +async function removeBrandLogo(theme) { + clearMessages("admin"); + try { + const result = await api(`/v1/admin/branding/logo?theme=${encodeURIComponent(theme)}`, { + method: "DELETE" + }); + state.system.branding = normalizeBranding(result.branding); + state.system.updatedAt = result.updatedAt || new Date().toISOString(); + renderBranding(); + renderMessage(els.adminMessage, `${theme === "light" ? "Light" : "Dark"}-Logo entfernt.`, false, true); + } catch (error) { + renderMessage(els.adminMessage, error.message, true); + } +} + +function fileToDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Datei konnte nicht gelesen werden")); + reader.onload = () => resolve(String(reader.result || "")); + reader.readAsDataURL(file); + }); +} + +async function setMaintenanceMode(enabled) { + clearMessages("admin"); + try { + const result = await api("/v1/admin/maintenance", { + method: "PUT", + body: { + enabled, + message: els.maintenanceMessageInput.value.trim() + } + }); + state.system.maintenanceMode = Boolean(result.maintenanceMode); + state.system.maintenanceMessage = result.maintenanceMessage || ""; + renderMaintenanceBanner(); + renderMessage(els.adminMessage, enabled ? "Wartungsmodus aktiviert" : "Wartungsmodus deaktiviert", false, true); + } catch (error) { + renderMessage(els.adminMessage, error.message, true); + } +} + +async function refreshCurrentUser() { + if (!state.accessToken) { + state.user = null; + updateUserUi(); + return; + } + + try { + const result = await api("/v1/me", { authRequired: true }); + state.user = result.user; + renderUserSettingsAuthMethods(); + await ensureLanguageFromUserPreference(); + } catch { + state.user = null; + clearTokens(); + } + updateUserUi(); +} + +async function logout(maintenanceRedirect = false, skipServerLogout = false) { + stopOpenWebRxTxPolling(); + stopActivationWatch(); + stopRemainingUsageWatch(); + activationPending = false; + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (!skipServerLogout) { + try { + await api("/v1/auth/logout", { + method: "POST", + body: { refreshToken: state.refreshToken }, + authRequired: false + }); + } catch { + // ignore + } + } + clearTokens(); + state.user = null; + state.status = null; + state.swrReport = null; + state.controls = []; + state.plugins = []; + state.providers = {}; + state.capabilities = []; + state.users = []; + state.helpContent = null; + state.activityEntries = []; + state.approvals = []; + state.openWebRx = { + sessionUrl: "", + sessionTicket: "", + expiresAt: null, + bands: [], + selectedBand: "", + txActive: null, + powerCommandConfigured: true, + pttCommandConfigured: true, + rotor: { + azimuth: null, + moving: false, + min: 0, + max: 360 + }, + busy: false, + pollMs: OPENWEBRX_TX_POLL_MS_DEFAULT, + pttPressed: false + }; + syncOpenWebRxTicketCookie(""); + renderOpenWebRxSessionAccess(); + if (els.openwebrxPanel) { + els.openwebrxPanel.hidden = true; + } + if (els.controlsPanel) { + els.controlsPanel.hidden = true; + } + renderOpenWebRxBandOptions(); + renderOpenWebRxTxState(); + renderRotorState(); + setOpenWebRxBusy(false); + renderPluginControls(); + renderPluginAdmin(); + renderUsersAdmin(); + renderApprovals(); + renderActivityLog(); + renderHelpContent(); + renderStatus(); + renderSwrPanels(); + if (maintenanceRedirect) { + await refreshPublicSystemStatus(); + } + applyRoute(); + updateUserUi(); + renderMessage( + els.authMessage, + maintenanceRedirect + ? (state.system.maintenanceMessage || "Wartungsmodus aktiv. Bitte spaeter erneut versuchen.") + : "Abgemeldet.", + maintenanceRedirect, + !maintenanceRedirect + ); +} + +async function activateStation() { + clearMessages("status"); + try { + const result = await api("/v1/station/activation-jobs", { + method: "POST", + body: {} + }); + if (result.pending) { + activationPending = true; + const text = "Aktivierung gestartet..."; + renderMessage(els.swrSummaryMessage, text, false, true); + renderMessage(els.swrPageMessage, text, false, true); + startActivationWatch(); + } + await refreshStatus(); + await refreshSwrReport(); + } catch (error) { + renderMessage(els.statusMessage, error.message, true); + } +} + +async function releaseStation() { + clearMessages("status"); + try { + await api("/v1/station/release", { method: "POST", body: {} }); + state.openWebRx.sessionUrl = ""; + state.openWebRx.sessionTicket = ""; + state.openWebRx.expiresAt = null; + state.openWebRx.bands = []; + state.openWebRx.selectedBand = ""; + state.openWebRx.txActive = false; + state.openWebRx.rotor = { + azimuth: null, + rawAzimuth: null, + moving: false, + stale: false, + updatedAt: null, + min: 0, + max: 360 + }; + state.openWebRx.busy = false; + state.openWebRx.pttPressed = false; + syncOpenWebRxTicketCookie(""); + renderOpenWebRxBandOptions(); + renderOpenWebRxTxState(); + renderRotorState(); + setOpenWebRxBusy(false); + renderOpenWebRxSessionAccess(); + renderMessage(els.statusMessage, "Station freigegeben", false, true); + await refreshStatus(); + await refreshSwrReport(); + } catch (error) { + renderMessage(els.statusMessage, error.message, true); + } +} + +async function reserveNextSlot() { + clearMessages("status"); + try { + await api("/v1/station/reservations/next", { method: "POST", body: {} }); + renderMessage(els.reservationMessage, "Reservierung gespeichert", false, true); + await refreshStatus(); + } catch (error) { + renderMessage(els.reservationMessage, error.message, true); + } +} + +async function cancelOwnReservation() { + clearMessages("status"); + try { + await api("/v1/station/reservations/next", { method: "DELETE" }); + renderMessage(els.reservationMessage, "Reservierung entfernt", false, true); + await refreshStatus(); + } catch (error) { + renderMessage(els.reservationMessage, error.message, true); + } +} + +async function openOpenWebRxSession() { + clearMessages("status"); + setOpenWebRxBusy(true); + try { + const result = await requestOpenWebRxSessionWithRetry(); + const session = result && result.session ? result.session : null; + state.openWebRx.sessionUrl = session && session.iframeUrl ? session.iframeUrl : ""; + state.openWebRx.sessionTicket = session && session.ticket ? String(session.ticket) : ""; + state.openWebRx.expiresAt = session && session.expiresAt ? session.expiresAt : null; + syncOpenWebRxTicketCookie(state.openWebRx.sessionTicket, state.openWebRx.expiresAt); + renderOpenWebRxSessionAccess(); + await refreshOpenWebRxBands(); + await refreshOpenWebRxTxStatus(); + await refreshOpenWebRxRotorStatus(); + renderMessage(els.openwebrxMessage, "", false); + } catch (error) { + renderMessage(els.openwebrxMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +async function openOpenWebRxExternal() { + if (!state.user) { + return; + } + clearMessages("status"); + try { + const result = await requestOpenWebRxSessionWithRetry(); + const session = result && result.session ? result.session : null; + const sessionUrl = session && session.iframeUrl ? String(session.iframeUrl) : ""; + if (!sessionUrl) { + throw new Error("OpenWebRX Session konnte nicht erstellt werden"); + } + state.openWebRx.sessionUrl = sessionUrl; + state.openWebRx.sessionTicket = session && session.ticket ? String(session.ticket) : ""; + state.openWebRx.expiresAt = session && session.expiresAt ? session.expiresAt : null; + syncOpenWebRxTicketCookie(state.openWebRx.sessionTicket, state.openWebRx.expiresAt); + renderOpenWebRxSessionAccess(); + window.open(sessionUrl, "_blank", "noopener,noreferrer"); + await refreshStatus(); + renderMessage(els.openwebrxMessage, "OpenWebRX in neuem Tab geoeffnet. Session-Link und Ticket aktualisiert.", false, true); + } catch (error) { + renderMessage(els.statusMessage, error.message, true); + } +} + +async function requestOpenWebRxSessionWithRetry() { + const attempts = [0, 700, 1400, 2200]; + let lastError = null; + for (let i = 0; i < attempts.length; i += 1) { + const waitMs = attempts[i]; + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + try { + return await api("/v1/openwebrx/session", { + method: "POST", + body: {} + }); + } catch (error) { + lastError = error; + const status = Number(error && error.status); + const msg = String(error && error.message ? error.message : "").toLowerCase(); + const retryable = status === 502 || status === 503 || status === 504 + || msg.includes("bad gateway") + || msg.includes("upstream") + || msg.includes("connection refused") + || msg.includes("gateway"); + if (!retryable || i >= attempts.length - 1) { + throw error; + } + } + } + throw lastError || new Error("OpenWebRX Session konnte nicht erstellt werden"); +} + +async function refreshOpenWebRxBands() { + if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) { + state.openWebRx.bands = []; + state.openWebRx.selectedBand = ""; + renderOpenWebRxBandOptions(); + return; + } + try { + const result = await api("/v1/openwebrx/bands"); + state.openWebRx.bands = Array.isArray(result.bands) ? result.bands : []; + state.openWebRx.selectedBand = result.selectedBand ? String(result.selectedBand) : ""; + renderOpenWebRxBandOptions(); + } catch { + state.openWebRx.bands = []; + state.openWebRx.selectedBand = ""; + renderOpenWebRxBandOptions(); + } +} + +async function refreshOpenWebRxTxStatus() { + if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) { + state.openWebRx.txActive = null; + state.openWebRx.powerCommandConfigured = true; + state.openWebRx.pttCommandConfigured = true; + renderOpenWebRxTxState(); + return; + } + try { + const result = await api("/v1/openwebrx/tx/status"); + state.openWebRx.txActive = Boolean(result && result.txActive); + state.openWebRx.powerCommandConfigured = result && result.powerCommandConfigured !== false; + state.openWebRx.pttCommandConfigured = result && result.pttCommandConfigured !== false; + } catch { + state.openWebRx.txActive = null; + state.openWebRx.powerCommandConfigured = true; + state.openWebRx.pttCommandConfigured = true; + } + renderOpenWebRxTxState(); +} + +async function refreshOpenWebRxRotorStatus() { + if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) { + state.openWebRx.rotor = { + azimuth: null, + rawAzimuth: null, + moving: false, + stale: false, + updatedAt: null, + min: 0, + max: 360 + }; + renderRotorState(); + return; + } + try { + const result = await api("/v1/openwebrx/rotor/status"); + const rotor = result && result.rotor && typeof result.rotor === "object" ? result.rotor : {}; + const hasAzimuth = rotor.azimuth !== null && rotor.azimuth !== undefined && rotor.azimuth !== ""; + const hasRawAzimuth = rotor.rawAzimuth !== null && rotor.rawAzimuth !== undefined && rotor.rawAzimuth !== ""; + state.openWebRx.rotor = { + azimuth: hasAzimuth && Number.isFinite(Number(rotor.azimuth)) ? Number(rotor.azimuth) : null, + rawAzimuth: hasRawAzimuth && Number.isFinite(Number(rotor.rawAzimuth)) ? Number(rotor.rawAzimuth) : null, + moving: Boolean(rotor.moving), + stale: Boolean(rotor.stale), + updatedAt: rotor.updatedAt ? String(rotor.updatedAt) : null, + min: Number.isFinite(Number(rotor.min)) ? Number(rotor.min) : 0, + max: Number.isFinite(Number(rotor.max)) ? Number(rotor.max) : 360 + }; + } catch { + state.openWebRx.rotor = { + azimuth: null, + rawAzimuth: null, + moving: false, + stale: false, + updatedAt: null, + min: 0, + max: 360 + }; + } + renderRotorState(); +} + +function startOpenWebRxTxPolling() { + const pollMs = normalizeOpenWebRxPollMs(state.openWebRx.pollMs); + if (openWebRxTxPollTimer && openWebRxTxPollIntervalMs === pollMs) { + return; + } + stopOpenWebRxTxPolling(); + openWebRxTxPollIntervalMs = pollMs; + openWebRxTxPollTimer = setInterval(async () => { + if (openWebRxTxPollInFlight) { + return; + } + if (!state.user || !state.status || !state.status.isInUse || state.status.activeByUserId !== state.user.id) { + return; + } + openWebRxTxPollInFlight = true; + try { + await refreshOpenWebRxTxStatus(); + await refreshOpenWebRxRotorStatus(); + } finally { + openWebRxTxPollInFlight = false; + } + }, pollMs); +} + +function stopOpenWebRxTxPolling() { + if (openWebRxTxPollTimer) { + clearInterval(openWebRxTxPollTimer); + openWebRxTxPollTimer = null; + } + openWebRxTxPollInFlight = false; + openWebRxTxPollIntervalMs = null; +} + +function applyOpenWebRxPollingConfig(status) { + const nextMs = normalizeOpenWebRxPollMs(status && status.openWebRxTxPollMs); + const changed = nextMs !== state.openWebRx.pollMs; + state.openWebRx.pollMs = nextMs; + if (changed && openWebRxTxPollTimer) { + startOpenWebRxTxPolling(); + } +} + +function normalizeOpenWebRxPollMs(value) { + const n = Number(value); + if (!Number.isFinite(n)) { + return OPENWEBRX_TX_POLL_MS_DEFAULT; + } + return Math.max(1000, Math.min(60000, Math.trunc(n))); +} + +function renderOpenWebRxTxState() { + if (!els.openwebrxTxStatePill) { + return; + } + els.openwebrxTxStatePill.className = "pill"; + if (els.openwebrxEnableTxBtn) { + els.openwebrxEnableTxBtn.textContent = "RIG einschalten"; + els.openwebrxEnableTxBtn.disabled = state.openWebRx.busy || state.openWebRx.powerCommandConfigured === false; + } + if (state.openWebRx.powerCommandConfigured === false) { + els.openwebrxTxStatePill.textContent = "RIG: nicht konfiguriert"; + return; + } + if (state.openWebRx.txActive === true) { + els.openwebrxTxStatePill.textContent = "RIG: an"; + if (els.openwebrxEnableTxBtn) { + els.openwebrxEnableTxBtn.textContent = "RIG ausschalten"; + } + return; + } + if (state.openWebRx.txActive === false) { + els.openwebrxTxStatePill.textContent = "RIG: aus"; + if (els.openwebrxEnableTxBtn) { + els.openwebrxEnableTxBtn.textContent = "RIG einschalten"; + } + return; + } + els.openwebrxTxStatePill.textContent = "RIG: unbekannt"; +} + +async function toggleOpenWebRxTxPower() { + if (state.openWebRx.powerCommandConfigured === false) { + renderMessage(els.controlsMessage, "RIG ON/OFF ist nicht konfiguriert (RFROUTE_CMD_ON/RFROUTE_CMD_OFF)", true); + return; + } + if (state.openWebRx.txActive === true) { + await disableOpenWebRxTx(); + return; + } + await enableOpenWebRxTx(); +} + +function renderOpenWebRxBandOptions() { + if (!els.openwebrxBandSelect) { + return; + } + els.openwebrxBandSelect.innerHTML = ""; + const bands = state.openWebRx.bands || []; + if (!bands.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Keine Baender"; + els.openwebrxBandSelect.appendChild(option); + els.openwebrxBandSelect.disabled = true; + if (els.openwebrxBandSetBtn) { + els.openwebrxBandSetBtn.disabled = true; + } + setOpenWebRxBusy(state.openWebRx.busy); + return; + } + for (const band of bands) { + const option = document.createElement("option"); + option.value = String(band.band || ""); + option.textContent = String(band.label || band.band || "Band"); + if (state.openWebRx.selectedBand && option.value === state.openWebRx.selectedBand) { + option.selected = true; + } + els.openwebrxBandSelect.appendChild(option); + } + els.openwebrxBandSelect.disabled = false; + if (els.openwebrxBandSetBtn) { + els.openwebrxBandSetBtn.disabled = false; + } + setOpenWebRxBusy(state.openWebRx.busy); +} + +async function setOpenWebRxBand() { + const band = els.openwebrxBandSelect ? String(els.openwebrxBandSelect.value || "").trim() : ""; + if (!band) { + renderMessage(els.openwebrxMessage, "Bitte Band auswaehlen", true); + return; + } + setOpenWebRxBusy(true); + try { + const result = await api("/v1/openwebrx/bands/select", { + method: "POST", + body: { band } + }); + state.openWebRx.selectedBand = band; + renderMessage(els.openwebrxMessage, result && result.result && result.result.message ? result.result.message : "Band gesetzt", false, true); + await refreshOpenWebRxBands(); + } catch (error) { + renderMessage(els.openwebrxMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +async function enableOpenWebRxTx() { + setOpenWebRxBusy(true); + try { + await api("/v1/openwebrx/tx/enable", { + method: "POST", + body: {} + }); + state.openWebRx.txActive = true; + renderOpenWebRxTxState(); + renderMessage(els.controlsMessage, "RIG eingeschaltet", false, true); + } catch (error) { + renderMessage(els.controlsMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +async function disableOpenWebRxTx() { + setOpenWebRxBusy(true); + try { + await api("/v1/openwebrx/tx/disable", { + method: "POST", + body: {} + }); + state.openWebRx.txActive = false; + renderOpenWebRxTxState(); + renderMessage(els.controlsMessage, "RIG ausgeschaltet", false, true); + } catch (error) { + renderMessage(els.controlsMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +async function setOpenWebRxRotor() { + if (!els.rotorTarget) { + return; + } + const raw = String(els.rotorTarget.value || "").trim(); + if (!raw) { + renderMessage(els.controlsMessage, "Bitte Rotor-Ziel eingeben", true); + return; + } + const target = Number(raw.replace(",", ".")); + if (!Number.isFinite(target) || target < 0 || target > 360) { + renderMessage(els.controlsMessage, "Rotor-Ziel muss zwischen 0 und 360 liegen", true); + return; + } + setOpenWebRxBusy(true); + try { + const result = await api("/v1/openwebrx/rotor/set", { + method: "POST", + body: { target, trigger: "user" } + }); + renderMessage( + els.controlsMessage, + result && result.result && result.result.message ? result.result.message : `Rotor auf ${Math.round(target)}° gesetzt`, + false, + true + ); + await refreshOpenWebRxRotorStatus(); + } catch (error) { + renderMessage(els.controlsMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +function renderRotorState() { + if (!els.rotorCurrent) { + return; + } + const rotor = state.openWebRx && state.openWebRx.rotor ? state.openWebRx.rotor : null; + const hasAzimuth = rotor && rotor.azimuth !== null && rotor.azimuth !== undefined && rotor.azimuth !== ""; + const azimuth = hasAzimuth && Number.isFinite(Number(rotor.azimuth)) ? Math.round(Number(rotor.azimuth)) : null; + if (azimuth === null) { + els.rotorCurrent.textContent = "Rotor: -"; + if (els.rotorCompass) { + els.rotorCompass.style.opacity = "0.45"; + } + if (els.rotorCompassArrow) { + els.rotorCompassArrow.style.transform = "translate(-50%, -100%) rotate(0deg)"; + } + } else { + const stale = Boolean(rotor && rotor.stale); + const moving = Boolean(rotor && rotor.moving); + let suffix = ""; + if (moving) { + suffix = " (dreht)"; + } else if (stale) { + suffix = " (letzter Wert)"; + } + els.rotorCurrent.textContent = `Rotor: ${azimuth}°${suffix}`; + if (els.rotorCompass) { + els.rotorCompass.style.opacity = "1"; + } + if (els.rotorCompassArrow) { + const normalized = ((Number(azimuth) % 360) + 360) % 360; + els.rotorCompassArrow.style.transform = `translate(-50%, -100%) rotate(${normalized}deg)`; + } + } + + if (els.rotorTarget) { + const min = rotor && Number.isFinite(Number(rotor.min)) ? Number(rotor.min) : 0; + const max = rotor && Number.isFinite(Number(rotor.max)) ? Number(rotor.max) : 360; + els.rotorTarget.min = String(min); + els.rotorTarget.max = String(max); + if (!String(els.rotorTarget.value || "").trim() && azimuth !== null) { + els.rotorTarget.value = String(azimuth); + } + } + + if (els.rotorPresets) { + for (const button of els.rotorPresets.querySelectorAll("button[data-azimuth]")) { + const preset = Number(button.getAttribute("data-azimuth")); + const active = azimuth !== null && Number.isFinite(preset) && Math.abs(preset - azimuth) <= 5; + button.classList.toggle("primary-btn", active); + } + } +} + +async function startOpenWebRxPtt() { + if (state.openWebRx.pttPressed) { + return; + } + state.openWebRx.pttPressed = true; + setOpenWebRxBusy(true); + try { + await api("/v1/openwebrx/ptt/down", { + method: "POST", + body: {} + }); + renderMessage(els.openwebrxMessage, "PTT gedrueckt: Antenne auf TX", false, true); + } catch (error) { + state.openWebRx.pttPressed = false; + renderMessage(els.openwebrxMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +async function stopOpenWebRxPtt() { + if (!state.openWebRx.pttPressed) { + return; + } + state.openWebRx.pttPressed = false; + setOpenWebRxBusy(true); + try { + await api("/v1/openwebrx/ptt/up", { + method: "POST", + body: {} + }); + renderMessage(els.openwebrxMessage, "PTT losgelassen: Antenne auf RX", false, true); + } catch (error) { + renderMessage(els.openwebrxMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +async function closeOpenWebRxSession() { + setOpenWebRxBusy(true); + try { + await api("/v1/openwebrx/session/close", { + method: "POST", + body: {} + }); + state.openWebRx.sessionUrl = ""; + state.openWebRx.sessionTicket = ""; + state.openWebRx.expiresAt = null; + state.openWebRx.bands = []; + state.openWebRx.selectedBand = ""; + state.openWebRx.txActive = false; + state.openWebRx.rotor = { + azimuth: null, + rawAzimuth: null, + moving: false, + stale: false, + updatedAt: null, + min: 0, + max: 360 + }; + state.openWebRx.pttPressed = false; + syncOpenWebRxTicketCookie(""); + renderOpenWebRxBandOptions(); + renderOpenWebRxTxState(); + renderRotorState(); + renderOpenWebRxSessionAccess(); + renderMessage(els.openwebrxMessage, "RIG ausgeschaltet, OpenWebRX Session geschlossen", false, true); + } catch (error) { + renderMessage(els.openwebrxMessage, error.message, true); + } finally { + setOpenWebRxBusy(false); + } +} + +function setOpenWebRxBusy(busy) { + state.openWebRx.busy = Boolean(busy); + const disabled = state.openWebRx.busy; + if (els.openwebrxOpenBtn) els.openwebrxOpenBtn.disabled = disabled; + if (els.openwebrxBandSetBtn) els.openwebrxBandSetBtn.disabled = disabled || (state.openWebRx.bands || []).length === 0; + if (els.openwebrxBandSelect) els.openwebrxBandSelect.disabled = disabled || (state.openWebRx.bands || []).length === 0; + if (els.openwebrxEnableTxBtn) { + els.openwebrxEnableTxBtn.disabled = disabled || state.openWebRx.powerCommandConfigured === false; + } + if (els.rotorSetBtn) els.rotorSetBtn.disabled = disabled; + if (els.rotorTarget) els.rotorTarget.disabled = disabled; + if (els.rotorPresets) { + for (const button of els.rotorPresets.querySelectorAll("button[data-azimuth]")) { + button.disabled = disabled; + } + } + if (els.openwebrxCloseBtn) els.openwebrxCloseBtn.disabled = disabled; + if (els.openwebrxCopyLinkBtn) { + els.openwebrxCopyLinkBtn.disabled = disabled || !String(state.openWebRx.sessionUrl || "").trim(); + } +} + +async function copyOpenWebRxLink() { + const sessionUrl = String(state.openWebRx.sessionUrl || "").trim(); + if (!sessionUrl) { + renderMessage(els.openwebrxMessage, "Kein OpenWebRX Link vorhanden", true); + return; + } + try { + await copyTextToClipboard(sessionUrl); + renderMessage(els.openwebrxMessage, "OpenWebRX Link in Zwischenablage kopiert", false, true); + } catch { + renderMessage(els.openwebrxMessage, "Link konnte nicht kopiert werden", true); + } +} + +async function copyTextToClipboard(value) { + if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { + await navigator.clipboard.writeText(value); + return; + } + const helper = document.createElement("textarea"); + helper.value = value; + helper.setAttribute("readonly", "readonly"); + helper.style.position = "absolute"; + helper.style.left = "-9999px"; + document.body.appendChild(helper); + helper.select(); + const copied = document.execCommand("copy"); + document.body.removeChild(helper); + if (!copied) { + throw new Error("copy-failed"); + } +} + +function renderOpenWebRxSessionAccess() { + if (!els.openwebrxSessionAccess || !els.openwebrxSessionLink || !els.openwebrxSessionTicket) { + return; + } + const sessionUrl = String(state.openWebRx.sessionUrl || "").trim(); + const ticket = String(state.openWebRx.sessionTicket || "").trim(); + const visible = Boolean(sessionUrl); + els.openwebrxSessionAccess.hidden = !visible; + els.openwebrxSessionLink.hidden = !visible; + if (!visible) { + els.openwebrxSessionLink.removeAttribute("href"); + els.openwebrxSessionTicket.textContent = "-"; + if (els.openwebrxCopyLinkBtn) { + els.openwebrxCopyLinkBtn.disabled = true; + } + return; + } + els.openwebrxSessionLink.href = sessionUrl; + els.openwebrxSessionTicket.textContent = ticket || "(nicht geliefert)"; + if (els.openwebrxCopyLinkBtn) { + els.openwebrxCopyLinkBtn.disabled = state.openWebRx.busy; + } +} + +function syncOpenWebRxTicketCookie(ticket, expiresAtIso) { + const value = String(ticket || "").trim(); + const baseRoot = "path=/; SameSite=Lax"; + const baseOpenWebRx = "path=/openwebrx/; SameSite=Lax"; + const secure = window && window.location && window.location.protocol === "https:" ? "; Secure" : ""; + if (!value) { + document.cookie = `rms_owrx_ticket=; Max-Age=0; ${baseRoot}${secure}`; + document.cookie = `rms_owrx_ticket=; Max-Age=0; ${baseOpenWebRx}${secure}`; + return; + } + let maxAge = 600; + if (expiresAtIso) { + const expiresMs = Date.parse(String(expiresAtIso)); + if (Number.isFinite(expiresMs)) { + const remainingSec = Math.floor((expiresMs - Date.now()) / 1000); + if (remainingSec > 0) { + maxAge = Math.max(10, Math.min(remainingSec, 86400)); + } + } + } + document.cookie = `rms_owrx_ticket=${encodeURIComponent(value)}; Max-Age=${maxAge}; ${baseRoot}${secure}`; + document.cookie = `rms_owrx_ticket=; Max-Age=0; ${baseOpenWebRx}${secure}`; +} + +async function refreshStatus() { + try { + const status = await api("/v1/station/status"); + state.status = status; + applyOpenWebRxPollingConfig(status); + renderStatus(); + await refreshOpenWebRxBands(); + await refreshOpenWebRxTxStatus(); + await refreshOpenWebRxRotorStatus(); + } catch (error) { + renderMessage(els.statusMessage, error.message, true); + } +} + +async function refreshSwrReport() { + if (!canOperateStation()) { + state.swrReport = null; + renderSwrPanels(); + return; + } + try { + const report = await api("/v1/swr/report"); + state.swrReport = report; + renderSwrPanels(); + } catch (error) { + renderMessage(els.swrSummaryMessage, error.message, true); + renderMessage(els.swrPageMessage, error.message, true); + } +} + +async function runManualSwrCheck() { + if (state.status && state.status.swrRun && state.status.swrRun.running) { + const text = "SWR-Check laeuft bereits."; + renderMessage(els.swrSummaryMessage, text, true); + renderMessage(els.swrPageMessage, text, true); + return; + } + if (state.status && state.status.isInUse) { + const text = "SWR-Check gesperrt solange die Station aktiv ist."; + renderMessage(els.swrSummaryMessage, text, true); + renderMessage(els.swrPageMessage, text, true); + return; + } + + const startedAt = Date.now(); + const expectedSec = SWR_EXPECTED_DURATION_SEC_DEFAULT; + const previousReport = state.swrReport; + + const runningBands = Array.isArray(previousReport && previousReport.bands) + ? previousReport.bands.map((entry) => ({ ...entry, status: "RUNNING" })) + : []; + state.swrReport = { + source: previousReport && previousReport.source ? previousReport.source : "native-controller", + generatedAt: new Date(startedAt).toISOString(), + overallStatus: "RUNNING", + overviewUrl: previousReport && Object.prototype.hasOwnProperty.call(previousReport, "overviewUrl") + ? previousReport.overviewUrl + : null, + bands: runningBands + }; + renderSwrPanels(); + + setSwrRunButtonsBusy(true); + + const updateProgressMessage = () => { + const elapsedSec = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); + const remainingSec = Math.max(0, expectedSec - elapsedSec); + const text = `SWR-Messung laeuft... ${elapsedSec}s vergangen, ca. ${remainingSec}s verbleibend.`; + renderMessage(els.swrSummaryMessage, text, false, true); + renderMessage(els.swrPageMessage, text, false, true); + }; + + updateProgressMessage(); + + try { + const payload = await api("/v1/swr/run-check", { + method: "POST", + body: {} + }); + const reportFromRun = payload && payload.result && payload.result.report ? payload.result.report : null; + const reportFromView = payload && payload.report ? payload.report : null; + if (reportFromRun) { + state.swrReport = reportFromRun; + renderSwrPanels(); + } else if (reportFromView) { + state.swrReport = reportFromView; + renderSwrPanels(); + } else { + await refreshSwrReport(); + } + const finishedSec = Math.max(1, Math.floor((Date.now() - startedAt) / 1000)); + const overall = state.swrReport && state.swrReport.overallStatus ? state.swrReport.overallStatus : "UNKNOWN"; + const doneText = `SWR-Check ERFOLGREICH abgeschlossen nach ${finishedSec}s. Ergebnis: ${overall}.`; + renderMessage(els.swrSummaryMessage, doneText, false, true); + renderMessage(els.swrPageMessage, doneText, false, true); + } catch (error) { + state.swrReport = previousReport; + renderSwrPanels(); + renderMessage(els.swrSummaryMessage, error.message, true); + renderMessage(els.swrPageMessage, error.message, true); + } finally { + setSwrRunButtonsBusy(false); + } +} + +function setSwrRunButtonsBusy(isBusy) { + const swrRunning = Boolean(state.status && state.status.swrRun && state.status.swrRun.running); + const disabled = Boolean(isBusy) || swrRunning; + if (els.runSwrCheckBtn) { + els.runSwrCheckBtn.disabled = disabled; + } + if (els.runSwrCheckPageBtn) { + els.runSwrCheckPageBtn.disabled = disabled; + } +} + +function renderSwrPanels() { + const report = state.swrReport; + const generatedAtText = report && report.generatedAt + ? `Stand: ${new Date(report.generatedAt).toLocaleString(localeForDate())}` + : "Stand: -"; + const overallText = `Gesamt: ${report && report.overallStatus ? report.overallStatus : "UNKNOWN"}`; + + if (els.swrSummaryGeneratedAt) { + els.swrSummaryGeneratedAt.textContent = generatedAtText; + } + if (els.swrSummaryOverall) { + els.swrSummaryOverall.textContent = overallText; + } + if (els.swrPageGeneratedAt) { + els.swrPageGeneratedAt.textContent = generatedAtText; + } + if (els.swrPageOverall) { + els.swrPageOverall.textContent = overallText; + } + + renderSwrBandsInto(els.swrSummaryBands, report, { withImages: false, compact: true }); + renderSwrBandsInto(els.swrPageBands, report, { withImages: true, compact: false }); +} + +function renderSwrBandsInto(container, report, options = {}) { + if (!container) return; + container.innerHTML = ""; + const bands = report && Array.isArray(report.bands) ? report.bands : []; + if (!bands.length) { + container.textContent = "Keine SWR Daten vorhanden."; + return; + } + if (options.compact) { + container.classList.add("swr-summary-list"); + for (const band of bands) { + const row = document.createElement("button"); + row.type = "button"; + row.className = "swr-summary-row swr-summary-link"; + row.title = `${band.band} in SWR-Detailseite anzeigen`; + row.addEventListener("click", () => { + navigateRmsPage("swr"); + setTimeout(() => { + scrollToSwrBand(band.band); + }, 0); + }); + + const bandText = document.createElement("strong"); + bandText.textContent = band.band; + row.appendChild(bandText); + + const status = document.createElement("span"); + status.className = "pill"; + status.textContent = band.status || "UNKNOWN"; + row.appendChild(status); + + container.appendChild(row); + } + return; + } + + container.classList.remove("swr-summary-list"); + for (const band of bands) { + const imageVersion = band.updatedAt || report.generatedAt || ""; + const imageUrl = withCacheVersion(band.imageUrl, imageVersion); + const block = document.createElement("div"); + block.className = "plugin-block"; + block.id = swrBandAnchorId(band.band); + block.dataset.swrBand = String(band.band || "").toLowerCase(); + const head = document.createElement("div"); + head.className = "section-head"; + const title = document.createElement("strong"); + title.textContent = band.band; + head.appendChild(title); + const status = document.createElement("span"); + status.className = "pill"; + status.textContent = band.status || "UNKNOWN"; + head.appendChild(status); + block.appendChild(head); + + if (band.updatedAt) { + const updated = document.createElement("p"); + updated.className = "muted"; + updated.textContent = `Bildstand: ${new Date(band.updatedAt).toLocaleString(localeForDate())}`; + block.appendChild(updated); + } + + if (options.withImages && imageUrl) { + const img = document.createElement("img"); + img.src = imageUrl; + img.alt = `SWR ${band.band}`; + img.className = "swr-band-image"; + block.appendChild(img); + } + + if (!options.withImages && imageUrl) { + const link = document.createElement("a"); + link.href = imageUrl; + link.target = "_blank"; + link.rel = "noopener"; + link.className = "ghost-btn"; + link.textContent = `${band.band} Grafik`; + block.appendChild(link); + } + + container.appendChild(block); + } +} + +function swrBandAnchorId(band) { + const normalized = String(band || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return `swr-band-${normalized || "unknown"}`; +} + +function scrollToSwrBand(band) { + const id = swrBandAnchorId(band); + const target = document.getElementById(id); + if (!target) { + return; + } + target.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +async function refreshControls() { + try { + const response = await api("/v1/ui/controls"); + state.controls = response.controls || []; + renderPluginControls(); + } catch (error) { + renderMessage(els.pluginMessage, error.message, true); + } +} + +async function refreshPlugins() { + if (!isAdmin()) { + state.plugins = []; + state.providers = {}; + state.capabilities = []; + renderPluginAdmin(); + renderProviderAdmin(); + renderCapabilityMatrix(); + await refreshPublicAuthMethods(); + return; + } + try { + const result = await api("/v1/plugins"); + state.plugins = result.plugins || []; + state.providers = result.providers || {}; + state.capabilities = result.capabilities || []; + renderPluginAdmin(); + renderProviderAdmin(); + renderCapabilityMatrix(); + await refreshPublicAuthMethods(); + } catch (error) { + renderMessage(els.adminMessage, error.message, true); + } +} + +async function refreshUsers() { + if (!canSeeUsersList()) { + state.users = []; + renderUsersAdmin(); + return; + } + try { + const result = await api("/v1/admin/users"); + state.users = result.users || []; + renderUsersAdmin(); + } catch (error) { + renderMessage(els.usersMessage, error.message, true); + } +} + +async function refreshApprovals() { + if (!canSeeApprovals()) { + state.approvals = []; + renderApprovals(); + return; + } + try { + const result = await api("/v1/approvals"); + state.approvals = result.approvals || []; + renderApprovals(); + } catch (error) { + renderMessage(els.approvalsMessage, error.message, true); + } +} + +async function refreshActivityLog() { + if (!canSeeActivityLog()) { + state.activityEntries = []; + renderActivityLog(); + return; + } + try { + const result = await api("/v1/activity-log?limit=300"); + state.activityEntries = Array.isArray(result.entries) ? result.entries : []; + renderActivityLog(); + } catch (error) { + renderMessage(els.activityMessage, error.message, true); + } +} + +async function refreshHelpContent() { + if (!state.user) { + state.helpContent = null; + renderHelpContent(); + return; + } + try { + const result = await api("/v1/help/content"); + state.helpContent = result && result.content ? result.content : null; + renderHelpContent(); + } catch (error) { + state.helpContent = null; + renderHelpContent(); + if (els.helpMessage) { + renderMessage(els.helpMessage, error.message, true); + } + } +} + +function renderHelpContent() { + if (!els.helpSections || !els.helpQuickStartSteps) { + return; + } + els.helpSections.innerHTML = ""; + els.helpQuickStartSteps.innerHTML = ""; + + const content = state.helpContent; + if (!content) { + if (els.helpTitle) { + els.helpTitle.textContent = "Hilfe"; + } + if (els.helpQuickStartTitle) { + els.helpQuickStartTitle.textContent = "Schnellstart"; + } + const li = document.createElement("li"); + li.className = "muted"; + li.textContent = "Keine Hilfedaten geladen."; + els.helpQuickStartSteps.appendChild(li); + return; + } + + if (els.helpTitle) { + els.helpTitle.textContent = content.title || "Hilfe"; + } + if (els.helpQuickStartTitle) { + els.helpQuickStartTitle.textContent = content.quickStart && content.quickStart.title + ? content.quickStart.title + : "Schnellstart"; + } + + const quickSteps = content.quickStart && Array.isArray(content.quickStart.steps) + ? content.quickStart.steps + : []; + for (const step of quickSteps) { + const li = document.createElement("li"); + li.textContent = String(step); + els.helpQuickStartSteps.appendChild(li); + } + if (!quickSteps.length) { + const li = document.createElement("li"); + li.className = "muted"; + li.textContent = "Keine Schnellstart-Schritte hinterlegt."; + els.helpQuickStartSteps.appendChild(li); + } + + const sections = Array.isArray(content.sections) ? content.sections : []; + for (const section of sections) { + const block = document.createElement("section"); + block.className = "plugin-block"; + + const title = document.createElement("h3"); + title.textContent = String(section && section.title ? section.title : "Hinweis"); + block.appendChild(title); + + const lines = Array.isArray(section && section.body) ? section.body : []; + for (const line of lines) { + const p = document.createElement("p"); + p.textContent = String(line); + block.appendChild(p); + } + + els.helpSections.appendChild(block); + } +} + +function renderActivityLog() { + if (!els.activityLogList) return; + els.activityLogList.innerHTML = ""; + if (!canSeeActivityLog()) return; + const sourceEntries = state.activityEntries.filter((entry) => { + if (state.activityFilter.type !== "all" && entry.action !== state.activityFilter.type) { + return false; + } + if (state.activityFilter.query) { + const haystack = `${String(entry.email || "")} ${String(entry.message || "")} ${String(entry.action || "")}`.toLowerCase(); + if (!haystack.includes(state.activityFilter.query)) { + return false; + } + } + return true; + }); + + if (!sourceEntries.length) { + els.activityLogList.textContent = "Keine Aktivitaetsdaten vorhanden."; + return; + } + for (const entry of sourceEntries) { + const block = document.createElement("div"); + block.className = "plugin-block"; + const head = document.createElement("div"); + head.className = "section-head"; + const title = document.createElement("strong"); + title.textContent = new Date(entry.at).toLocaleString(localeForDate()); + head.appendChild(title); + const pill = document.createElement("span"); + pill.className = "pill"; + pill.textContent = entry.action; + head.appendChild(pill); + block.appendChild(head); + + const text = document.createElement("p"); + text.textContent = entry.message || entry.action; + block.appendChild(text); + + if (entry.details) { + const details = document.createElement("pre"); + details.className = "audit-log"; + details.textContent = JSON.stringify(entry.details, null, 2); + block.appendChild(details); + } + els.activityLogList.appendChild(block); + } +} + +function renderUsersAdmin() { + if (!els.usersAdmin) return; + els.usersAdmin.innerHTML = ""; + if (!canSeeUsersList()) return; + const readOnly = !isAdmin(); + if (readOnly) { + const hint = document.createElement("p"); + hint.className = "muted"; + hint.textContent = "Read-only Ansicht: Rollen und Methoden koennen nur von Admin bearbeitet werden."; + els.usersAdmin.appendChild(hint); + } + if (!state.users.length) { + const empty = document.createElement("p"); + empty.className = "muted"; + empty.textContent = "Keine Benutzer gefunden."; + els.usersAdmin.appendChild(empty); + return; + } + const filteredUsers = state.users.filter((user) => { + const query = state.usersFilter.query; + const role = state.usersFilter.role; + const status = state.usersFilter.status; + if (query && !String(user.email || "").toLowerCase().includes(query)) { + return false; + } + if (role !== "all" && user.role !== role) { + return false; + } + if (status !== "all" && user.status !== status) { + return false; + } + return true; + }); + + const sortedUsers = [...filteredUsers].sort((a, b) => { + const roleWeight = { admin: 0, approver: 1, operator: 2 }; + const aWeight = roleWeight[a.role] ?? 9; + const bWeight = roleWeight[b.role] ?? 9; + if (aWeight !== bWeight) { + return aWeight - bWeight; + } + return String(a.email || "").localeCompare(String(b.email || "")); + }); + + if (!sortedUsers.length) { + const emptyFiltered = document.createElement("p"); + emptyFiltered.className = "muted"; + emptyFiltered.textContent = "Keine Benutzer fuer den aktuellen Filter gefunden."; + els.usersAdmin.appendChild(emptyFiltered); + return; + } + + for (const user of sortedUsers) { + const block = document.createElement("div"); + block.className = "plugin-block"; + const head = document.createElement("div"); + head.className = "section-head"; + const title = document.createElement("h3"); + title.textContent = user.email; + head.appendChild(title); + const status = document.createElement("span"); + status.className = "pill"; + status.textContent = `${user.role} | ${user.status}`; + head.appendChild(status); + block.appendChild(head); + + if (!readOnly) { + const controls = document.createElement("div"); + controls.className = "actions"; + for (const role of ["operator", "approver", "admin"]) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = role === user.role ? "ghost-btn" : ""; + btn.textContent = role; + btn.disabled = role === user.role; + btn.addEventListener("click", async () => { + await updateUserRole(user.id, role); + }); + controls.appendChild(btn); + } + block.appendChild(controls); + } + + if (state.authMethods.length) { + const methodsWrap = document.createElement("div"); + methodsWrap.className = "stack"; + const hint = document.createElement("span"); + hint.className = "muted"; + hint.textContent = "Bestaetigungsarten"; + methodsWrap.appendChild(hint); + + const enabledMethods = new Set(Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : []); + const methodChecks = []; + for (const method of state.authMethods) { + const label = document.createElement("label"); + label.className = "field"; + const caption = document.createElement("span"); + caption.textContent = `${method.label} (${method.type})`; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = enabledMethods.has(method.id); + label.appendChild(caption); + label.appendChild(checkbox); + methodsWrap.appendChild(label); + methodChecks.push({ methodId: method.id, checkbox }); + } + + const primarySelect = document.createElement("select"); + for (const method of state.authMethods) { + const option = document.createElement("option"); + option.value = method.id; + option.textContent = `${method.label}`; + option.selected = method.id === user.primaryAuthMethod; + primarySelect.appendChild(option); + } + methodsWrap.appendChild(primarySelect); + + if (readOnly) { + for (const entry of methodChecks) { + entry.checkbox.disabled = true; + } + primarySelect.disabled = true; + } else { + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.textContent = "Auth-Methoden speichern"; + saveBtn.addEventListener("click", async () => { + const enabled = methodChecks.filter((entry) => entry.checkbox.checked).map((entry) => entry.methodId); + await updateUserAuthMethods(user.id, enabled, primarySelect.value); + }); + methodsWrap.appendChild(saveBtn); + } + block.appendChild(methodsWrap); + } + + els.usersAdmin.appendChild(block); + } +} + +function renderApprovals() { + if (!els.approvalsList) return; + els.approvalsList.innerHTML = ""; + if (!canSeeApprovals()) return; + const showOpenOnly = state.approvalsFilter.mode !== "all"; + els.approvalsFilterOpenBtn.classList.toggle("active", showOpenOnly); + els.approvalsFilterAllBtn.classList.toggle("active", !showOpenOnly); + + const sourceApprovals = state.approvals.filter((entry) => { + if (showOpenOnly && entry.status !== "pending") { + return false; + } + if (state.approvalsFilter.status !== "all" && entry.status !== state.approvalsFilter.status) { + return false; + } + if (state.approvalsFilter.query) { + const email = String(entry.email || "").toLowerCase(); + if (!email.includes(state.approvalsFilter.query)) { + return false; + } + } + return true; + }); + + if (!sourceApprovals.length) { + els.approvalsList.textContent = "Keine Freigabe-Anfragen vorhanden."; + return; + } + const statusWeight = { pending: 0, approved: 1, rejected: 2 }; + const sortedApprovals = [...sourceApprovals].sort((a, b) => { + const aWeight = statusWeight[a.status] ?? 9; + const bWeight = statusWeight[b.status] ?? 9; + if (aWeight !== bWeight) { + return aWeight - bWeight; + } + return new Date(b.updatedAt || b.createdAt || 0).getTime() - new Date(a.updatedAt || a.createdAt || 0).getTime(); + }); + + for (const entry of sortedApprovals) { + const block = document.createElement("div"); + block.className = "plugin-block"; + const head = document.createElement("div"); + head.className = "section-head"; + const title = document.createElement("h3"); + title.textContent = entry.email; + head.appendChild(title); + const status = document.createElement("span"); + status.className = "pill"; + status.classList.add(`approval-${entry.status}`); + status.textContent = entry.status; + head.appendChild(status); + block.appendChild(head); + + const meta = document.createElement("p"); + meta.className = "muted"; + meta.textContent = `Erstellt: ${new Date(entry.createdAt).toLocaleString(localeForDate())} | Account: ${entry.userStatus || "-"} (${entry.userRole || "-"}) | Zuletzt: ${entry.updatedBy || "-"}`; + block.appendChild(meta); + + const actions = document.createElement("div"); + actions.className = "actions"; + const approveBtn = document.createElement("button"); + approveBtn.type = "button"; + approveBtn.textContent = entry.status === "approved" ? "Erneut freigeben" : "Freigeben"; + approveBtn.addEventListener("click", async () => { + await decideApproval(entry.id, true); + }); + actions.appendChild(approveBtn); + + const rejectBtn = document.createElement("button"); + rejectBtn.type = "button"; + rejectBtn.className = "danger"; + rejectBtn.textContent = entry.status === "rejected" ? "Erneut ablehnen" : "Ablehnen"; + rejectBtn.addEventListener("click", async () => { + await decideApproval(entry.id, false); + }); + actions.appendChild(rejectBtn); + block.appendChild(actions); + + els.approvalsList.appendChild(block); + } +} + +async function updateUserRole(userId, role) { + clearMessages("users"); + try { + await api(`/v1/admin/users/${encodeURIComponent(userId)}/role`, { + method: "PUT", + body: { role } + }); + await refreshUsers(); + await refreshApprovals(); + renderMessage(els.usersMessage, `Rolle auf ${role} gesetzt`, false, true); + } catch (error) { + renderMessage(els.usersMessage, error.message, true); + } +} + +async function updateUserAuthMethods(userId, enabledMethods, primaryMethod) { + clearMessages("users"); + try { + await api(`/v1/admin/users/${encodeURIComponent(userId)}/auth-methods`, { + method: "PUT", + body: { enabledMethods, primaryMethod } + }); + await refreshUsers(); + renderMessage(els.usersMessage, "Bestaetigungsarten gespeichert", false, true); + } catch (error) { + renderMessage(els.usersMessage, error.message, true); + } +} + +async function decideApproval(id, approve) { + clearMessages("approvals"); + try { + await api(`/v1/approvals/${encodeURIComponent(id)}/${approve ? "approve" : "reject"}`, { + method: "POST", + body: {} + }); + await refreshApprovals(); + await refreshUsers(); + renderMessage(els.approvalsMessage, approve ? "Freigabe bestaetigt" : "Freigabe abgelehnt", false, true); + } catch (error) { + renderMessage(els.approvalsMessage, error.message, true); + } +} + +function renderStatus() { + const status = state.status; + if (!status) { + stopRemainingUsageWatch(); + if (els.remainingUsage) { + els.remainingUsage.textContent = "-"; + } + return; + } + + const activationRunning = Boolean(status.activation && status.activation.running); + + els.stationName.textContent = status.stationName || "-"; + els.usageStatus.textContent = status.isInUse + ? translateLiteral("In Benutzung") + : (activationRunning ? translateLiteral("Aktivierung laeuft") : translateLiteral("Frei")); + els.activeBy.textContent = status.activeByEmail || "-"; + els.startedAt.textContent = status.startedAt ? new Date(status.startedAt).toLocaleString(localeForDate()) : "-"; + els.endsAt.textContent = status.endsAt ? new Date(status.endsAt).toLocaleString(localeForDate()) : "-"; + renderRemainingUsage(); + if (status.isInUse || activationRunning) { + startRemainingUsageWatch(); + } else { + stopRemainingUsageWatch(); + } + els.stationOnlinePill.textContent = status.stationOnline ? "Online" : "Offline"; + els.stationOnlinePill.classList.toggle("ok", Boolean(status.stationOnline)); + els.stationOnlinePill.classList.toggle("offline", !status.stationOnline); + + if (status.maintenanceMode) { + renderMessage(els.statusMessage, status.maintenanceMessage || "Wartungsmodus aktiv", true); + } + + const swrRun = status.swrRun || status.activation; + renderActivationProgress(swrRun); + renderReservationQueue(status); + renderStationLinks(status); + renderOpenWebRx(status); + + const loggedIn = Boolean(state.user); + const swrRunning = Boolean(swrRun && swrRun.running); + const isOwner = loggedIn && state.user.email === status.activeByEmail; + const activeReservation = status.reservationQueue && status.reservationQueue.activeEntry + ? status.reservationQueue.activeEntry + : null; + const slotLockActive = Boolean(status.reservationQueue && status.reservationQueue.slotLockActive && activeReservation); + const isSlotOwner = loggedIn + && activeReservation + && state.user + && state.user.id + && String(state.user.id) === String(activeReservation.userId || ""); + const slotDenied = slotLockActive && !isSlotOwner && !isAdmin(); + const canOperate = canOperateStation(); + els.activateBtn.disabled = !loggedIn || !canOperate || activationRunning || swrRunning || !status.stationOnline || Boolean(status.isInUse) || Boolean(status.maintenanceMode) || slotDenied; + els.deactivateBtn.disabled = !loggedIn || !status.isInUse || (!isOwner && !isAdmin()) || slotDenied; + setSwrRunButtonsBusy(false); +} + +function startRemainingUsageWatch() { + if (remainingUsageTimer) { + return; + } + remainingUsageTimer = setInterval(() => { + renderRemainingUsage(); + }, 1000); +} + +function stopRemainingUsageWatch() { + if (!remainingUsageTimer) { + return; + } + clearInterval(remainingUsageTimer); + remainingUsageTimer = null; +} + +function renderRemainingUsage() { + if (!els.remainingUsage) { + return; + } + const status = state.status; + const activationRunning = Boolean(status && status.activation && status.activation.running); + if (!status || (!status.isInUse && !activationRunning)) { + els.remainingUsage.textContent = "-"; + return; + } + + const endsAtMs = Date.parse(String(status.endsAt || "")); + if (Number.isFinite(endsAtMs)) { + const remainingSec = Math.max(0, Math.ceil((endsAtMs - Date.now()) / 1000)); + els.remainingUsage.textContent = formatRemainingUsage(remainingSec); + return; + } + + els.remainingUsage.textContent = formatRemainingUsage(Math.max(0, Number(status.remainingUsageSec || 0))); +} + +function renderReservationQueue(status) { + if (!els.reservationPanel || !els.reserveNextBtn || !els.reservationList) { + return; + } + const queue = status && status.reservationQueue && typeof status.reservationQueue === "object" + ? status.reservationQueue + : { entries: [], canReserve: false }; + const entries = Array.isArray(queue.entries) ? queue.entries : []; + const visible = Boolean(queue.visible); + const loggedIn = Boolean(state.user); + const canOperate = canOperateStation(); + const isOwner = loggedIn && status && state.user && state.user.id && String(state.user.id) === String(status.activeByUserId || ""); + const hasOwnReservation = loggedIn + && state.user + && entries.some((entry) => String(entry && entry.userId ? entry.userId : "") === String(state.user.id || "")); + + els.reservationPanel.hidden = !visible; + els.reserveNextBtn.disabled = !loggedIn || !canOperate || !queue.canReserve || isOwner || hasOwnReservation; + + els.reservationList.innerHTML = ""; + if (!visible) { + return; + } + if (!entries.length) { + const empty = document.createElement("p"); + empty.className = "muted"; + empty.textContent = translateLiteral("Noch keine Reservierungen vorhanden."); + els.reservationList.appendChild(empty); + return; + } + + const list = document.createElement("div"); + list.className = "swr-summary-list"; + let hasMineEntry = false; + entries.forEach((entry) => { + const row = document.createElement("div"); + row.className = `swr-summary-row reservation-row${entry.active ? " reservation-row-active" : ""}`; + + const left = document.createElement("div"); + left.className = "stack"; + + const title = document.createElement("strong"); + title.textContent = `#${Number(entry.position || 0)} ${entry.email || "-"}`; + + const details = document.createElement("small"); + details.className = "muted"; + const fromText = entry.from + ? new Date(entry.from).toLocaleString(localeForDate(), { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }) + : "-"; + const toText = entry.to + ? new Date(entry.to).toLocaleString(localeForDate(), { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }) + : "-"; + details.textContent = `${fromText} - ${toText}`; + + const pill = document.createElement("span"); + pill.className = `pill${entry.active ? " ok" : ""}`; + if (entry.active) { + pill.textContent = translateLiteral("Aktiv"); + } else { + const fromLabel = entry.from + ? new Date(entry.from).toLocaleTimeString(localeForDate(), { hour: "2-digit", minute: "2-digit" }) + : "-"; + const toLabel = entry.to + ? new Date(entry.to).toLocaleTimeString(localeForDate(), { hour: "2-digit", minute: "2-digit" }) + : "-"; + pill.textContent = `${fromLabel} - ${toLabel}`; + } + + left.appendChild(title); + left.appendChild(details); + row.appendChild(left); + row.appendChild(pill); + list.appendChild(row); + + const isMine = loggedIn + && state.user + && String(entry.userId || "") === String(state.user.id || ""); + hasMineEntry = hasMineEntry || isMine; + }); + els.reservationList.appendChild(list); + + if (hasMineEntry) { + const removeWrap = document.createElement("div"); + removeWrap.className = "actions"; + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "ghost-btn danger"; + removeBtn.textContent = translateLiteral("Meine Reservierung loeschen"); + removeBtn.addEventListener("click", async () => { + await cancelOwnReservation(); + }); + removeWrap.appendChild(removeBtn); + els.reservationList.appendChild(removeWrap); + } +} + +function renderOpenWebRx(status) { + if (!els.openwebrxPanel) { + return; + } + const loggedIn = Boolean(state.user); + const isOwner = loggedIn && status && status.isInUse && state.user.id && state.user.id === status.activeByUserId; + els.openwebrxPanel.hidden = !isOwner; + if (els.controlsPanel) { + els.controlsPanel.hidden = !isOwner; + } + if (!isOwner) { + stopOpenWebRxTxPolling(); + state.openWebRx.sessionUrl = ""; + state.openWebRx.sessionTicket = ""; + state.openWebRx.expiresAt = null; + state.openWebRx.bands = []; + state.openWebRx.selectedBand = ""; + state.openWebRx.txActive = null; + state.openWebRx.rotor = { + azimuth: null, + moving: false, + min: 0, + max: 360 + }; + syncOpenWebRxTicketCookie(""); + renderOpenWebRxBandOptions(); + renderOpenWebRxTxState(); + renderRotorState(); + renderOpenWebRxSessionAccess(); + return; + } + startOpenWebRxTxPolling(); + if (els.openwebrxOpenBtn) { + els.openwebrxOpenBtn.disabled = Boolean(status.activation && status.activation.running) || state.openWebRx.busy; + } + setOpenWebRxBusy(state.openWebRx.busy); +} + +function renderActivationProgress(activation) { + const running = Boolean(activation && activation.running); + renderSingleActivationProgress({ + box: els.activationProgress, + fill: els.progressFill, + text: els.progressText, + eta: els.progressEta + }, activation, running); + renderSingleActivationProgress({ + box: els.activationProgressSwr, + fill: els.progressFillSwr, + text: els.progressTextSwr, + eta: els.progressEtaSwr + }, activation, running); + if (!running) { + stopActivationWatch(); + return; + } + startActivationWatch(); +} + +function renderSingleActivationProgress(target, activation, running) { + if (!target || !target.box || !target.fill || !target.text || !target.eta) { + return; + } + target.box.hidden = !running; + if (!running) { + target.fill.style.width = "0%"; + target.text.textContent = "-"; + target.eta.textContent = "Geschaetzte Restzeit: -"; + return; + } + const percent = Number(activation.percent || 0); + const elapsedSec = Number(activation.elapsedSec || 0); + const remainingSec = Number(activation.remainingSec || 0); + const phase = String(activation.phase || "swr-check"); + target.fill.style.width = `${Math.max(0, Math.min(100, percent))}%`; + target.text.textContent = `${elapsedSec}s`; + target.eta.textContent = `SWR-Status: ${phase}${remainingSec > 0 ? `, noch ca. ${remainingSec}s` : ""}`; +} + +function startActivationWatch() { + if (activationWatchTimer) { + return; + } + activationWatchTimer = setInterval(async () => { + if (activationWatchInFlight || !state.user) { + return; + } + activationWatchInFlight = true; + try { + await refreshStatus(); + const activation = state.status && state.status.activation ? state.status.activation : null; + const swrRun = state.status && state.status.swrRun ? state.status.swrRun : null; + if (activation && activation.running && String(activation.phase || "") !== "swr-check") { + clearActivationSwrWaitingMessages(); + } + await refreshSwrReport(); + if (!(swrRun && swrRun.running)) { + if (activationPending && !(state.status && state.status.isInUse)) { + const activation = state.status && state.status.activation ? state.status.activation : null; + const reason = activation && activation.lastStatus === "failed" && activation.lastError + ? `: ${activation.lastError}` + : ". Bitte Logs pruefen."; + const failText = `SWR-Check fertig, Aktivierung fehlgeschlagen${reason}`; + renderMessage(els.swrSummaryMessage, failText, true); + renderMessage(els.swrPageMessage, failText, true); + } + activationPending = false; + stopActivationWatch(); + await refreshControls(); + } + } catch { + // ignore watch errors + } finally { + activationWatchInFlight = false; + } + }, 1000); +} + +function stopActivationWatch() { + if (!activationWatchTimer) { + return; + } + clearInterval(activationWatchTimer); + activationWatchTimer = null; +} + +function clearActivationSwrWaitingMessages() { + const waitingPrefix = "SWR-Messung laeuft"; + const targets = [els.swrSummaryMessage, els.swrPageMessage]; + for (const target of targets) { + if (!target || typeof target.textContent !== "string") { + continue; + } + if (target.textContent.trim().startsWith(waitingPrefix)) { + target.textContent = ""; + target.className = "message"; + } + } +} + +function renderStationLinks(status) { + if (!els.stationLinks) { + return; + } + const links = status && status.links ? status.links : {}; + const ready = Boolean(status && status.linksReady); + const hasAny = Boolean(links.swrOverview || links.openWebRxPath || links.webSdr || links.rotorControl); + els.stationLinks.hidden = !(ready && hasAny); + setLink(els.swrLink, links.swrOverview); + setLink(els.openwebrxLink, links.openWebRxPath || null); + setLink(els.websdrLink, links.webSdr); + setLink(els.rotorLink, links.rotorControl); +} + +function setLink(el, href) { + if (!el) { + return; + } + if (!href) { + el.hidden = true; + el.removeAttribute("href"); + return; + } + el.hidden = false; + el.href = href; +} + +function renderPluginControls() { + els.pluginControls.innerHTML = ""; + if (!state.controls.length) { + els.pluginControls.textContent = "Keine dynamischen Controls verfuegbar."; + return; + } + + for (const control of state.controls) { + if (control.controlId === "station-main") { + continue; + } + const wrapper = document.createElement("div"); + wrapper.className = "card"; + wrapper.style.padding = "0.8rem"; + + const title = document.createElement("h3"); + title.textContent = control.title; + wrapper.appendChild(title); + + const status = document.createElement("p"); + status.className = "muted"; + status.textContent = JSON.stringify(control.status || {}); + wrapper.appendChild(status); + + const actions = document.createElement("div"); + actions.className = "schema-form"; + for (const action of control.actions || []) { + const form = document.createElement("form"); + form.className = "schema-form"; + const formFields = renderActionFields(form, action.inputSchema || {}); + + const submit = document.createElement("button"); + submit.type = "submit"; + submit.textContent = action.name; + form.appendChild(submit); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const input = readActionInput(formFields); + await executePluginAction(control, action, input); + }); + + actions.appendChild(form); + } + wrapper.appendChild(actions); + els.pluginControls.appendChild(wrapper); + } +} + +function renderActionFields(form, schema) { + const fields = []; + const properties = schema && schema.properties ? schema.properties : {}; + for (const [name, fieldSchema] of Object.entries(properties)) { + const label = document.createElement("label"); + label.className = "field"; + const caption = document.createElement("span"); + caption.textContent = name; + label.appendChild(caption); + + const required = Array.isArray(schema.required) && schema.required.includes(name); + + if (Array.isArray(fieldSchema.enum)) { + const select = document.createElement("select"); + select.name = name; + select.required = required; + for (const optionValue of fieldSchema.enum) { + const option = document.createElement("option"); + option.value = String(optionValue); + option.textContent = String(optionValue); + select.appendChild(option); + } + label.appendChild(select); + fields.push({ name, type: fieldSchema.type || "string", element: select }); + } else { + const input = document.createElement("input"); + input.name = name; + input.required = required; + if (fieldSchema.type === "number" || fieldSchema.type === "integer") { + input.type = "number"; + if (fieldSchema.type === "integer") input.step = "1"; + if (fieldSchema.minimum !== undefined) input.min = String(fieldSchema.minimum); + if (fieldSchema.maximum !== undefined) input.max = String(fieldSchema.maximum); + } else if (fieldSchema.type === "boolean") { + input.type = "checkbox"; + } else { + input.type = "text"; + } + if (fieldSchema.default !== undefined) { + if (fieldSchema.type === "boolean") { + input.checked = Boolean(fieldSchema.default); + } else { + input.value = String(fieldSchema.default); + } + } + label.appendChild(input); + fields.push({ name, type: fieldSchema.type || "string", element: input }); + } + + form.appendChild(label); + } + return fields; +} + +function readActionInput(fields) { + const input = {}; + for (const field of fields) { + if (!field || !field.element) { + continue; + } + if (field.type === "boolean" && !field.hasSavedValue && !field.dirty && !field.element.checked) { + continue; + } + const raw = field.type === "boolean" ? String(field.element.checked) : field.element.value; + if (raw === "") { + if (field.dirty && (field.type === "string" || !field.type)) { + input[field.name] = ""; + } + continue; + } + if (field.type === "number" || field.type === "integer") { + const parsed = Number(raw); + input[field.name] = field.type === "integer" ? Math.trunc(parsed) : parsed; + } else if (field.type === "boolean") { + input[field.name] = field.element.checked; + } else { + input[field.name] = raw; + } + } + return input; +} + +async function executePluginAction(control, action, input) { + clearMessages("plugin"); + try { + await api(`/v1/ui/controls/${encodeURIComponent(control.controlId)}/actions/${encodeURIComponent(action.name)}`, { + method: "POST", + body: { input } + }); + renderMessage(els.pluginMessage, `${control.title}: ${action.name} ausgefuehrt`, false, true); + await refreshStatus(); + await refreshControls(); + } catch (error) { + renderMessage(els.pluginMessage, error.message, true); + } +} + +function renderPluginAdmin() { + renderPluginAdminInto(els.pluginsAdminConfig); +} + +function renderPluginAdminInto(container) { + if (!container) { + return; + } + container.innerHTML = ""; + if (!isAdmin()) { + return; + } + if (!state.plugins.length) { + container.textContent = "Keine Plugins gefunden."; + return; + } + + for (const plugin of state.plugins) { + const block = document.createElement("div"); + block.className = "plugin-block"; + + const head = document.createElement("div"); + head.className = "section-head"; + const title = document.createElement("h3"); + title.textContent = `${plugin.name} (${plugin.id})`; + head.appendChild(title); + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = plugin.enabled ? "danger" : ""; + toggle.textContent = plugin.enabled ? "Deaktivieren" : "Aktivieren"; + toggle.addEventListener("click", async () => { + await togglePlugin(plugin.id, plugin.enabled); + }); + head.appendChild(toggle); + block.appendChild(head); + + const caps = document.createElement("p"); + caps.className = "muted"; + caps.textContent = `Capabilities: ${(plugin.capabilities || []).join(", ") || "-"}`; + block.appendChild(caps); + + const settingsSchema = plugin.settingsSchema || { type: "object", properties: {} }; + const hasSettings = settingsSchema && settingsSchema.properties && Object.keys(settingsSchema.properties).length > 0; + if (hasSettings) { + const settingsTitle = document.createElement("span"); + settingsTitle.className = "muted"; + settingsTitle.textContent = "Plugin Settings"; + block.appendChild(settingsTitle); + + const settingsForm = document.createElement("form"); + settingsForm.className = "schema-form"; + const settingFields = renderSchemaForm(settingsForm, settingsSchema, plugin.settings || {}); + maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin); + + const save = document.createElement("button"); + save.type = "submit"; + save.textContent = "Settings speichern"; + settingsForm.appendChild(save); + + settingsForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await savePluginSettings(plugin.id, settingFields); + }); + + block.appendChild(settingsForm); + } + + container.appendChild(block); + } +} + +function maybeAttachMicrohamEqBuilder(settingsForm, settingFields, plugin) { + if (!settingsForm || !Array.isArray(settingFields) || !plugin || plugin.id !== "rms.microham") { + return; + } + const extraArgsField = settingFields.find((field) => field && field.name === "audioFfmpegExtraArgs" && field.element); + if (!extraArgsField || !extraArgsField.element) { + return; + } + + const presets = { + flat: { + hp: 250, + lp: 2800, + mids: 0, + presence: 0, + deesserEnabled: false, + deesserFreq: 5200, + deesserCut: 0, + compressorEnabled: false, + compressorThreshold: -18, + compressorRatio: 2.5, + gateEnabled: false, + gateThreshold: -52, + gateRelease: 180, + limiter: true + }, + dx: { + hp: 360, + lp: 2500, + mids: 1.0, + presence: 1.8, + deesserEnabled: true, + deesserFreq: 5200, + deesserCut: 2.0, + compressorEnabled: true, + compressorThreshold: -20, + compressorRatio: 3.0, + gateEnabled: false, + gateThreshold: -50, + gateRelease: 160, + limiter: true + }, + ragchew: { + hp: 300, + lp: 2800, + mids: 0.5, + presence: 1.0, + deesserEnabled: true, + deesserFreq: 5000, + deesserCut: 1.2, + compressorEnabled: true, + compressorThreshold: -22, + compressorRatio: 2.2, + gateEnabled: true, + gateThreshold: -55, + gateRelease: 220, + limiter: true + } + }; + + const parsed = parseMicrohamEqArgs(extraArgsField.element.value); + const initial = parsed || presets.flat; + + const wrap = document.createElement("div"); + wrap.className = "plugin-eq-builder"; + + const title = document.createElement("strong"); + title.textContent = "TX Audio EQ"; + wrap.appendChild(title); + + const hint = document.createElement("small"); + hint.className = "muted"; + hint.textContent = parsed + ? "EQ aus aktuellen Extra-Args geladen" + : "Preset/Regler schreiben den FFmpeg-EQ in audioFfmpegExtraArgs"; + wrap.appendChild(hint); + + const presetRow = document.createElement("div"); + presetRow.className = "plugin-eq-row"; + + const presetLabel = document.createElement("span"); + presetLabel.className = "muted"; + presetLabel.textContent = "Preset"; + presetRow.appendChild(presetLabel); + + const presetSelect = document.createElement("select"); + presetSelect.innerHTML = [ + '', + '', + '' + ].join(""); + presetRow.appendChild(presetSelect); + + const presetBtn = document.createElement("button"); + presetBtn.type = "button"; + presetBtn.className = "ghost-btn"; + presetBtn.textContent = "Preset anwenden"; + presetRow.appendChild(presetBtn); + + wrap.appendChild(presetRow); + + const presetInfo = document.createElement("small"); + presetInfo.className = "muted"; + wrap.appendChild(presetInfo); + + const hpControl = createEqSliderControl("Highpass (Hz)", 80, 600, 10, initial.hp); + const lpControl = createEqSliderControl("Lowpass (Hz)", 1800, 4000, 10, initial.lp); + const midsControl = createEqSliderControl("Mitten (dB)", -8, 12, 0.5, initial.mids); + const presenceControl = createEqSliderControl("Presence (dB)", -8, 12, 0.5, initial.presence); + const deesserFreqControl = createEqSliderControl("De-Esser (Hz)", 3500, 8000, 100, initial.deesserFreq); + const deesserCutControl = createEqSliderControl("De-Esser Cut (dB)", 0, 8, 0.5, initial.deesserCut); + const compThresholdControl = createEqSliderControl("Compressor Threshold (dB)", -40, -6, 1, initial.compressorThreshold); + const compRatioControl = createEqSliderControl("Compressor Ratio", 1.2, 6, 0.1, initial.compressorRatio); + const gateThresholdControl = createEqSliderControl("Gate Threshold (dB)", -70, -20, 1, initial.gateThreshold); + const gateReleaseControl = createEqSliderControl("Gate Release (ms)", 60, 500, 10, initial.gateRelease); + wrap.appendChild(hpControl.row); + wrap.appendChild(lpControl.row); + wrap.appendChild(midsControl.row); + wrap.appendChild(presenceControl.row); + const deesserEnabledRow = createEqCheckboxRow("De-Esser aktivieren", initial.deesserEnabled); + wrap.appendChild(deesserEnabledRow.row); + wrap.appendChild(deesserFreqControl.row); + wrap.appendChild(deesserCutControl.row); + const compressorEnabledRow = createEqCheckboxRow("Compressor aktivieren", initial.compressorEnabled); + wrap.appendChild(compressorEnabledRow.row); + wrap.appendChild(compThresholdControl.row); + wrap.appendChild(compRatioControl.row); + const gateEnabledRow = createEqCheckboxRow("Noise Gate aktivieren", initial.gateEnabled); + wrap.appendChild(gateEnabledRow.row); + wrap.appendChild(gateThresholdControl.row); + wrap.appendChild(gateReleaseControl.row); + + const limiterRow = document.createElement("label"); + limiterRow.className = "plugin-eq-checkbox"; + const limiterInput = document.createElement("input"); + limiterInput.type = "checkbox"; + limiterInput.checked = Boolean(initial.limiter); + limiterRow.appendChild(limiterInput); + const limiterText = document.createElement("span"); + limiterText.textContent = "Limiter aktivieren (alimiter=0.95)"; + limiterRow.appendChild(limiterText); + wrap.appendChild(limiterRow); + + const preview = document.createElement("small"); + preview.className = "muted"; + wrap.appendChild(preview); + + const saveButton = settingsForm.querySelector('button[type="submit"]'); + if (saveButton && saveButton.parentNode === settingsForm) { + settingsForm.insertBefore(wrap, saveButton); + } else { + settingsForm.appendChild(wrap); + } + + const applyControlsToExtraArgs = () => { + const hp = Number(hpControl.number.value); + const lp = Number(lpControl.number.value); + const mids = Number(midsControl.number.value); + const presence = Number(presenceControl.number.value); + const deesserEnabled = Boolean(deesserEnabledRow.input.checked); + const deesserFreq = Number(deesserFreqControl.number.value); + const deesserCut = Number(deesserCutControl.number.value); + const compressorEnabled = Boolean(compressorEnabledRow.input.checked); + const compressorThreshold = Number(compThresholdControl.number.value); + const compressorRatio = Number(compRatioControl.number.value); + const gateEnabled = Boolean(gateEnabledRow.input.checked); + const gateThreshold = Number(gateThresholdControl.number.value); + const gateRelease = Number(gateReleaseControl.number.value); + const limiter = Boolean(limiterInput.checked); + const args = buildMicrohamEqExtraArgs({ + hp, + lp, + mids, + presence, + deesserEnabled, + deesserFreq, + deesserCut, + compressorEnabled, + compressorThreshold, + compressorRatio, + gateEnabled, + gateThreshold, + gateRelease, + limiter + }); + extraArgsField.element.value = args; + preview.textContent = `Generierte Extra-Args: ${args}`; + }; + + const updatePresetInfo = () => { + const selected = String(presetSelect.value || "flat"); + if (selected === "dx") { + presetInfo.textContent = "DX: sprachfokussiert mit reduziertem Bass und moderater Praesenz, weniger kuenstlich abgestimmt."; + return; + } + if (selected === "ragchew") { + presetInfo.textContent = "Ragchew: natuerlicher Sprachklang mit leicht reduziertem Bass und sanfter Mitten/Praesenz-Anhebung."; + return; + } + presetInfo.textContent = "Flat: neutrale Basis mit wenig Klangfaerbung, gut fuer Vergleich und Feintuning."; + }; + + const bindControlUpdate = (control) => { + control.range.addEventListener("input", () => { + control.number.value = control.range.value; + applyControlsToExtraArgs(); + }); + control.number.addEventListener("input", () => { + control.range.value = control.number.value; + applyControlsToExtraArgs(); + }); + }; + + bindControlUpdate(hpControl); + bindControlUpdate(lpControl); + bindControlUpdate(midsControl); + bindControlUpdate(presenceControl); + bindControlUpdate(deesserFreqControl); + bindControlUpdate(deesserCutControl); + bindControlUpdate(compThresholdControl); + bindControlUpdate(compRatioControl); + bindControlUpdate(gateThresholdControl); + bindControlUpdate(gateReleaseControl); + limiterInput.addEventListener("change", applyControlsToExtraArgs); + deesserEnabledRow.input.addEventListener("change", applyControlsToExtraArgs); + compressorEnabledRow.input.addEventListener("change", applyControlsToExtraArgs); + gateEnabledRow.input.addEventListener("change", applyControlsToExtraArgs); + + presetBtn.addEventListener("click", () => { + const selected = presets[presetSelect.value] || presets.flat; + hpControl.range.value = String(selected.hp); + hpControl.number.value = String(selected.hp); + lpControl.range.value = String(selected.lp); + lpControl.number.value = String(selected.lp); + midsControl.range.value = String(selected.mids); + midsControl.number.value = String(selected.mids); + presenceControl.range.value = String(selected.presence); + presenceControl.number.value = String(selected.presence); + deesserEnabledRow.input.checked = Boolean(selected.deesserEnabled); + deesserFreqControl.range.value = String(selected.deesserFreq); + deesserFreqControl.number.value = String(selected.deesserFreq); + deesserCutControl.range.value = String(selected.deesserCut); + deesserCutControl.number.value = String(selected.deesserCut); + compressorEnabledRow.input.checked = Boolean(selected.compressorEnabled); + compThresholdControl.range.value = String(selected.compressorThreshold); + compThresholdControl.number.value = String(selected.compressorThreshold); + compRatioControl.range.value = String(selected.compressorRatio); + compRatioControl.number.value = String(selected.compressorRatio); + gateEnabledRow.input.checked = Boolean(selected.gateEnabled); + gateThresholdControl.range.value = String(selected.gateThreshold); + gateThresholdControl.number.value = String(selected.gateThreshold); + gateReleaseControl.range.value = String(selected.gateRelease); + gateReleaseControl.number.value = String(selected.gateRelease); + limiterInput.checked = Boolean(selected.limiter); + applyControlsToExtraArgs(); + }); + + presetSelect.addEventListener("change", updatePresetInfo); + + preview.textContent = `Aktuelle Extra-Args: ${extraArgsField.element.value || ""}`; + updatePresetInfo(); +} + +function createEqSliderControl(labelText, min, max, step, value) { + const row = document.createElement("div"); + row.className = "plugin-eq-row"; + + const label = document.createElement("span"); + label.className = "muted"; + label.textContent = labelText; + row.appendChild(label); + + const range = document.createElement("input"); + range.type = "range"; + range.min = String(min); + range.max = String(max); + range.step = String(step); + range.value = String(value); + row.appendChild(range); + + const number = document.createElement("input"); + number.type = "number"; + number.min = String(min); + number.max = String(max); + number.step = String(step); + number.value = String(value); + row.appendChild(number); + + return { row, range, number }; +} + +function createEqCheckboxRow(labelText, checked) { + const row = document.createElement("label"); + row.className = "plugin-eq-checkbox"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = Boolean(checked); + row.appendChild(input); + const label = document.createElement("span"); + label.textContent = labelText; + row.appendChild(label); + return { row, input }; +} + +function buildMicrohamEqExtraArgs({ + hp, + lp, + mids, + presence, + deesserEnabled, + deesserFreq, + deesserCut, + compressorEnabled, + compressorThreshold, + compressorRatio, + gateEnabled, + gateThreshold, + gateRelease, + limiter +}) { + const filters = [ + `highpass=f=${Math.round(Number(hp) || 0)}`, + `lowpass=f=${Math.round(Number(lp) || 0)}`, + `equalizer=f=1200:t=q:w=0.8:g=${Number(Number(mids || 0).toFixed(1))}`, + `equalizer=f=2100:t=q:w=0.9:g=${Number(Number(presence || 0).toFixed(1))}` + ]; + if (deesserEnabled && Number(deesserCut || 0) > 0) { + const freq = Math.round(Number(deesserFreq) || 5200); + const cut = Number(Number(-Math.abs(Number(deesserCut || 0))).toFixed(1)); + filters.push(`equalizer=f=${freq}:t=q:w=1.1:g=${cut}`); + } + if (compressorEnabled) { + const thresholdDb = Number(Number(compressorThreshold || -20).toFixed(1)); + const ratio = Number(Number(compressorRatio || 3).toFixed(2)); + const thresholdLinear = Number(Math.pow(10, thresholdDb / 20).toFixed(6)); + filters.push(`acompressor=threshold=${thresholdLinear}:ratio=${ratio}:attack=5:release=120:makeup=1.2`); + } + if (gateEnabled) { + const gateThresholdDb = Number(Number(gateThreshold || -55).toFixed(1)); + const gateThresholdLinear = Number(Math.pow(10, gateThresholdDb / 20).toFixed(6)); + const release = Math.round(Number(gateRelease || 180)); + filters.push(`agate=threshold=${gateThresholdLinear}:release=${release}`); + } + if (limiter) { + filters.push("alimiter=limit=0.95"); + } + return `-af "${filters.join(",")}"`; +} + +function parseMicrohamEqArgs(value) { + const text = String(value || "").trim(); + if (!text) { + return null; + } + const hpMatch = text.match(/highpass\s*=\s*f\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i); + const lpMatch = text.match(/lowpass\s*=\s*f\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i); + const midsMatch = text.match(/equalizer\s*=\s*f\s*=\s*1200\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*0\.8\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i); + const presenceMatch = text.match(/equalizer\s*=\s*f\s*=\s*2100\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*0\.9\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i); + if (!hpMatch || !lpMatch || !presenceMatch) { + return null; + } + const hp = Number(hpMatch[1]); + const lp = Number(lpMatch[1]); + const mids = midsMatch ? Number(midsMatch[1]) : 0; + const presence = Number(presenceMatch[1]); + if (!Number.isFinite(hp) || !Number.isFinite(lp) || !Number.isFinite(mids) || !Number.isFinite(presence)) { + return null; + } + const deesserMatch = text.match(/equalizer\s*=\s*f\s*=\s*([0-9]+)\s*:\s*t\s*=\s*q\s*:\s*w\s*=\s*1\.1\s*:\s*g\s*=\s*(-?[0-9]+(?:\.[0-9]+)?)/i); + const deesserEnabled = Boolean(deesserMatch); + const deesserFreq = deesserMatch ? Number(deesserMatch[1]) : 5200; + const deesserCut = deesserMatch ? Math.abs(Number(deesserMatch[2])) : 0; + const compMatch = text.match(/acompressor\s*=\s*threshold\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*:\s*ratio\s*=\s*([0-9]+(?:\.[0-9]+)?)/i); + const compressorEnabled = Boolean(compMatch); + const compressorThreshold = compMatch ? Number((20 * Math.log10(Number(compMatch[1]))).toFixed(1)) : -20; + const compressorRatio = compMatch ? Number(compMatch[2]) : 3; + const gateMatch = text.match(/agate\s*=\s*threshold\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*:\s*release\s*=\s*([0-9]+(?:\.[0-9]+)?)/i); + const gateEnabled = Boolean(gateMatch); + const gateThreshold = gateMatch ? Number((20 * Math.log10(Number(gateMatch[1]))).toFixed(1)) : -55; + const gateRelease = gateMatch ? Number(gateMatch[2]) : 180; + const limiter = /alimiter\s*=\s*limit\s*=\s*0\.95/i.test(text); + return { + hp, + lp, + mids, + presence, + deesserEnabled, + deesserFreq, + deesserCut, + compressorEnabled, + compressorThreshold, + compressorRatio, + gateEnabled, + gateThreshold, + gateRelease, + limiter + }; +} + +function renderProviderAdmin() { + renderProviderAdminInto(els.providersAdminConfig); +} + +function renderProviderAdminInto(container) { + if (!container) { + return; + } + container.innerHTML = ""; + if (!isAdmin()) { + return; + } + if (!state.capabilities.length) { + container.textContent = "Keine Provider-Daten verfuegbar."; + return; + } + + for (const entry of state.capabilities) { + const block = document.createElement("div"); + block.className = "plugin-block"; + + const head = document.createElement("div"); + head.className = "section-head"; + const title = document.createElement("h3"); + title.textContent = entry.capability; + head.appendChild(title); + block.appendChild(head); + + const row = document.createElement("label"); + row.className = "field"; + const label = document.createElement("span"); + label.textContent = "Aktiver Provider"; + row.appendChild(label); + + const select = document.createElement("select"); + for (const provider of entry.providers || []) { + const opt = document.createElement("option"); + opt.value = provider.pluginId; + opt.textContent = provider.pluginId; + if (entry.activePluginId === provider.pluginId) { + opt.selected = true; + } + select.appendChild(opt); + } + select.addEventListener("change", async () => { + await switchProvider(entry.capability, select.value); + }); + row.appendChild(select); + block.appendChild(row); + + const providers = document.createElement("div"); + providers.className = "actions"; + for (const provider of entry.providers || []) { + const badge = document.createElement("span"); + badge.className = `health-badge health-${provider.health}`; + badge.textContent = `${provider.pluginId} (${provider.enabled ? "on" : "off"}, ${provider.health})`; + providers.appendChild(badge); + } + block.appendChild(providers); + container.appendChild(block); + } +} + +function renderCapabilityMatrix() { + renderCapabilityMatrixInto(els.providersCapabilityMatrix); +} + +function renderCapabilityMatrixInto(container) { + if (!container) { + return; + } + container.innerHTML = ""; + if (!isAdmin()) { + return; + } + if (!state.capabilities.length) { + container.textContent = "Keine Capability Daten verfuegbar."; + return; + } + + for (const entry of state.capabilities) { + const row = document.createElement("div"); + row.className = "matrix-row"; + + const head = document.createElement("div"); + head.className = "section-head"; + const title = document.createElement("strong"); + title.textContent = entry.capability; + head.appendChild(title); + + const active = document.createElement("span"); + active.className = "pill"; + active.textContent = `Aktiv: ${entry.activePluginId || "-"}`; + head.appendChild(active); + row.appendChild(head); + + const providers = document.createElement("div"); + providers.className = "actions"; + for (const provider of entry.providers || []) { + const badge = document.createElement("span"); + badge.className = `health-badge health-${provider.health}`; + badge.textContent = `${provider.pluginId} (${provider.enabled ? "on" : "off"}, ${provider.health})`; + providers.appendChild(badge); + } + row.appendChild(providers); + + container.appendChild(row); + } +} + +function renderSchemaForm(form, schema, values) { + const fields = []; + const properties = schema.properties || {}; + const required = Array.isArray(schema.required) ? schema.required : []; + + for (const [name, fieldSchema] of Object.entries(properties)) { + const label = document.createElement("label"); + label.className = "field"; + const caption = document.createElement("span"); + caption.textContent = name; + label.appendChild(caption); + + const hasSavedValue = values && values[name] !== undefined; + const rawCurrentValue = hasSavedValue ? values[name] : fieldSchema.default; + const currentValue = coerceSchemaValue(fieldSchema, rawCurrentValue); + const sourceLabel = hasSavedValue ? "gespeichert" : (fieldSchema.default !== undefined ? "default" : "nicht gesetzt"); + + if (Array.isArray(fieldSchema.enum)) { + const select = document.createElement("select"); + select.name = name; + select.required = required.includes(name); + for (const enumValue of fieldSchema.enum) { + const option = document.createElement("option"); + option.value = String(enumValue); + option.textContent = String(enumValue); + if (currentValue === enumValue) { + option.selected = true; + } + select.appendChild(option); + } + label.appendChild(select); + const field = { name, type: fieldSchema.type || "string", element: select, hasSavedValue, dirty: false }; + select.addEventListener("change", () => { + field.dirty = true; + }); + fields.push(field); + } else { + const input = document.createElement("input"); + input.name = name; + input.required = required.includes(name); + if (fieldSchema.type === "number" || fieldSchema.type === "integer") { + input.type = "number"; + if (fieldSchema.type === "integer") input.step = "1"; + if (fieldSchema.minimum !== undefined) input.min = String(fieldSchema.minimum); + if (fieldSchema.maximum !== undefined) input.max = String(fieldSchema.maximum); + } else if (fieldSchema.type === "boolean") { + input.type = "checkbox"; + } else { + input.type = "text"; + } + + const value = currentValue; + if (value !== undefined) { + if (fieldSchema.type === "boolean") { + input.checked = Boolean(value); + } else { + input.value = String(value); + } + } + label.appendChild(input); + const field = { name, type: fieldSchema.type || "string", element: input, hasSavedValue, dirty: false }; + input.addEventListener("change", () => { + field.dirty = true; + }); + fields.push(field); + } + + const currentInfo = document.createElement("small"); + currentInfo.className = "muted"; + currentInfo.textContent = `Aktuell in Verwendung: ${formatSettingValue(currentValue)} (${sourceLabel})`; + label.appendChild(currentInfo); + + form.appendChild(label); + } + return fields; +} + +function formatSettingValue(value) { + if (value === undefined || value === null || value === "") { + return ""; + } + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + return String(value); +} + +function coerceSchemaValue(fieldSchema, value) { + if (!fieldSchema || value === undefined || value === null) { + return value; + } + const type = String(fieldSchema.type || "").toLowerCase(); + if (type === "boolean") { + if (typeof value === "boolean") { + return value; + } + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "off", ""].includes(normalized)) { + return false; + } + return Boolean(value); + } + if (type === "number" || type === "integer") { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return value; + } + return type === "integer" ? Math.trunc(numeric) : numeric; + } + return value; +} + +async function savePluginSettings(pluginId, fields) { + clearMessages("admin"); + try { + const settings = readActionInput(fields); + await api(`/v1/plugins/${encodeURIComponent(pluginId)}/settings`, { + method: "PUT", + body: { settings } + }); + await refreshPlugins(); + await refreshControls(); + renderMessage(els.adminMessage, `Settings fuer ${pluginId} gespeichert`, false, true); + } catch (error) { + renderMessage(els.adminMessage, error.message, true); + } +} + +async function togglePlugin(pluginId, currentlyEnabled) { + clearMessages("admin"); + try { + const route = currentlyEnabled ? "disable" : "enable"; + await api(`/v1/plugins/${encodeURIComponent(pluginId)}/${route}`, { + method: "POST", + body: {} + }); + await refreshPlugins(); + await refreshControls(); + renderMessage(els.adminMessage, `${pluginId} ${currentlyEnabled ? "deaktiviert" : "aktiviert"}`, false, true); + } catch (error) { + renderMessage(els.adminMessage, error.message, true); + } +} + +async function switchProvider(capability, pluginId) { + clearMessages("provider"); + try { + await api(`/v1/admin/capabilities/${encodeURIComponent(capability)}/provider`, { + method: "PUT", + body: { pluginId } + }); + await refreshPlugins(); + await refreshControls(); + renderMessage(els.providersMessage, `Provider fuer ${capability} auf ${pluginId} gesetzt`, false, true); + } catch (error) { + renderMessage(els.providersMessage, error.message, true); + } +} + +function updateUserUi() { + const setHidden = (el, value) => { + if (el) { + el.hidden = value; + } + }; + const setDisabled = (el, value) => { + if (el) { + el.disabled = value; + } + }; + const loggedIn = Boolean(state.user); + const rmsVisible = loggedIn && window.location.pathname.startsWith("/rms"); + const rawPage = currentRmsPage(); + const page = normalizedRmsPage(rawPage); + + if (rmsVisible && page !== rawPage) { + window.history.replaceState({}, "", pageToPath(page)); + } + + setHidden(els.authView, rmsVisible); + setHidden(els.rmsView, !rmsVisible); + setHidden(els.pageRms, !rmsVisible || page !== "rms"); + setHidden(els.pageSwr, !rmsVisible || page !== "swr"); + setHidden(els.pageUser, !rmsVisible || page !== "user"); + setHidden(els.pageHelp, !rmsVisible || page !== "help"); + setHidden(els.pagePlugins, !rmsVisible || page !== "plugins" || !isAdmin()); + setHidden(els.pagePluginConfig, !rmsVisible || page !== "plugin-config" || !isAdmin()); + setHidden(els.pageProviders, !rmsVisible || page !== "providers" || !isAdmin()); + setHidden(els.pageAdmin, !rmsVisible || page !== "admin"); + setHidden(els.pageUsers, !rmsVisible || page !== "users"); + setHidden(els.pageApprovals, !rmsVisible || page !== "approvals"); + setHidden(els.pageActivity, !rmsVisible || page !== "activity"); + setDisabled(els.loginBtn, loggedIn); + setDisabled(els.logoutBtn, !loggedIn); + setDisabled(els.userMenuButton, !loggedIn); + setDisabled(els.email, loggedIn); + if (els.userMenuButton) { + els.userMenuButton.textContent = "☰"; + els.userMenuButton.setAttribute("aria-label", loggedIn ? `Menue (${state.user.email})` : "Menue"); + } + setHidden(els.currentUserLink, !loggedIn); + if (els.currentUserLink) { + els.currentUserLink.textContent = loggedIn ? `👤 ${state.user.email}` : "👤 -"; + } + setHidden(els.adminCard, !isAdmin()); + if (els.pluginsConfigCard) { + els.pluginsConfigCard.hidden = !isAdmin(); + } + setHidden(els.menuAdmin, !isAdmin()); + setHidden(els.menuSwr, !canOperateStation()); + setHidden(els.menuHelp, !loggedIn); + setHidden(els.menuPlugins, !isAdmin()); + setHidden(els.menuPluginConfig, !isAdmin()); + setHidden(els.menuProviders, !isAdmin()); + setHidden(els.menuUsers, !canSeeUsersList()); + setHidden(els.menuApprovals, !canSeeApprovals()); + setHidden(els.menuActivity, !canSeeActivityLog()); + setHidden(els.mobileNavUsers, !canSeeUsersList()); + setHidden(els.mobileNavPlugins, !isAdmin()); + setHidden(els.mobileNavPluginConfig, !isAdmin()); + setHidden(els.mobileNavSwr, !canOperateStation()); + setHidden(els.mobileNavHelp, !loggedIn); + setHidden(els.mobileNavAdmin, !isAdmin()); + setHidden(els.mobileNavApprovals, !canSeeApprovals()); + setHidden(els.mobileNavActivity, !canSeeActivityLog()); + if (els.mobileNav) { + els.mobileNav.classList.toggle("admin-visible", isAdmin()); + els.mobileNav.classList.toggle("swr-visible", canOperateStation()); + els.mobileNav.classList.toggle("users-visible", canSeeUsersList()); + els.mobileNav.classList.toggle("approvals-visible", canSeeApprovals()); + els.mobileNav.classList.toggle("activity-visible", canSeeActivityLog()); + } + if (els.settingsEmail) { + els.settingsEmail.textContent = loggedIn ? state.user.email : "-"; + } + if (els.settingsRole) { + els.settingsRole.textContent = loggedIn ? state.user.role : "-"; + } + renderLanguageSelectors(); + if (els.settingsLanguageSelect) { + const userPreferred = normalizeLanguage(loggedIn && state.user && state.user.preferredLanguage + ? state.user.preferredLanguage + : state.i18n.language); + els.settingsLanguageSelect.value = userPreferred; + } + if (els.menuLanguageSelect) { + els.menuLanguageSelect.value = normalizeLanguage(state.i18n.language); + } + renderUserSettingsAuthMethods(); + renderMaintenanceBanner(); + updateMenuState(page, loggedIn); + updatePageMeta(loggedIn, page); + applyI18n(); + setHidden(els.mobileNav, !rmsVisible); + setUserMenuOpen(false); + setLanguageMenuOpen(false); + if (rmsVisible) { + animateCurrentPage(page); + } +} + +function animateCurrentPage(page) { + const target = page === "user" + ? els.pageUser + : page === "swr" + ? els.pageSwr + : page === "help" + ? els.pageHelp + : page === "plugins" + ? els.pagePlugins + : page === "plugin-config" + ? els.pagePluginConfig + : page === "providers" + ? els.pageProviders + : page === "users" + ? els.pageUsers + : page === "approvals" + ? els.pageApprovals + : page === "activity" + ? els.pageActivity + : page === "admin" + ? els.pageAdmin + : els.pageRms; + if (!target) { + return; + } + target.classList.remove("page-enter"); + void target.offsetWidth; + target.classList.add("page-enter"); +} + +function updatePageMeta(loggedIn, page) { + if (!loggedIn) { + els.pageTitle.textContent = "RMS Status"; + els.pageHint.textContent = "Bitte anmelden"; + els.pageCrumb.textContent = "LOGIN"; + return; + } + if (page === "user") { + els.pageTitle.textContent = "Einstellungen"; + els.pageHint.textContent = "Persoenliche Einstellungen"; + els.pageCrumb.textContent = "RMS / EINSTELLUNGEN"; + return; + } + if (page === "help") { + els.pageTitle.textContent = "Hilfe"; + els.pageHint.textContent = "Grundablaeufe fuer den Stationsbetrieb"; + els.pageCrumb.textContent = "RMS / HILFE"; + return; + } + if (page === "plugins") { + els.pageTitle.textContent = "Plugin Controls"; + els.pageHint.textContent = "Dynamische Geraetesteuerung"; + els.pageCrumb.textContent = "RMS / PLUGIN CONTROLS"; + return; + } + if (page === "plugin-config") { + els.pageTitle.textContent = "Plugin Konfiguration"; + els.pageHint.textContent = "Einstellungen und Aktivierung"; + els.pageCrumb.textContent = "RMS / PLUGIN KONFIG"; + return; + } + if (page === "providers") { + els.pageTitle.textContent = "Provider"; + els.pageHint.textContent = "Capability-Zuordnung"; + els.pageCrumb.textContent = "RMS / PROVIDER"; + return; + } + if (page === "swr") { + els.pageTitle.textContent = "SWR Test-Daten"; + els.pageHint.textContent = "Bandauswertung und Grafiken"; + els.pageCrumb.textContent = "RMS / SWR"; + return; + } + if (page === "admin") { + els.pageTitle.textContent = "Admin"; + els.pageHint.textContent = "System- und Plugin-Verwaltung"; + els.pageCrumb.textContent = "RMS / ADMIN"; + return; + } + if (page === "users") { + els.pageTitle.textContent = "Benutzerverwaltung"; + els.pageHint.textContent = "Rollen und Accountstatus"; + els.pageCrumb.textContent = "RMS / USERS"; + return; + } + if (page === "approvals") { + els.pageTitle.textContent = "Freigaben"; + els.pageHint.textContent = "Externe Domain-Anfragen"; + els.pageCrumb.textContent = "RMS / FREIGABEN"; + return; + } + if (page === "activity") { + els.pageTitle.textContent = "Aktivitaetslog"; + els.pageHint.textContent = "Bedienung und Stationsnutzung"; + els.pageCrumb.textContent = "RMS / AKTIVITAET"; + return; + } + els.pageTitle.textContent = "RMS Status"; + els.pageHint.textContent = "Station steuern"; + els.pageCrumb.textContent = "RMS / STATUS"; +} + +function updateMenuState(page, loggedIn) { + const map = { + rms: els.menuRms, + swr: els.menuSwr, + user: els.menuUser, + help: els.menuHelp, + plugins: els.menuPlugins, + "plugin-config": els.menuPluginConfig, + providers: els.menuProviders, + users: els.menuUsers, + approvals: els.menuApprovals, + activity: els.menuActivity, + admin: els.menuAdmin + }; + for (const [name, el] of Object.entries(map)) { + if (el) { + el.classList.toggle("active", loggedIn && page === name); + } + } + const mobileMap = { + rms: els.mobileNavRms, + swr: els.mobileNavSwr, + user: els.mobileNavUser, + help: els.mobileNavHelp, + plugins: els.mobileNavPlugins, + "plugin-config": els.mobileNavPluginConfig, + users: els.mobileNavUsers, + approvals: els.mobileNavApprovals, + activity: els.mobileNavActivity, + admin: els.mobileNavAdmin + }; + for (const [name, el] of Object.entries(mobileMap)) { + if (el) { + el.classList.toggle("active", loggedIn && page === name); + } + } + if (!loggedIn) { + const clearActive = [ + els.menuRms, + els.menuSwr, + els.menuUser, + els.menuHelp, + els.menuPlugins, + els.menuPluginConfig, + els.menuProviders, + els.menuUsers, + els.menuApprovals, + els.menuActivity, + els.menuAdmin, + els.mobileNavRms, + els.mobileNavSwr, + els.mobileNavUser, + els.mobileNavHelp, + els.mobileNavPlugins, + els.mobileNavPluginConfig, + els.mobileNavUsers, + els.mobileNavApprovals, + els.mobileNavActivity, + els.mobileNavAdmin + ]; + for (const el of clearActive) { + if (el) { + el.classList.remove("active"); + } + } + } +} + +function clearMessages(scope = "all") { + if (scope === "all" || scope === "auth") { + els.authMessage.textContent = ""; + els.authMessage.className = "message"; + } + if (scope === "all" || scope === "status") { + els.statusMessage.textContent = ""; + els.statusMessage.className = "message"; + if (els.reservationMessage) { + els.reservationMessage.textContent = ""; + els.reservationMessage.className = "message"; + } + if (els.openwebrxMessage) { + els.openwebrxMessage.textContent = ""; + els.openwebrxMessage.className = "message"; + } + } + if (scope === "all" || scope === "swr") { + if (els.swrSummaryMessage) { + els.swrSummaryMessage.textContent = ""; + els.swrSummaryMessage.className = "message"; + } + if (els.swrPageMessage) { + els.swrPageMessage.textContent = ""; + els.swrPageMessage.className = "message"; + } + } + if (scope === "all" || scope === "admin") { + els.adminMessage.textContent = ""; + els.adminMessage.className = "message"; + } + if (scope === "all" || scope === "provider") { + if (els.providersMessage) { + els.providersMessage.textContent = ""; + els.providersMessage.className = "message"; + } + } + if (scope === "all" || scope === "plugin") { + els.pluginMessage.textContent = ""; + els.pluginMessage.className = "message"; + } + if (scope === "all" || scope === "users") { + els.usersMessage.textContent = ""; + els.usersMessage.className = "message"; + } + if (scope === "all" || scope === "approvals") { + els.approvalsMessage.textContent = ""; + els.approvalsMessage.className = "message"; + } + if (scope === "all" || scope === "activity") { + els.activityMessage.textContent = ""; + els.activityMessage.className = "message"; + } + if (scope === "all" || scope === "help") { + if (els.helpMessage) { + els.helpMessage.textContent = ""; + els.helpMessage.className = "message"; + } + } +} + +function renderMessage(el, text, isError = false, isOk = false) { + if (!el) return; + el.textContent = translateLiteral(String(text || "")); + el.className = "message"; + if (isError) { + el.classList.add("error"); + } + if (isOk) { + el.classList.add("ok"); + } +} + +function currentRoute() { + const path = window.location.pathname; + if ( + path === "/rms" || + path === "/rms/swr" || + path === "/rms/user" || + path === "/rms/hilfe" || + path === "/rms/plugins" || + path === "/rms/plugin-konfig" || + path === "/rms/providers" || + path === "/rms/users" || + path === "/rms/freigaben" || + path === "/rms/aktivitaet" || + path === "/rms/admin" + ) { + return path; + } + return "/login"; +} + +function applyRoute(replace = false) { + const desired = state.user ? pageToPath(normalizedRmsPage(currentRmsPage())) : "/login"; + const desiredUrl = state.user + ? `${desired}${routeQueryForPage(normalizedRmsPage(currentRmsPage()))}` + : "/login"; + const currentUrl = `${window.location.pathname}${window.location.search}`; + if (currentUrl !== desiredUrl) { + window.history[replace ? "replaceState" : "pushState"]({}, "", desiredUrl); + } + updateUserUi(); +} + +function currentRmsPage() { + const path = window.location.pathname; + if (path === "/rms/user") return "user"; + if (path === "/rms/swr") return "swr"; + if (path === "/rms/hilfe") return "help"; + if (path === "/rms/plugins") return "plugins"; + if (path === "/rms/plugin-konfig") return "plugin-config"; + if (path === "/rms/providers") return "providers"; + if (path === "/rms/users") return "users"; + if (path === "/rms/freigaben") return "approvals"; + if (path === "/rms/aktivitaet") return "activity"; + if (path === "/rms/admin") return "admin"; + return "rms"; +} + +function normalizedRmsPage(page) { + if (page === "admin" && !isAdmin()) { + return "rms"; + } + if (page === "swr" && !canOperateStation()) { + return "rms"; + } + if (page === "users" && !canSeeUsersList()) { + return "rms"; + } + if (page === "plugins" && !isAdmin()) { + return "rms"; + } + if (page === "plugin-config" && !isAdmin()) { + return "rms"; + } + if (page === "providers" && !isAdmin()) { + return "rms"; + } + if (page === "approvals" && !canSeeApprovals()) { + return "rms"; + } + if (page === "activity" && !canSeeActivityLog()) { + return "rms"; + } + return page; +} + +function navigateRmsPage(page) { + if (!state.user) { + return; + } + setUserMenuOpen(false); + setLanguageMenuOpen(false); + const safePage = normalizedRmsPage(["rms", "swr", "user", "help", "plugins", "plugin-config", "providers", "users", "approvals", "activity", "admin"].includes(page) ? page : "rms"); + window.history.pushState({}, "", `${pageToPath(safePage)}${routeQueryForPage(safePage)}`); + updateUserUi(); +} + +function pageToPath(page) { + if (page === "swr") return "/rms/swr"; + if (page === "user") return "/rms/user"; + if (page === "help") return "/rms/hilfe"; + if (page === "plugins") return "/rms/plugins"; + if (page === "plugin-config") return "/rms/plugin-konfig"; + if (page === "providers") return "/rms/providers"; + if (page === "users") return "/rms/users"; + if (page === "approvals") return "/rms/freigaben"; + if (page === "activity") return "/rms/aktivitaet"; + if (page === "admin") return "/rms/admin"; + return "/rms"; +} + +function routeQueryForPage(page) { + const params = new URLSearchParams(); + if (page === "users") { + if (state.usersFilter.query) params.set("uq", state.usersFilter.query); + if (state.usersFilter.role !== "all") params.set("ur", state.usersFilter.role); + if (state.usersFilter.status !== "all") params.set("us", state.usersFilter.status); + } + if (page === "approvals") { + if (state.approvalsFilter.mode !== "open") params.set("am", state.approvalsFilter.mode); + if (state.approvalsFilter.query) params.set("aq", state.approvalsFilter.query); + if (state.approvalsFilter.status !== "all") params.set("as", state.approvalsFilter.status); + } + if (page === "activity") { + if (state.activityFilter.query) params.set("lq", state.activityFilter.query); + if (state.activityFilter.type !== "all") params.set("lt", state.activityFilter.type); + } + const query = params.toString(); + return query ? `?${query}` : ""; +} + +function updateRouteQueryForCurrentPage() { + if (!state.user) { + return; + } + const page = normalizedRmsPage(currentRmsPage()); + const next = `${pageToPath(page)}${routeQueryForPage(page)}`; + const current = `${window.location.pathname}${window.location.search}`; + if (current !== next) { + window.history.replaceState({}, "", next); + } +} + +function hydrateFilterStateFromUrl() { + const params = new URLSearchParams(window.location.search); + state.usersFilter.query = String(params.get("uq") || "").trim().toLowerCase(); + state.usersFilter.role = normalizeFilterValue(params.get("ur"), ["all", "admin", "approver", "operator"], "all"); + state.usersFilter.status = normalizeFilterValue( + params.get("us"), + ["all", "active", "pending_approval", "pending_verification", "denied"], + "all" + ); + + state.approvalsFilter.mode = normalizeFilterValue(params.get("am"), ["open", "all"], "open"); + state.approvalsFilter.query = String(params.get("aq") || "").trim().toLowerCase(); + state.approvalsFilter.status = normalizeFilterValue(params.get("as"), ["all", "pending", "approved", "rejected"], "all"); + + state.activityFilter.query = String(params.get("lq") || "").trim().toLowerCase(); + state.activityFilter.type = normalizeFilterValue( + params.get("lt"), + [ + "all", + "auth.request_access", + "station.activate.start", + "station.activate.done", + "station.activate.failed", + "station.deactivate", + "station.deactivate.timeout" + ], + "all" + ); + + syncFilterInputsFromState(); +} + +function syncFilterInputsFromState() { + if (els.usersFilterQuery) els.usersFilterQuery.value = state.usersFilter.query; + if (els.usersFilterRole) els.usersFilterRole.value = state.usersFilter.role; + if (els.usersFilterStatus) els.usersFilterStatus.value = state.usersFilter.status; + if (els.approvalsFilterQuery) els.approvalsFilterQuery.value = state.approvalsFilter.query; + if (els.approvalsFilterStatus) els.approvalsFilterStatus.value = state.approvalsFilter.status; + if (els.activityFilterQuery) els.activityFilterQuery.value = state.activityFilter.query; + if (els.activityFilterType) els.activityFilterType.value = state.activityFilter.type; +} + +function normalizeFilterValue(value, allowed, fallback) { + return allowed.includes(value) ? value : fallback; +} + +function formatRemainingUsage(totalSec) { + const sec = Math.max(0, Math.floor(Number(totalSec || 0))); + const hours = Math.floor(sec / 3600); + const minutes = Math.floor((sec % 3600) / 60); + const seconds = sec % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + } + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +function withCacheVersion(url, version) { + const rawUrl = String(url || "").trim(); + if (!rawUrl) { + return ""; + } + const rawVersion = String(version || "").trim(); + if (!rawVersion) { + return rawUrl; + } + const separator = rawUrl.includes("?") ? "&" : "?"; + return `${rawUrl}${separator}v=${encodeURIComponent(rawVersion)}`; +} + +function setUserMenuOpen(open) { + const effective = Boolean(open && state.user); + els.userMenu.hidden = !effective; + els.userMenuButton.setAttribute("aria-expanded", effective ? "true" : "false"); + els.userMenuButton.textContent = "☰"; + if (state.user) { + els.userMenuButton.setAttribute("aria-label", effective ? `Menue schliessen (${state.user.email})` : `Menue oeffnen (${state.user.email})`); + } else { + els.userMenuButton.setAttribute("aria-label", effective ? "Menue schliessen" : "Menue oeffnen"); + } +} + +function setLanguageMenuOpen(open) { + if (!els.languageMenu || !els.languageMenuButton) { + return; + } + const effective = Boolean(open); + els.languageMenu.hidden = !effective; + els.languageMenuButton.setAttribute("aria-expanded", effective ? "true" : "false"); +} + +function toggleTheme() { + const current = document.documentElement.dataset.theme || "dark"; + const next = current === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + localStorage.setItem("arcg-theme", next); + updateThemeToggleIcon(); + renderBranding(); +} + +function loadTheme() { + const saved = localStorage.getItem("arcg-theme"); + document.documentElement.dataset.theme = saved === "light" ? "light" : "dark"; + updateThemeToggleIcon(); + renderBranding(); +} + +function updateThemeToggleIcon() { + if (!els.themeToggle) { + return; + } + const theme = document.documentElement.dataset.theme || "dark"; + if (theme === "dark") { + els.themeToggle.textContent = "☀"; + els.themeToggle.setAttribute("aria-label", "Zu Light Mode wechseln"); + return; + } + els.themeToggle.textContent = "☾"; + els.themeToggle.setAttribute("aria-label", "Zu Dark Mode wechseln"); +} + +function isAdmin() { + return Boolean(state.user && state.user.role === "admin"); +} + +function canOperateStation() { + return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver" || state.user.role === "operator")); +} + +function canSeeApprovals() { + return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver")); +} + +function canSeeUsersList() { + return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver")); +} + +function canSeeActivityLog() { + return Boolean(state.user && (state.user.role === "admin" || state.user.role === "approver")); +} + +function setTokens(accessToken, refreshToken) { + state.accessToken = accessToken || ""; + state.refreshToken = refreshToken || ""; + if (state.accessToken) { + localStorage.setItem("rms-access-token", state.accessToken); + } + if (state.refreshToken) { + localStorage.setItem("rms-refresh-token", state.refreshToken); + } +} + +function clearTokens() { + state.accessToken = ""; + state.refreshToken = ""; + localStorage.removeItem("rms-access-token"); + localStorage.removeItem("rms-refresh-token"); +} + +let eventSource = null; +let eventReconnectTimer = null; +let openWebRxTxPollTimer = null; +let openWebRxTxPollInFlight = false; +let openWebRxTxPollIntervalMs = null; + +function connectEvents() { + if (!state.accessToken) { + return; + } + if (eventSource) { + eventSource.close(); + } + if (eventReconnectTimer) { + clearTimeout(eventReconnectTimer); + eventReconnectTimer = null; + } + eventSource = new EventSource(`/v1/events/stream?accessToken=${encodeURIComponent(state.accessToken)}`); + eventSource.onmessage = () => {}; + eventSource.onerror = async () => { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + const refreshed = await tryRefreshToken({ reason: "sse" }); + if (!state.user || (!state.accessToken && !refreshed)) { + return; + } + if (eventReconnectTimer) { + clearTimeout(eventReconnectTimer); + } + eventReconnectTimer = setTimeout(() => { + connectEvents(); + }, 2000); + }; + eventSource.addEventListener("station.status.changed", async () => { + await refreshStatus(); + await refreshSwrReport(); + if (canSeeActivityLog()) { + await refreshActivityLog(); + } + }); + eventSource.addEventListener("station.activation.progress", async () => { + await refreshStatus(); + }); + eventSource.addEventListener("station.activation.completed", async (event) => { + activationPending = false; + stopActivationWatch(); + let reportFromEvent = null; + try { + const payload = JSON.parse(event.data || "{}"); + if (payload && payload.swrReport && typeof payload.swrReport === "object") { + reportFromEvent = payload.swrReport; + } + } catch { + // ignore malformed payload + } + if (reportFromEvent) { + state.swrReport = reportFromEvent; + renderSwrPanels(); + } + await refreshStatus(); + await refreshSwrReport(); + const overall = state.swrReport && state.swrReport.overallStatus ? state.swrReport.overallStatus : "UNKNOWN"; + const doneText = `SWR-Check ERFOLGREICH abgeschlossen. Ergebnis: ${overall}.`; + renderMessage(els.swrSummaryMessage, doneText, false, true); + renderMessage(els.swrPageMessage, doneText, false, true); + await refreshControls(); + if (canSeeActivityLog()) { + await refreshActivityLog(); + } + }); + eventSource.addEventListener("station.activation.failed", async (event) => { + activationPending = false; + stopActivationWatch(); + await refreshStatus(); + await refreshSwrReport(); + let errorText = "Unbekannter Fehler"; + try { + const payload = JSON.parse(event.data || "{}"); + if (payload && payload.error) { + errorText = String(payload.error); + } + } catch { + // ignore parse issues + } + const failText = `SWR-Check fertig, Aktivierung fehlgeschlagen: ${errorText}`; + renderMessage(els.swrSummaryMessage, failText, true); + renderMessage(els.swrPageMessage, failText, true); + await refreshControls(); + }); + eventSource.addEventListener("swr.run.started", async () => { + await refreshStatus(); + }); + eventSource.addEventListener("swr.run.finished", async () => { + await refreshStatus(); + }); + eventSource.addEventListener("swr.report.changed", async () => { + await refreshSwrReport(); + }); + eventSource.addEventListener("plugin.provider.changed", async () => { + await refreshPlugins(); + await refreshControls(); + }); + eventSource.addEventListener("plugin.enabled.changed", async () => { + await refreshPlugins(); + await refreshControls(); + }); + eventSource.addEventListener("plugin.health.changed", async () => { + await refreshPlugins(); + }); + eventSource.addEventListener("approval.status.changed", async () => { + await refreshApprovals(); + await refreshUsers(); + if (canSeeActivityLog()) { + await refreshActivityLog(); + } + }); + eventSource.addEventListener("system.maintenance.enabled", async (event) => { + const payload = JSON.parse(event.data || "{}"); + state.system.maintenanceMode = true; + state.system.maintenanceMessage = payload.message || state.system.maintenanceMessage; + if (!isAdmin()) { + await logout(true, true); + return; + } + renderMaintenanceBanner(); + }); + eventSource.addEventListener("system.maintenance.disabled", async () => { + state.system.maintenanceMode = false; + await refreshPublicSystemStatus(); + }); + eventSource.addEventListener("branding.updated", async () => { + await refreshPublicSystemStatus(); + }); +} + +async function api(path, options = {}, triedRefresh = false) { + const headers = { + "Content-Type": "application/json" + }; + const authRequired = options.authRequired !== false; + if (authRequired && state.accessToken) { + headers.Authorization = `Bearer ${state.accessToken}`; + } + + const response = await fetch(path, { + method: options.method || "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined + }); + + const payload = await response.json().catch(() => ({})); + if (response.status === 401 && authRequired && !triedRefresh && state.refreshToken) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + return api(path, options, true); + } + } + + if (!response.ok) { + const err = new Error((payload.error && payload.error.message) || `Request failed: ${response.status}`); + err.status = response.status; + err.code = payload && payload.error ? payload.error.code : undefined; + throw err; + } + return payload; +} + +async function tryRefreshToken(options = {}) { + const reason = String(options.reason || "api"); + if (!state.refreshToken) { + return false; + } + try { + const result = await api("/v1/auth/refresh", { + method: "POST", + body: { refreshToken: state.refreshToken }, + authRequired: false + }, true); + setTokens(result.accessToken, result.refreshToken); + state.user = result.user; + updateUserUi(); + return true; + } catch { + if (reason !== "sse") { + clearTokens(); + state.user = null; + updateUserUi(); + } + return false; + } +} diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..addcbc6ffbb429cad54c14feb62647e3d469000e GIT binary patch literal 1053 zcmV+&1mgRNP)IjM8>ImSZd&jF000McNliru<^vZLC>f-_N)G@41FA_x zK~z}7#g}_*(^VA5KfkssrR(OlZVY8>_W&H*!bTa)WMeWO8A=v27RmfaqmmI6k;D)q z4}(V3U^HriGtq?c2Ly350-`fciEJoJ%jVeTYY*3TNYDYUkG1RC;~#D05C7>N?QU}a zy7zw1>wC^U2WGM|)XPDY&s?qQ`r=dfJ!ScNCK#F-yfv1s2|m2U0JZPUh574@`{cL9 zGS-nU6!JmLj%A*F z{KbZd6&bvgCe+XqWe*s#mu<|cT!f845O%)_FE)fwvUDzzicI;Cr;eTi8(e#y%+rGg z4j+Rjs^Nvlq46}JjB|9I8Or<5MM(jRnrvmqb2)Ie2exkk0fGT2Ed&Cvc788d{FTvB zp#19Ys_K6WX?M@`{b*`d-i;hh!wfihy*U* zA>r?zB4ZaqWbFLDKzNqX+4Vq{t~S0TZJznhAzFU~tp-F0#UQ*@3N)UD#M>jFCk5gk zj08F|#y%j&>Q`qhy58pCmqEzM1hYAO76%hPIMoWLlT zEAl`c4q)FfEHr{40sL|o+NOWt)%Z=|{&IFWaLwBs>~g!Bhf^U4f#CnI2%%K$^yY$# zTagH#S{e>&4;~rz+#!z&{FC7FfT9X(pi;A=TcBCj5Gkw@c^;bL*nVxozjGiWop$Z!JPYg@*jOwb`WSh13E3#Jp@eXNj#l`^PuQqWIRg0p>wGJy#3oDNUq@WOyE8E3bnYbwI0*N}9??LW}_H$%g4yd~^HHN5Xwg3@Hz7X6OshQIyH7v52` z$$$jm^NWBkl{Z@29D?g|Y?{L5)P2^zN~8mkPNeO^Vj-DlV%nBh>@|hjvMb2ux60kI zA51Ee4#=;0a=YVhp@5@vF&m{FI$0zqn^wtO&4z!qf2Dl + + + + + ARCG RemoteStation + + + + + + + +
    +
    +
    +
    + +
    + + ARCG +
    +
    +
    +

    Amateur Radio Club Graz

    +

    RemoteStation Control

    +

    LOGIN

    +
    +
    +
    + + + + + + +
    +
    + +
    + +
    + + + + +
    + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..ddbaa99 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,865 @@ +:root { + --font-display: "Montserrat", "Segoe UI", sans-serif; + --font-body: "Source Sans 3", "Trebuchet MS", sans-serif; + --radius: 20px; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.28); + --transition: 200ms ease; +} + +html[data-theme="dark"] { + --bg-0: #071623; + --bg-1: #0d2d45; + --bg-2: #194766; + --surface: rgba(8, 23, 36, 0.76); + --surface-border: rgba(164, 210, 236, 0.24); + --text: #e8f5ff; + --muted: #a4c1d4; + --primary: #30b6f7; + --primary-ink: #042032; + --danger: #f76e6e; + --ok: #69dca7; +} + +html[data-theme="light"] { + --bg-0: #f2f8ff; + --bg-1: #d5e8f8; + --bg-2: #bfd9ea; + --surface: rgba(255, 255, 255, 0.85); + --surface-border: rgba(19, 60, 92, 0.16); + --text: #0a2235; + --muted: #4f6a7e; + --primary: #0077b6; + --primary-ink: #ffffff; + --danger: #c43f3f; + --ok: #1f8f63; + --shadow: 0 14px 36px rgba(9, 33, 52, 0.16); +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-body); + color: var(--text); + background: radial-gradient(circle at 20% 12%, var(--bg-2), var(--bg-0) 42%), + linear-gradient(140deg, var(--bg-1), var(--bg-0)); +} + +.page-bg { + position: fixed; + inset: 0; + pointer-events: none; + background-image: linear-gradient(120deg, transparent 0 48%, rgba(255, 255, 255, 0.05) 48% 52%, transparent 52% 100%), + radial-gradient(circle at 86% 18%, rgba(255, 255, 255, 0.08), transparent 45%); +} + +.app-shell { + position: relative; + max-width: 1100px; + margin: 0 auto; + padding: 2rem 1.2rem 2.8rem; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1.4rem; +} + +.topbar-actions { + display: flex; + gap: 0.6rem; + align-items: center; + position: relative; +} + +.user-menu { + position: absolute; + top: calc(100% + 0.4rem); + right: 7.2rem; + min-width: 180px; + border: 1px solid var(--surface-border); + border-radius: 12px; + background: var(--surface); + backdrop-filter: blur(10px); + box-shadow: var(--shadow); + padding: 0.45rem; + display: grid; + gap: 0.35rem; + z-index: 20; +} + +.language-menu { + position: absolute; + top: calc(100% + 0.4rem); + right: 3.8rem; + min-width: 130px; + border: 1px solid var(--surface-border); + border-radius: 12px; + background: var(--surface); + backdrop-filter: blur(10px); + box-shadow: var(--shadow); + padding: 0.45rem; + z-index: 20; +} + +.language-menu select { + width: 100%; +} + +.user-menu button { + width: 100%; + text-align: left; +} + +.menu-separator { + border: 0; + border-top: 1px solid var(--surface-border); + margin: 0.15rem 0; +} + +.user-menu button.active { + border: 1px solid var(--surface-border); + background: color-mix(in srgb, var(--primary), transparent 78%); +} + +.brand { + display: flex; + gap: 1rem; + align-items: center; +} + +.brand-home-link { + display: inline-flex; + color: inherit; + text-decoration: none; +} + +.brand-mark { + font-family: var(--font-display); + font-weight: 800; + letter-spacing: 0.12em; + border: 1px solid var(--surface-border); + border-radius: 12px; + padding: 0.45rem; + background: var(--surface); + backdrop-filter: blur(8px); + width: 72px; + height: 72px; + display: grid; + place-items: center; + overflow: hidden; +} + +.brand-mark img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.brand-mark span { + font-family: var(--font-display); + font-weight: 800; + letter-spacing: 0.12em; +} + +h1, +h2 { + margin: 0; + font-family: var(--font-display); + letter-spacing: 0.01em; +} + +h3 { + margin: 0; + font-family: var(--font-display); + font-size: 1rem; +} + +h1 { + font-size: clamp(1.2rem, 1.6vw + 1rem, 2rem); +} + +h2 { + font-size: 1.4rem; +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.15em; + font-size: 0.7rem; + color: var(--muted); +} + +#pageCrumb { + display: inline-block; + margin-top: 0.35rem; + padding: 0.16rem 0.42rem; + border: 1px solid var(--surface-border); + border-radius: 999px; + letter-spacing: 0.09em; +} + +.grid-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.2rem; +} + +.view[hidden] { + display: none; +} + +#rmsView { + display: grid; + gap: 1rem; +} + +.page-enter { + animation: fade-up 260ms ease; +} + +.page-overview { + padding: 0.85rem 1rem; +} + +.mobile-nav { + position: fixed; + left: 0.7rem; + right: 0.7rem; + bottom: 0.7rem; + display: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; + padding: 0.5rem; + border: 1px solid var(--surface-border); + border-radius: 14px; + background: color-mix(in srgb, var(--surface), transparent 5%); + backdrop-filter: blur(12px); + box-shadow: var(--shadow); + z-index: 30; +} + +.mobile-nav.admin-visible { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.mobile-nav.swr-visible { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.mobile-nav.approvals-visible { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.mobile-nav.users-visible { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.mobile-nav.activity-visible { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.mobile-nav.admin-visible.approvals-visible { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.mobile-nav.users-visible.approvals-visible, +.mobile-nav.users-visible.admin-visible, +.mobile-nav.users-visible.activity-visible, +.mobile-nav.users-visible.swr-visible, +.mobile-nav.approvals-visible.activity-visible, +.mobile-nav.admin-visible.activity-visible, +.mobile-nav.swr-visible.activity-visible, +.mobile-nav.swr-visible.admin-visible, +.mobile-nav.swr-visible.approvals-visible { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.mobile-nav.users-visible.admin-visible.approvals-visible { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.mobile-nav.swr-visible.users-visible.admin-visible.approvals-visible, +.mobile-nav.swr-visible.users-visible.admin-visible.activity-visible, +.mobile-nav.swr-visible.users-visible.approvals-visible.activity-visible, +.mobile-nav.swr-visible.admin-visible.approvals-visible.activity-visible { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.mobile-nav.users-visible.admin-visible.approvals-visible.activity-visible { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.mobile-nav.swr-visible.users-visible.admin-visible.approvals-visible.activity-visible { + grid-template-columns: repeat(8, minmax(0, 1fr)); +} + +.mobile-nav button.active { + border: 1px solid var(--surface-border); + background: color-mix(in srgb, var(--primary), transparent 78%); +} + +.actions .ghost-btn.active { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary), transparent 84%); +} + +.pill.approval-pending { + color: #5b3d00; + background: #ffe9b8; + border: 1px solid #f0bf63; +} + +.pill.approval-approved { + color: #0e4f2f; + background: #d9f5e6; + border: 1px solid #81d3a8; +} + +.pill.approval-rejected { + color: #6a1212; + background: #ffdede; + border: 1px solid #ee9696; +} + +.swr-band-image { + width: 100%; + max-width: 760px; + border-radius: 10px; + border: 1px solid var(--surface-border); + display: block; +} + +.auth-view { + display: grid; + place-items: center; + min-height: min(72vh, 760px); +} + +.compact-field { + min-width: 9.5rem; +} + +.compact-field input, +.compact-field select { + min-width: 9.5rem; +} + +.login-card { + width: min(100%, 560px); +} + +.card { + border-radius: var(--radius); + border: 1px solid var(--surface-border); + background: var(--surface); + backdrop-filter: blur(14px); + box-shadow: var(--shadow); + padding: 1.2rem; +} + +.muted { + margin-top: 0.45rem; + color: var(--muted); +} + +.stack { + margin-top: 1rem; + display: grid; + gap: 0.8rem; +} + +.field { + display: grid; + gap: 0.35rem; +} + +.field span { + color: var(--muted); +} + +input { + border: 1px solid var(--surface-border); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-radius: 12px; + padding: 0.7rem 0.8rem; + font: inherit; +} + +select { + border: 1px solid var(--surface-border); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-radius: 12px; + padding: 0.7rem 0.8rem; + font: inherit; +} + +button { + border: none; + border-radius: 12px; + background: var(--primary); + color: var(--primary-ink); + padding: 0.66rem 0.9rem; + font-family: var(--font-display); + font-weight: 700; + cursor: pointer; + transition: transform var(--transition), opacity var(--transition), filter var(--transition); +} + +button:hover { + transform: translateY(-1px); + filter: brightness(1.05); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.ghost-btn { + background: transparent; + border: 1px solid var(--surface-border); + color: var(--text); +} + +.primary-btn { + background: var(--primary); + border-color: transparent; + color: var(--primary-ink); +} + +.danger { + background: var(--danger); + color: #fff; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin-top: 0.5rem; +} + +.link-btn { + border: none; + border-radius: 12px; + padding: 0.66rem 0.9rem; + font-family: var(--font-display); + font-weight: 700; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform var(--transition), opacity var(--transition), filter var(--transition); +} + +.link-btn:hover { + transform: translateY(-1px); + filter: brightness(1.05); +} + +.progress-wrap, +.links-wrap { + margin-top: 0.9rem; + border: 1px solid var(--surface-border); + border-radius: 14px; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.04); +} + +.controls-grid { + margin-top: 0.7rem; + display: grid; + gap: 0.45rem; +} + +.rotor-layout { + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + align-items: center; + justify-content: space-between; +} + +.rotor-main { + flex: 1 1 360px; + min-width: 260px; +} + +.rotor-compass { + position: relative; + width: 170px; + height: 170px; + border-radius: 999px; + border: 1px solid var(--surface-border); + background: radial-gradient(circle at 35% 30%, rgba(255,255,255,0.1), rgba(15,23,42,0.88) 65%); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05); + flex: 0 0 auto; +} + +.rotor-mark { + position: absolute; + color: #cbd5e1; + font-size: 12px; + font-weight: 700; +} + +.rotor-mark-n { + top: 6px; + left: 50%; + transform: translateX(-50%); +} + +.rotor-mark-e { + top: 50%; + right: 6px; + transform: translateY(-50%); +} + +.rotor-mark-s { + bottom: 6px; + left: 50%; + transform: translateX(-50%); +} + +.rotor-mark-w { + top: 50%; + left: 6px; + transform: translateY(-50%); +} + +.rotor-arrow { + position: absolute; + left: 50%; + top: 50%; + width: 5px; + height: 66px; + border-radius: 5px; + transform-origin: 50% 100%; + transform: translate(-50%, -100%) rotate(0deg); + transition: transform 220ms ease-out; + background: linear-gradient(180deg, #ef4444 0%, #ef4444 56%, #334155 56%, #334155 100%); +} + +.rotor-center { + position: absolute; + left: 50%; + top: 50%; + width: 10px; + height: 10px; + border-radius: 999px; + transform: translate(-50%, -50%); + background: #e5e7eb; +} + +.controls-inline { + margin: 0; +} + +#rotorTarget { + width: min(220px, 100%); +} + +.progress-head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9rem; +} + +.progress-track { + margin-top: 0.45rem; + width: 100%; + height: 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; +} + +.progress-fill { + width: 0; + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, color-mix(in srgb, var(--primary), #fff 6%), var(--ok)); + transition: width 450ms ease; +} + +.section-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.swr-page-head { + align-items: flex-start; +} + +.swr-page-actions { + display: grid; + justify-items: end; + gap: 0.35rem; +} + +.swr-page-head-message { + margin: 0; + min-height: 1.1rem; + text-align: right; + max-width: 34ch; +} + +.pill { + font-size: 0.78rem; + padding: 0.34rem 0.58rem; + border-radius: 999px; + border: 1px solid var(--surface-border); + color: var(--muted); +} + +.pill.ok { + color: var(--ok); + border-color: color-mix(in srgb, var(--ok), transparent 55%); + background: color-mix(in srgb, var(--ok), transparent 88%); +} + +.pill.offline { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger), transparent 55%); + background: color-mix(in srgb, var(--danger), transparent 88%); +} + +.status-grid { + margin: 0.9rem 0 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; +} + +.admin-card { + grid-column: 1 / -1; +} + +.audit-log { + margin: 0.8rem 0 0; + border-radius: 12px; + border: 1px solid var(--surface-border); + background: rgba(0, 0, 0, 0.12); + color: var(--muted); + padding: 0.7rem; + min-height: 120px; + max-height: 250px; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8rem; +} + +.separator { + border: 0; + border-top: 1px solid var(--surface-border); + margin: 1rem 0; +} + +.plugin-block { + border: 1px solid var(--surface-border); + border-radius: 12px; + padding: 0.7rem; + background: rgba(255, 255, 255, 0.03); +} + +.swr-summary-list { + margin-top: 0.6rem; + display: grid; + gap: 0.4rem; +} + +.swr-summary-row { + border: 1px solid var(--surface-border); + border-radius: 10px; + padding: 0.42rem 0.6rem; + background: rgba(255, 255, 255, 0.03); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.reservation-row { + align-items: flex-start; +} + +.reservation-row-active { + border-color: color-mix(in srgb, var(--ok), transparent 55%); + background: color-mix(in srgb, var(--ok), transparent 90%); +} + +.swr-summary-link { + width: 100%; + text-align: left; + color: inherit; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; +} + +.swr-summary-link:hover { + border-color: color-mix(in srgb, var(--primary), var(--surface-border) 45%); +} + +.swr-summary-link:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary), #fff 20%); + outline-offset: 1px; +} + +.matrix-row { + border: 1px solid var(--surface-border); + border-radius: 12px; + padding: 0.6rem; + background: rgba(255, 255, 255, 0.02); +} + +.health-badge { + font-size: 0.74rem; + border: 1px solid var(--surface-border); + border-radius: 999px; + padding: 0.2rem 0.5rem; +} + +.health-healthy { + color: var(--ok); +} + +.health-degraded, +.health-failing { + color: var(--danger); +} + +.schema-form { + display: grid; + gap: 0.45rem; + margin-top: 0.5rem; +} + +.plugin-eq-builder { + border: 1px solid var(--surface-border); + border-radius: 10px; + padding: 0.65rem; + display: grid; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.02); +} + +.plugin-eq-row { + display: grid; + grid-template-columns: 130px 1fr 88px; + gap: 0.5rem; + align-items: center; +} + +.plugin-eq-checkbox { + display: flex; + align-items: center; + gap: 0.5rem; +} + +dt { + color: var(--muted); + font-size: 0.85rem; +} + +dd { + margin: 0.2rem 0 0; + font-size: 1.03rem; + font-weight: 600; +} + +.message { + min-height: 1.2rem; + margin: 0.5rem 0 0; + color: var(--muted); +} + +.message.error { + color: var(--danger); +} + +.message.ok { + color: var(--ok); +} + +.reveal { + animation: fade-up 500ms ease; +} + +.stagger { + opacity: 0; + animation: fade-up 600ms ease forwards; +} + +.stagger:nth-child(2) { + animation-delay: 90ms; +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 840px) { + .grid-layout { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + align-items: flex-start; + } + + .topbar-actions { + width: 100%; + } + + .actions button { + width: 100%; + } + + .matrix-row .actions { + flex-direction: column; + align-items: stretch; + } + + .health-badge { + width: 100%; + } + + .plugin-block .actions button { + width: 100%; + } + + .app-shell { + padding-bottom: 6rem; + } + + .mobile-nav { + display: grid; + } +} diff --git a/public/uploads/logo-dark.png b/public/uploads/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3fb348792a9ce4b47d148570c731a5072156c232 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$fBwreFf%hTyqkV6 Q1t`qm>FVdQ&MBb@0Ez()hyVZp literal 0 HcmV?d00001 diff --git a/public/uploads/logo-dark.svg b/public/uploads/logo-dark.svg new file mode 100644 index 0000000..663cde0 --- /dev/null +++ b/public/uploads/logo-dark.svg @@ -0,0 +1,204 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + A + R + C + G + + AmateurRadioClubGraz + + diff --git a/public/uploads/logo-light.svg b/public/uploads/logo-light.svg new file mode 100644 index 0000000..fc08741 --- /dev/null +++ b/public/uploads/logo-light.svg @@ -0,0 +1,204 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + A + R + C + G + + AmateurRadioClubGraz + + diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..3d9c7b1 --- /dev/null +++ b/server/index.js @@ -0,0 +1,7530 @@ +const http = require("http"); +const fs = require("fs"); +const fsp = require("fs/promises"); +const path = require("path"); +const crypto = require("crypto"); +const { spawn } = require("child_process"); +const { WebSocketServer } = require("ws"); +const { createStorageProvider } = require("./storage"); + +const DEBUG_REMOTE_INTERFACE_ENABLED = true; +const DEBUG_REMOTE_DEFAULT_TOKEN = "dbg_8f4e5c9b71a64f2fa3e7c1d6b5a9e2f0_owrx"; + +const rootDir = path.resolve(__dirname, ".."); +const initialEnvKeys = new Set(Object.keys(process.env)); +if (!initialEnvKeys.has("DATA_DIR")) { + loadDotEnv(path.join(rootDir, ".env")); +} +const defaultDataDir = resolveDefaultDataDir(rootDir); + +const config = { + port: Number(process.env.PORT || 8080), + dataDir: path.resolve(rootDir, process.env.DATA_DIR || defaultDataDir), + brandingUploadsDir: path.resolve(rootDir, process.env.BRANDING_UPLOADS_DIR || path.join(process.env.DATA_DIR || defaultDataDir, "uploads")), + vswrImagesDir: path.resolve(rootDir, process.env.VSWR_IMAGES_DIR_PATH || path.join(process.env.DATA_DIR || defaultDataDir, "vswr", "images")), + storageProvider: String(process.env.STORAGE_PROVIDER || "json").trim(), + storageModulePath: String(process.env.STORAGE_MODULE_PATH || "").trim(), + storageSqlitePath: String(process.env.STORAGE_SQLITE_PATH || "").trim(), + pluginDir: path.resolve(rootDir, process.env.PLUGIN_DIR || "./plugins"), + stationName: process.env.STATION_NAME || "ARCG Stradnerkogel", + adminEmails: new Set( + String(process.env.ADMIN_EMAILS || "") + .split(",") + .map((value) => normalizeEmail(value)) + .filter(Boolean) + ), + accessTokenTtlSec: Number(process.env.ACCESS_TOKEN_TTL_SEC || 10800), + refreshTokenTtlSec: Number(process.env.REFRESH_TOKEN_TTL_SEC || 60 * 60 * 24 * 14), + jwtSecret: process.env.JWT_SECRET || "change-me-in-production", + jwtIssuer: process.env.JWT_ISSUER || "rms-arcg", + jwtAudience: process.env.JWT_AUDIENCE || "rms-clients", + authRateWindowMs: Number(process.env.AUTH_RATE_WINDOW_MS || 10 * 60 * 1000), + authRateLimit: Number(process.env.AUTH_RATE_LIMIT || 25), + actionRateWindowMs: Number(process.env.ACTION_RATE_WINDOW_MS || 60 * 1000), + actionRateLimit: Number(process.env.ACTION_RATE_LIMIT || 30), + openWebRxPttLiveFreqToleranceHz: Number(process.env.OPENWEBRX_PTT_LIVE_FREQ_TOLERANCE_HZ || 50), + openWebRxLiveStateTtlMs: Number(process.env.OPENWEBRX_LIVE_STATE_TTL_MS || 10000), + openWebRxPttBlockedBandConfigIdsRaw: String(process.env.OPENWEBRX_PTT_BLOCKED_BAND_CONFIG_IDS || "27,6,24"), + openWebRxTxPollMs: Number(process.env.OPENWEBRX_TX_POLL_MS || 3000), + txAudioEnabled: String(process.env.TX_AUDIO_ENABLED || "true").trim().toLowerCase(), + txAudioAlsaDevice: String(process.env.TX_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0").trim(), + txAudioInputMime: String(process.env.TX_AUDIO_INPUT_MIME || "webm").trim().toLowerCase(), + txAudioStopOnDisconnect: String(process.env.TX_AUDIO_STOP_ON_DISCONNECT || "true").trim().toLowerCase(), + txAudioChunkMs: Number(process.env.TX_AUDIO_CHUNK_MS || 100), + txAudioSessionTimeoutMs: Number(process.env.TX_AUDIO_SESSION_TIMEOUT_MS || 120000), + txAudioFfmpegPath: String(process.env.TX_AUDIO_FFMPEG_PATH || "").trim(), + txAudioFfmpegExtraArgs: String(process.env.TX_AUDIO_FFMPEG_EXTRA_ARGS || "").trim(), + openWebRxPttCommandsEnabled: String(process.env.OPENWEBRX_PTT_COMMANDS_ENABLED || "false").trim().toLowerCase(), + openWebRxPttDevice: String(process.env.OPENWEBRX_PTT_DEVICE || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim(), + openWebRxPttTimeoutMs: Number(process.env.OPENWEBRX_PTT_TIMEOUT_MS || 5000), + openWebRxPttDownCmd: String(process.env.OPENWEBRX_PTT_DOWN_CMD || "").trim(), + openWebRxPttUpCmd: String(process.env.OPENWEBRX_PTT_UP_CMD || "").trim(), + rotorDevice: String(process.env.RMS_ROTOR_DEV || process.env.RMS_FTDI_DEV || "/dev/rms-ftdi-uart").trim(), + rotorModel: Number(process.env.ROTOR_ROTCTL_MODEL || 902), + rotorSetEnabled: String(process.env.ROTOR_SET_ENABLED || "true").trim().toLowerCase(), + rotorGetTimeoutMs: Number(process.env.ROTOR_GET_TIMEOUT_MS || 10000), + rotorSetTimeoutMs: Number(process.env.ROTOR_SET_TIMEOUT_MS || 20000), + rotorMonitorMaxMs: Number(process.env.ROTOR_MONITOR_MAX_MS || 120000), + rotorStatusRetryCount: Number(process.env.ROTOR_STATUS_RETRY_COUNT || 6), + rotorStatusRetryDelayMs: Number(process.env.ROTOR_STATUS_RETRY_DELAY_MS || 400), + rotorPostSetStatusTimeoutMs: Number(process.env.ROTOR_POST_SET_STATUS_TIMEOUT_MS || 5000), + autoDisableTxBeforeActivation: String(process.env.AUTO_DISABLE_TX_BEFORE_ACTIVATION || "").trim().toLowerCase(), + stationMaxUsageSec: Number(process.env.STATION_MAX_USAGE_SEC || 3600), + primaryEmailDomain: String(process.env.PRIMARY_EMAIL_DOMAIN || "arcg.at").toLowerCase(), + publicBaseUrl: String(process.env.PUBLIC_BASE_URL || ""), + approverEmails: new Set( + String(process.env.APPROVER_EMAILS || "") + .split(",") + .map((value) => normalizeEmail(value)) + .filter(Boolean) + ) +}; +config.execMode = resolveExecMode(process.env.RMS_EXEC_MODE, process.env.npm_lifecycle_event); +config.openWebRxPttBlockedBandConfigIds = parseBandConfigIdList(config.openWebRxPttBlockedBandConfigIdsRaw); +config.simulateHardware = config.execMode !== "prod"; +if (!config.autoDisableTxBeforeActivation) { + config.autoDisableTxBeforeActivation = config.execMode === "prod" ? "true" : "false"; +} +config.rootDir = rootDir; +applyRuntimePathDefaults(config, initialEnvKeys); + +function resolveDefaultDataDir(appRootDir) { + const normalized = path.resolve(appRootDir); + const marker = `${path.sep}releases${path.sep}`; + const markerIdx = normalized.indexOf(marker); + if (markerIdx !== -1) { + const deployBase = normalized.slice(0, markerIdx); + return path.join(deployBase, "shared", "data"); + } + return "./data"; +} + +function parseBandConfigIdList(raw) { + return String(raw || "") + .split(",") + .map((value) => String(value || "").trim()) + .filter(Boolean); +} + +function isPttBlockedForBandConfigId(bandConfigId) { + const selected = String(bandConfigId || "").trim(); + if (!selected) { + return false; + } + return Array.isArray(config.openWebRxPttBlockedBandConfigIds) + && config.openWebRxPttBlockedBandConfigIds.includes(selected); +} + +function applyRuntimePathDefaults(appConfig, envKeysBeforeDotEnv) { + if (!envKeysBeforeDotEnv.has("DATA_DIR")) { + return; + } + const defaults = { + TX_STATE_PATH: "tx-state.json", + OPENWEBRX_BAND_STATE_PATH: "openwebrx-band-state.json", + OPENWEBRX_ACCESS_POLICY_FILE: "openwebrx-access-policy.txt", + OPENWEBRX_PERSISTENT_USERS_FILE: "openwebrx-persistent-users.txt", + BRANDING_UPLOADS_DIR: "uploads" + }; + for (const [key, relative] of Object.entries(defaults)) { + if (!envKeysBeforeDotEnv.has(key)) { + process.env[key] = path.join(appConfig.dataDir, relative); + } + } +} + +const files = { + users: path.join(config.dataDir, "users.json"), + station: path.join(config.dataDir, "station-state.json"), + audit: path.join(config.dataDir, "audit.log"), + auth: path.join(config.dataDir, "auth-state.json"), + plugins: path.join(config.dataDir, "plugin-state.json"), + approvals: path.join(config.dataDir, "approval-requests.json"), + system: path.join(config.dataDir, "system-state.json"), + mailOutbox: path.join(config.dataDir, "mail-outbox.log") +}; + +const runtime = { + users: [], + station: null, + authState: { + refreshTokens: [], + tokenVersionByUser: {}, + emailTokens: [], + otpChallenges: [] + }, + approvalRequests: [], + systemState: { + maintenanceMode: false, + maintenanceMessage: "Wartungsmodus aktiv. Login ist derzeit deaktiviert.", + branding: { + logoLightUrl: null, + logoDarkUrl: null + }, + updatedAt: null + }, + pluginState: { + enabled: {}, + providers: {}, + settings: {} + }, + plugins: new Map(), + pluginHealth: {}, + rateBuckets: new Map(), + jobs: new Map(), + currentActivationJobId: null, + swrRun: { + running: false, + token: null, + source: null, + phase: null, + startedAt: null, + expectedDurationMs: 0, + startedBy: null, + lastStatus: null, + lastError: null, + lastFinishedAt: null + }, + txFollowRoute: null, + openWebRxAntennaRoute: null, + openWebRxSession: { + activeOwnerUserId: null, + expiresAtMs: 0, + lastEnsureSdrAtMs: 0 + }, + openWebRxLiveStateByUserId: {}, + rotor: { + azimuth: null, + rawAzimuth: null, + moving: false, + targetAzimuth: null, + pendingTargetAzimuth: null, + pendingSource: null, + pendingRequestedByUserId: null, + queueWorkerActive: false, + statusRefreshInFlight: false, + phase: "idle", + commandInProgress: false, + commandStartedAt: null, + lastChangeAt: null, + min: 0, + max: 360, + updatedAt: null, + lastResult: null, + lastError: null + }, + txAudio: { + wsServer: null, + ffmpeg: null, + clients: new Set(), + running: false, + startedAt: null, + ownerUserId: null, + alsaDevice: null, + stopRequested: false, + lastError: null, + lastExit: null, + idleTimer: null + }, + pttActive: false, + sseClients: new Set(), + eventSeq: 0 +}; + +let mutex = Promise.resolve(); +let storage = null; + +start().catch((error) => { + console.error("Startup failed", error); + process.exit(1); +}); + +async function start() { + storage = await createStorageProvider(config); + await ensureDataFiles(); + runtime.users = await readJson(files.users, []); + runtime.station = await readJson(files.station, buildDefaultStationState()); + runtime.authState = await readJson(files.auth, runtime.authState); + runtime.pluginState = await readJson(files.plugins, runtime.pluginState); + await migratePluginSettingsDefaults(); + runtime.approvalRequests = await readJson(files.approvals, []); + runtime.systemState = normalizeSystemState(await readJson(files.system, runtime.systemState)); + await reconcileBrandingFromUploads(); + await loadPlugins(); + await applyAdminRoles(); + await reconcileStationLeaseOnStartup(); + + const server = http.createServer(routeRequest); + initTxAudioWebSocket(server); + server.listen(config.port, () => { + console.log(`RMS API listening on http://localhost:${config.port}`); + }); + + setInterval(() => { + broadcastEvent("system.heartbeat", { ok: true }); + }, 20000).unref(); + + setInterval(() => { + refreshPluginHealth().catch(() => {}); + }, 30000).unref(); + + setInterval(() => { + refreshRotorStatusCache().catch(() => {}); + }, 2000).unref(); + + setInterval(() => { + enforceStationLeaseTimeout().catch(() => {}); + }, 5000).unref(); + + setInterval(() => { + enforceOpenWebRxPttConnectionWatchdog().catch(() => {}); + }, 2000).unref(); +} + +async function routeRequest(req, res) { + try { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const method = req.method || "GET"; + + if (method === "GET" && url.pathname === "/api/health") { + return sendJson(res, 200, { + ok: true, + stationName: config.stationName, + maintenanceMode: Boolean(runtime.systemState.maintenanceMode), + maintenanceMessage: runtime.systemState.maintenanceMessage + }); + } + + if (method === "GET" && url.pathname === "/v1/public/system") { + return sendJson(res, 200, { + maintenanceMode: Boolean(runtime.systemState.maintenanceMode), + maintenanceMessage: runtime.systemState.maintenanceMessage, + branding: runtime.systemState.branding, + updatedAt: runtime.systemState.updatedAt + }); + } + + if (method === "GET" && url.pathname === "/v1/public/auth-methods") { + return sendJson(res, 200, { methods: listPublicAuthMethods() }); + } + + if (method === "POST" && (url.pathname === "/v1/auth/request-access" || url.pathname === "/v1/auth/register" || url.pathname === "/v1/auth/login")) { + if (!enforceRateLimit(req, res, `auth:request-access:${clientIp(req)}`, config.authRateLimit, config.authRateWindowMs)) { + return; + } + const body = await readJsonBody(req); + return handleRequestAccess(req, res, body); + } + + if (method === "POST" && url.pathname === "/v1/auth/verify-email") { + const body = await readJsonBody(req); + return handleVerifyEmail(req, res, body); + } + + if (method === "POST" && url.pathname === "/v1/auth/request-approval") { + const body = await readJsonBody(req); + return handleRequestApproval(req, res, body); + } + + if (method === "POST" && url.pathname === "/v1/auth/refresh") { + if (!enforceRateLimit(req, res, `auth:refresh:${clientIp(req)}`, config.authRateLimit, config.authRateWindowMs)) { + return; + } + const body = await readJsonBody(req); + return handleRefresh(req, res, body); + } + + if (method === "POST" && url.pathname === "/v1/auth/logout") { + const body = await readJsonBody(req); + return handleLogout(res, body); + } + + if (method === "POST" && url.pathname === "/v1/auth/logout-all") { + const auth = requireAuth(req, res); + if (!auth) return; + return handleLogoutAll(res, auth.user); + } + + if (method === "GET" && url.pathname === "/v1/me") { + const auth = requireAuth(req, res); + if (!auth) return; + return sendJson(res, 200, { user: sanitizeUser(auth.user), capabilities: userCapabilities(auth.user) }); + } + + if (method === "GET" && url.pathname === "/v1/auth/session/diag") { + const auth = requireAuth(req, res); + if (!auth) return; + return handleAuthSessionDiag(res, auth.user, auth.payload); + } + + if (method === "GET" && url.pathname === "/v1/help/content") { + const auth = requireAuth(req, res); + if (!auth) return; + return handleHelpContent(res, auth.user); + } + + if (method === "PUT" && url.pathname === "/v1/me/auth-method") { + const auth = requireAuth(req, res); + if (!auth) return; + const body = await readJsonBody(req); + return handleSelfAuthMethodUpdate(res, auth.user, body); + } + + if (method === "PUT" && url.pathname === "/v1/me/language") { + const auth = requireAuth(req, res); + if (!auth) return; + const body = await readJsonBody(req); + return handleSelfLanguageUpdate(res, auth.user, body); + } + + if (method === "GET" && url.pathname === "/v1/station/status") { + const auth = requireAuth(req, res); + if (!auth) return; + return sendJson(res, 200, buildStationStatusView()); + } + + if (method === "GET" && url.pathname === "/v1/swr/report") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + const report = await buildSwrReportView(auth.user); + return sendJson(res, 200, report); + } + + if (method === "POST" && url.pathname === "/v1/swr/run-check") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + if (denyIfStationOwnedByOther(res, auth.user, "SWR-Check")) { + return; + } + try { + const { result, report } = await runSwrCheckAndBuildReport(auth.user); + await appendAudit("vswr.run.manual", auth.user, { ok: true }); + return sendJson(res, 200, { ok: true, result, report }); + } catch (error) { + await appendAudit("vswr.run.manual", auth.user, { ok: false, error: String(error && error.message ? error.message : error) }); + if (error && error.code === "TX_SWITCH_LOCK") { + return sendError(res, 409, "tx.switch_locked", String(error.message || error), error.details || null); + } + if (error && error.code === "OPENWEBRX_SESSION_ACTIVE") { + return sendError(res, 409, "vswr.unsafe_openwebrx_active", String(error.message || error)); + } + if (error && error.code === "VSWR_WHILE_STATION_ACTIVE") { + return sendError(res, 409, "vswr.unsafe_station_active", String(error.message || error)); + } + if (error && error.code === "SWR_ALREADY_RUNNING") { + return sendError(res, 409, "swr.running", String(error.message || error)); + } + return sendError(res, 500, "vswr.run.failed", String(error && error.message ? error.message : error)); + } + } + + if (method === "POST" && url.pathname === "/v1/station/activation-jobs") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + if (denyIfStationOwnedByOther(res, auth.user, "Aktivierung")) { + return; + } + if (!enforceRateLimit(req, res, `station:activate:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleActivationStart(res, auth.user); + } + + if (method === "GET" && url.pathname.startsWith("/v1/station/activation-jobs/")) { + const auth = requireAuth(req, res); + if (!auth) return; + const jobId = decodeURIComponent(url.pathname.slice("/v1/station/activation-jobs/".length)); + const job = runtime.jobs.get(jobId); + if (!job) { + return sendError(res, 404, "station.job.not_found", "Job nicht gefunden"); + } + return sendJson(res, 200, { job }); + } + + if (method === "POST" && url.pathname === "/v1/station/release") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "Freigabe", { allowAdmin: true })) { + return; + } + if (!enforceRateLimit(req, res, `station:release:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleStationRelease(res, auth.user); + } + + if (method === "POST" && url.pathname === "/v1/station/reservations/next") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + if (!enforceRateLimit(req, res, `station:reserve-next:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleReserveNextSlot(res, auth.user); + } + + if (method === "DELETE" && url.pathname === "/v1/station/reservations/next") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + if (!enforceRateLimit(req, res, `station:reserve-delete:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleDeleteOwnReservation(res, auth.user); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/session") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Session")) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:session:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleOpenWebRxSessionIssue(res, auth.user); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/tx/enable") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX TX")) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:tx-enable:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleOpenWebRxTxEnable(res, auth.user); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/tx/disable") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX TX")) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:tx-disable:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleOpenWebRxTxDisable(res, auth.user); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/ptt/down") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX PTT")) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:ptt-down:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleOpenWebRxPttDown(res, auth.user); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/ptt/up") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX PTT")) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:ptt-up:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleOpenWebRxPttUp(res, auth.user); + } + + if (method === "GET" && url.pathname === "/v1/openwebrx/tx/status") { + const auth = requireAuth(req, res); + if (!auth) return; + return handleOpenWebRxTxStatus(res, auth.user); + } + + if (method === "GET" && url.pathname === "/v1/openwebrx/rotor/status") { + const auth = requireAuth(req, res); + if (!auth) return; + return handleOpenWebRxRotorStatus(res, auth.user); + } + + if (method === "GET" && url.pathname === "/v1/openwebrx/bands") { + const auth = requireAuth(req, res); + if (!auth) return; + return handleOpenWebRxBands(res, auth.user); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/bands/select") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Bandwechsel")) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:band-select:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + const body = await readJsonBody(req); + return handleOpenWebRxBandSelect(res, auth.user, body); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/rotor/set") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Rotor")) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:rotor-set:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + const body = await readJsonBody(req); + return handleOpenWebRxRotorSet(res, auth.user, body, "api"); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/session/close") { + const auth = requireAuth(req, res); + if (!auth) return; + if (denyIfStationOwnedByOther(res, auth.user, "OpenWebRX Session", { allowAdmin: true })) { + return; + } + if (!enforceRateLimit(req, res, `openwebrx:session-close:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleOpenWebRxSessionClose(res, auth.user); + } + + if (method === "GET" && url.pathname === "/v1/openwebrx/plugin/state") { + return handleOpenWebRxPluginState(req, res, url); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/tx/enable") { + return handleOpenWebRxPluginTx(req, res, url, true); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/tx/disable") { + return handleOpenWebRxPluginTx(req, res, url, false); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/ptt/down") { + return handleOpenWebRxPluginPtt(req, res, url, true); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/ptt/up") { + return handleOpenWebRxPluginPtt(req, res, url, false); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/live-state") { + const body = await readJsonBody(req); + return handleOpenWebRxPluginLiveState(req, res, url, body); + } + + if (method === "GET" && url.pathname === "/v1/openwebrx/plugin/audio/status") { + return handleOpenWebRxPluginAudioStatus(req, res, url); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/audio/connect") { + return handleOpenWebRxPluginAudioConnect(req, res, url); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/audio/disconnect") { + return handleOpenWebRxPluginAudioDisconnect(req, res, url); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/bands/select") { + const body = await readJsonBody(req); + return handleOpenWebRxPluginBandSelect(req, res, url, body); + } + + if (method === "POST" && url.pathname === "/v1/openwebrx/plugin/rotor/set") { + const body = await readJsonBody(req); + return handleOpenWebRxPluginRotorSet(req, res, url, body); + } + + if (method === "GET" && url.pathname === "/v1/openwebrx/authorize") { + return handleOpenWebRxAuthorize(req, res, url); + } + + if (method === "POST" && url.pathname === "/v1/debug/collect/owrx") { + return handleDebugCollect(req, res, url, "owrx"); + } + + if (method === "GET" && url.pathname === "/v1/debug/logs/owrx") { + return handleDebugLogs(req, res, url, "owrx"); + } + + if (method === "POST" && url.pathname === "/v1/debug/clear/owrx") { + return handleDebugClear(req, res, url, "owrx"); + } + + if (method === "GET" && url.pathname === "/v1/debug/snapshot/owrx") { + return handleDebugSnapshot(req, res, url, "owrx"); + } + + if (method === "POST" && url.pathname === "/v1/debug/collect/usb") { + return handleDebugCollect(req, res, url, "usb"); + } + + if (method === "GET" && url.pathname === "/v1/debug/logs/usb") { + return handleDebugLogs(req, res, url, "usb"); + } + + if (method === "POST" && url.pathname === "/v1/debug/clear/usb") { + return handleDebugClear(req, res, url, "usb"); + } + + if (method === "GET" && url.pathname === "/v1/debug/snapshot/usb") { + return handleDebugSnapshot(req, res, url, "usb"); + } + + if (method === "POST" && url.pathname === "/v1/debug/collect/alsa") { + return handleDebugCollect(req, res, url, "alsa"); + } + + if (method === "GET" && url.pathname === "/v1/debug/logs/alsa") { + return handleDebugLogs(req, res, url, "alsa"); + } + + if (method === "POST" && url.pathname === "/v1/debug/clear/alsa") { + return handleDebugClear(req, res, url, "alsa"); + } + + if (method === "GET" && url.pathname === "/v1/debug/snapshot/alsa") { + return handleDebugSnapshot(req, res, url, "alsa"); + } + + if (method === "POST" && url.pathname === "/v1/debug/collect/soapy") { + return handleDebugCollect(req, res, url, "soapy"); + } + + if (method === "GET" && url.pathname === "/v1/debug/logs/soapy") { + return handleDebugLogs(req, res, url, "soapy"); + } + + if (method === "POST" && url.pathname === "/v1/debug/clear/soapy") { + return handleDebugClear(req, res, url, "soapy"); + } + + if (method === "GET" && url.pathname === "/v1/debug/snapshot/soapy") { + return handleDebugSnapshot(req, res, url, "soapy"); + } + + if (method === "GET" && url.pathname === "/v1/debug/which") { + return handleDebugWhich(req, res, url); + } + + if (method === "GET" && url.pathname === "/v1/ui/controls") { + const auth = requireAuth(req, res); + if (!auth) return; + return sendJson(res, 200, { controls: await buildUiControls(auth.user) }); + } + + if (method === "POST" && url.pathname.startsWith("/v1/ui/controls/") && url.pathname.includes("/actions/")) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!enforceRateLimit(req, res, `ui:action:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + const body = await readJsonBody(req); + return handleUiControlAction(req, res, auth.user, url.pathname, body); + } + + if (method === "GET" && url.pathname === "/v1/plugins") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + return sendJson(res, 200, { + plugins: await listPlugins(), + providers: runtime.pluginState.providers, + capabilities: await buildCapabilitiesMatrix() + }); + } + + if (method === "GET" && url.pathname === "/v1/admin/capabilities") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + return sendJson(res, 200, { capabilities: await buildCapabilitiesMatrix() }); + } + + if (method === "GET" && url.pathname === "/v1/admin/users") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin", "approver"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + return sendJson(res, 200, { users: runtime.users.map((entry) => sanitizeUser(entry)) }); + } + + if (method === "PUT" && url.pathname.match(/^\/v1\/admin\/users\/[^/]+\/role$/)) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const body = await readJsonBody(req); + return handleAdminUserRoleUpdate(res, auth.user, url.pathname, body); + } + + if (method === "PUT" && url.pathname.match(/^\/v1\/admin\/users\/[^/]+\/auth-methods$/)) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const body = await readJsonBody(req); + return handleAdminUserAuthMethodsUpdate(res, auth.user, url.pathname, body); + } + + if (method === "GET" && url.pathname === "/v1/approvals") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin", "approver"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + return sendJson(res, 200, { approvals: listApprovalsView() }); + } + + if (method === "GET" && url.pathname === "/v1/activity-log") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin", "approver"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + const limitRaw = Number(url.searchParams.get("limit") || 200); + const limit = Math.max(20, Math.min(1000, Number.isFinite(limitRaw) ? limitRaw : 200)); + const entries = await listStationActivityLog(limit); + return sendJson(res, 200, { entries }); + } + + if (method === "POST" && url.pathname.match(/^\/v1\/approvals\/[^/]+\/(approve|reject)$/)) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin", "approver"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + return handleApprovalDecision(res, auth.user, url.pathname); + } + + if (method === "PUT" && url.pathname === "/v1/admin/maintenance") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const body = await readJsonBody(req); + return handleMaintenanceUpdate(res, auth.user, body); + } + + if (method === "PUT" && url.pathname === "/v1/admin/branding/logo") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const body = await readJsonBody(req); + return handleAdminBrandingLogoUpdate(res, auth.user, body); + } + + if (method === "DELETE" && url.pathname === "/v1/admin/branding/logo") { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const theme = String(url.searchParams.get("theme") || "").toLowerCase(); + return handleAdminBrandingLogoDelete(res, auth.user, theme); + } + + if (method === "GET" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings-schema$/)) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + return handlePluginSettingsSchema(res, url.pathname); + } + + if (method === "GET" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings$/)) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + return handlePluginSettingsGet(res, url.pathname); + } + + if (method === "PUT" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/settings$/)) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const body = await readJsonBody(req); + return handlePluginSettingsPut(res, auth.user, url.pathname, body); + } + + if (method === "POST" && url.pathname.match(/^\/v1\/plugins\/[^/]+\/(enable|disable)$/)) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + return handlePluginToggle(req, res, auth.user, url.pathname); + } + + if (method === "PUT" && url.pathname.startsWith("/v1/admin/capabilities/") && url.pathname.endsWith("/provider")) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const body = await readJsonBody(req); + return handleCapabilityProviderSwitch(res, auth.user, url.pathname, body); + } + + if (method === "GET" && url.pathname === "/v1/events/stream") { + return handleEventStream(req, res, url); + } + + if (method === "GET" || method === "HEAD") { + return serveStaticFile(rootDir, req, res, url.pathname); + } + + return sendError(res, 404, "route.not_found", "Nicht gefunden"); + } catch (error) { + if (error && error.code === "INVALID_JSON") { + return sendError(res, 400, "request.invalid_json", "Ungueltiges JSON"); + } + console.error("Unhandled request error", error); + return sendError(res, 500, "server.error", "Interner Fehler"); + } +} + +async function handleRequestAccess(req, res, body) { + const email = normalizeEmail(body && body.email); + const requestedMethod = typeof (body && body.method) === "string" ? body.method.trim() : ""; + if (!isValidEmail(email)) { + return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben"); + } + + let user = runtime.users.find((entry) => entry.email === email); + if (runtime.systemState.maintenanceMode) { + const canBypass = (user && user.role === "admin") || config.adminEmails.has(email); + if (!canBypass) { + return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage); + } + } + const nowIso = new Date().toISOString(); + if (!user) { + const role = runtime.users.length === 0 || config.adminEmails.has(email) + ? "admin" + : (config.approverEmails.has(email) ? "approver" : "operator"); + user = { + id: crypto.randomUUID(), + email, + role, + status: "pending_verification", + accountType: isPrimaryDomainEmail(email) ? "primary-domain" : "external-domain", + enabledAuthMethods: listPublicAuthMethods().map((entry) => entry.id), + primaryAuthMethod: preferredAuthMethodId(listPublicAuthMethods()) || null, + preferredLanguage: "de", + createdAt: nowIso, + emailVerifiedAt: null, + approvedAt: null, + deniedAt: null + }; + runtime.users.push(user); + await writeJson(files.users, runtime.users); + await appendAudit("auth.register_email", user, { accountType: user.accountType }); + } + + if (user.status === "denied") { + return sendError(res, 403, "auth.access_denied", "Kein Zugriff. Bitte Freigabe anfordern.", { + requestApprovalUrl: `${publicBaseUrlFor(req)}/login?requestApproval=1&email=${encodeURIComponent(email)}` + }); + } + + const selectedMethod = resolveAuthMethodForUser(user, requestedMethod); + if (!selectedMethod) { + const token = await issueEmailToken(user.id, user.status === "active" ? "login" : "verify"); + const actionPath = user.status === "active" ? "/login?loginToken=" : "/login?verifyToken="; + const link = `${publicBaseUrlFor(req)}${actionPath}${encodeURIComponent(token)}`; + const message = buildAuthEmailMessage(req, { + user, + type: "link", + subject: user.status === "active" ? "ARCG Login-Link" : "ARCG E-Mail bestaetigen", + text: user.status === "active" + ? `Dein Login-Link: ${link}` + : `Bitte E-Mail bestaetigen: ${link}`, + actionLink: link, + actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen" + }); + await sendEmailMessage( + email, + message.subject, + message.text, + message.html + ); + await appendAudit("auth.request_access", user, { status: user.status, method: "fallback-mail" }); + return sendJson(res, 200, { + ok: true, + method: "smtp-link", + challengeType: "link", + challengeHint: "Fallback per Mail aktiv", + message: user.status === "active" + ? "Login-Link wurde per E-Mail versendet." + : "Bestaetigungslink wurde per E-Mail versendet.", + domainAllowed: isPrimaryDomainEmail(email), + requestApprovalHint: !isPrimaryDomainEmail(email) + ? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert." + : null + }); + } + + let challengeType = selectedMethod.type; + let challengeHint = null; + if (selectedMethod.type === "link") { + const token = await issueEmailToken(user.id, user.status === "active" ? "login" : "verify"); + const actionPath = user.status === "active" ? "/login?loginToken=" : "/login?verifyToken="; + const link = `${publicBaseUrlFor(req)}${actionPath}${encodeURIComponent(token)}`; + const message = buildAuthEmailMessage(req, { + user, + type: "link", + subject: user.status === "active" ? "ARCG Login-Link" : "ARCG E-Mail bestaetigen", + text: user.status === "active" + ? `Dein Login-Link: ${link}` + : `Bitte E-Mail bestaetigen: ${link}`, + actionLink: link, + actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen" + }); + await dispatchAuthChallenge(req, user, selectedMethod, { + type: "link", + subject: message.subject, + text: message.text, + html: message.html, + token, + link + }); + } else if (selectedMethod.type === "otp") { + const otpCode = await issueOtpChallenge(user.id, user.status === "active" ? "login" : "verify"); + challengeHint = `Code wurde ueber ${selectedMethod.label} gesendet.`; + const message = buildAuthEmailMessage(req, { + user, + type: "otp", + subject: user.status === "active" ? "ARCG Login-Code" : "ARCG Bestaetigungs-Code", + text: `Dein Code lautet: ${otpCode}`, + code: otpCode + }); + await dispatchAuthChallenge(req, user, selectedMethod, { + type: "otp", + subject: message.subject, + text: message.text, + html: message.html, + code: otpCode + }); + } else { + return sendError(res, 400, "auth.method_invalid", "Unbekannte Bestaetigungsart"); + } + + await appendAudit("auth.request_access", user, { status: user.status }); + return sendJson(res, 200, { + ok: true, + method: selectedMethod.id, + challengeType, + challengeHint, + message: challengeType === "otp" + ? "Code wurde versendet." + : user.status === "active" + ? "Login-Link wurde per E-Mail versendet." + : "Bestaetigungslink wurde per E-Mail versendet.", + domainAllowed: isPrimaryDomainEmail(email), + requestApprovalHint: !isPrimaryDomainEmail(email) + ? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert." + : null + }); +} + +async function handleVerifyEmail(req, res, body) { + const token = typeof (body && body.token) === "string" ? body.token : ""; + const otpEmail = normalizeEmail(body && body.email); + const otpCode = typeof (body && body.code) === "string" ? body.code.trim() : ""; + + let verification = null; + if (token) { + verification = consumeEmailToken(token); + } else if (otpEmail && otpCode) { + const otpUser = runtime.users.find((entry) => entry.email === otpEmail); + if (!otpUser) { + return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden"); + } + verification = consumeOtpChallenge(otpUser.id, otpCode); + } else { + return sendError(res, 400, "auth.token_missing", "Token oder OTP-Code fehlt"); + } + + if (!verification.ok) { + return sendError(res, 400, "auth.token_invalid", verification.message || "Token ungueltig"); + } + + const user = runtime.users.find((entry) => entry.id === verification.userId); + if (!user) { + return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden"); + } + + if (verification.purpose === "verify") { + user.emailVerifiedAt = new Date().toISOString(); + if (user.accountType === "primary-domain" || user.role === "admin") { + user.status = "active"; + user.approvedAt = new Date().toISOString(); + user.deniedAt = null; + await writeJson(files.users, runtime.users); + await appendAudit("auth.verified_auto_approved", user, null); + } else { + user.status = "pending_approval"; + user.deniedAt = null; + await writeJson(files.users, runtime.users); + await createApprovalRequest(user, req); + await appendAudit("auth.verified_pending_approval", user, null); + return sendJson(res, 200, { + ok: true, + verified: true, + approved: false, + message: "E-Mail bestaetigt. Kein direkter Zugriff fuer diese Domain. Freigabe wurde angefordert." + }); + } + } + + if (runtime.systemState.maintenanceMode && user.role !== "admin") { + return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage); + } + if (user.status !== "active") { + return sendError(res, 403, "auth.not_approved", "Benutzer ist noch nicht freigegeben"); + } + + const tokens = await issueTokenPair(user, req); + await appendAudit("auth.login", user, { sid: tokens.sid, via: verification.purpose }); + return sendJson(res, 200, { + ok: true, + user: sanitizeUser(user), + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresInSec: config.accessTokenTtlSec + }); +} + +async function handleRequestApproval(req, res, body) { + const email = normalizeEmail(body && body.email); + if (!isValidEmail(email)) { + return sendError(res, 400, "auth.invalid_email", "Bitte gueltige E-Mail angeben"); + } + const user = runtime.users.find((entry) => entry.email === email); + if (!user) { + return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden"); + } + if (user.accountType === "primary-domain") { + return sendError(res, 409, "approval.not_needed", "Fuer diese Domain ist keine Freigabe erforderlich"); + } + if (!user.emailVerifiedAt) { + return sendError(res, 409, "approval.email_not_verified", "Bitte zuerst E-Mail bestaetigen"); + } + + user.status = "pending_approval"; + user.deniedAt = null; + await writeJson(files.users, runtime.users); + await createApprovalRequest(user, req); + await appendAudit("auth.request_approval", user, null); + return sendJson(res, 200, { ok: true, message: "Freigabe wurde angefordert" }); +} + +async function handleRefresh(req, res, body) { + const refreshToken = typeof (body && body.refreshToken) === "string" ? body.refreshToken : ""; + if (!refreshToken) { + return sendError(res, 400, "auth.refresh_missing", "refreshToken fehlt"); + } + + const verified = verifyJwt(refreshToken, "refresh"); + if (!verified.ok) { + return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig"); + } + + const payload = verified.payload; + const user = runtime.users.find((entry) => entry.id === payload.sub); + if (!user) { + return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig"); + } + if (runtime.systemState.maintenanceMode && user.role !== "admin") { + return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage); + } + if (user.status !== "active") { + return sendError(res, 403, "auth.not_approved", "Benutzer ist nicht freigegeben"); + } + + const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0; + if (payload.tv !== tokenVersion) { + return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig"); + } + + const tokenHash = sha256(refreshToken); + const record = runtime.authState.refreshTokens.find((entry) => entry.id === payload.rid); + if (!record || record.tokenHash !== tokenHash) { + return sendError(res, 401, "auth.invalid_refresh", "Refresh Token ungueltig"); + } + + if (record.revokedAt) { + await revokeSessionFamily(record.sid); + await appendAudit("auth.refresh.reuse_detected", user, { sid: record.sid, rid: record.id }); + return sendError(res, 401, "auth.refresh_reuse", "Refresh Token bereits verwendet"); + } + if (Date.now() > record.expiresAtMs) { + return sendError(res, 401, "auth.refresh_expired", "Refresh Token abgelaufen"); + } + + record.revokedAt = new Date().toISOString(); + const next = await issueTokenPair(user, req, { sid: record.sid, rotatedFrom: record.id }); + await saveAuthState(); + await appendAudit("auth.refresh", user, { sid: record.sid, rid: next.rid }); + + return sendJson(res, 200, { + ok: true, + user: sanitizeUser(user), + accessToken: next.accessToken, + refreshToken: next.refreshToken, + expiresInSec: config.accessTokenTtlSec + }); +} + +async function handleLogout(res, body) { + const refreshToken = typeof (body && body.refreshToken) === "string" ? body.refreshToken : ""; + if (!refreshToken) { + return sendJson(res, 200, { ok: true }); + } + + const verified = verifyJwt(refreshToken, "refresh"); + if (!verified.ok) { + return sendJson(res, 200, { ok: true }); + } + const record = runtime.authState.refreshTokens.find((entry) => entry.id === verified.payload.rid); + if (record && !record.revokedAt) { + record.revokedAt = new Date().toISOString(); + await saveAuthState(); + } + return sendJson(res, 200, { ok: true }); +} + +async function handleLogoutAll(res, user) { + runtime.authState.tokenVersionByUser[user.id] = (runtime.authState.tokenVersionByUser[user.id] || 0) + 1; + for (const token of runtime.authState.refreshTokens) { + if (token.userId === user.id && !token.revokedAt) { + token.revokedAt = new Date().toISOString(); + } + } + await saveAuthState(); + await appendAudit("auth.logout_all", user, null); + return sendJson(res, 200, { ok: true }); +} + +async function handleAuthSessionDiag(res, user, payload) { + if (!hasRole(user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nur Admin"); + } + const nowMs = Date.now(); + const nowSec = Math.floor(nowMs / 1000); + const expSec = Number(payload && payload.exp ? payload.exp : 0); + const tokenRemainingSec = expSec > 0 ? Math.max(0, expSec - nowSec) : 0; + const ttlDetails = getEffectiveAccessTokenTtlDetails(user, nowSec); + + return sendJson(res, 200, { + ok: true, + token: { + expiresAt: expSec > 0 ? new Date(expSec * 1000).toISOString() : null, + remainingSec: tokenRemainingSec + }, + config: { + configuredAccessTokenTtlSec: Number.isFinite(Number(config.accessTokenTtlSec)) ? Math.floor(Number(config.accessTokenTtlSec)) : null, + minimumAccessTokenTtlSec: ttlDetails.minimumTtlSec, + effectiveNextIssueTtlSec: ttlDetails.effectiveTtlSec + }, + station: { + isInUse: Boolean(runtime.station && runtime.station.isInUse), + activeByUserId: runtime.station && runtime.station.activeByUserId ? String(runtime.station.activeByUserId) : null, + endsAt: runtime.station && runtime.station.endsAt ? runtime.station.endsAt : null, + ownerSessionBoostApplied: Boolean(ttlDetails.ownerSessionBoostApplied) + } + }); +} + +async function handleAdminUserRoleUpdate(res, actor, pathname, body) { + const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/role$/); + if (!match) { + return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden"); + } + const userId = decodeURIComponent(match[1]); + const role = typeof (body && body.role) === "string" ? body.role : ""; + if (!["admin", "approver", "operator"].includes(role)) { + return sendError(res, 400, "user.role.invalid", "Rolle ungueltig"); + } + const user = runtime.users.find((entry) => entry.id === userId); + if (!user) { + return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden"); + } + if (config.adminEmails.has(user.email) && role !== "admin") { + return sendError(res, 409, "user.role.locked", "Konfigurierter Admin kann nicht herabgestuft werden"); + } + + user.role = role; + if (user.status !== "active") { + user.status = "active"; + user.approvedAt = new Date().toISOString(); + } + await writeJson(files.users, runtime.users); + await appendAudit("admin.user.role.update", actor, { userId, email: user.email, role }); + return sendJson(res, 200, { ok: true, user: sanitizeUser(user) }); +} + +async function handleSelfAuthMethodUpdate(res, user, body) { + const primaryMethod = typeof (body && body.primaryMethod) === "string" ? body.primaryMethod : ""; + if (!primaryMethod) { + return sendError(res, 400, "user.auth_methods.invalid", "primaryMethod erforderlich"); + } + const availableIds = listPublicAuthMethods().map((entry) => entry.id); + if (!availableIds.includes(primaryMethod)) { + return sendError(res, 400, "user.auth_methods.invalid", "Methode nicht verfuegbar"); + } + const enabledMethods = Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : []; + if (!enabledMethods.includes(primaryMethod)) { + return sendError(res, 400, "user.auth_methods.invalid", "Methode ist fuer Benutzer nicht freigeschaltet"); + } + user.primaryAuthMethod = primaryMethod; + await writeJson(files.users, runtime.users); + await appendAudit("user.auth_method.update", user, { primaryMethod }); + return sendJson(res, 200, { ok: true, user: sanitizeUser(user) }); +} + +async function handleSelfLanguageUpdate(res, user, body) { + const preferredLanguage = typeof (body && body.preferredLanguage) === "string" + ? body.preferredLanguage.trim().toLowerCase() + : ""; + if (!preferredLanguage) { + return sendError(res, 400, "user.language.invalid", "preferredLanguage erforderlich"); + } + const allowed = new Set(["de", "en"]); + if (!allowed.has(preferredLanguage)) { + return sendError(res, 400, "user.language.invalid", "Sprache nicht verfuegbar"); + } + user.preferredLanguage = preferredLanguage; + await writeJson(files.users, runtime.users); + await appendAudit("user.language.update", user, { preferredLanguage }); + return sendJson(res, 200, { ok: true, user: sanitizeUser(user) }); +} + +async function handleAdminUserAuthMethodsUpdate(res, actor, pathname, body) { + const match = pathname.match(/^\/v1\/admin\/users\/([^/]+)\/auth-methods$/); + if (!match) { + return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden"); + } + const userId = decodeURIComponent(match[1]); + const user = runtime.users.find((entry) => entry.id === userId); + if (!user) { + return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden"); + } + + const availableIds = listPublicAuthMethods().map((entry) => entry.id); + const enabledMethods = Array.isArray(body && body.enabledMethods) + ? body.enabledMethods.filter((entry) => typeof entry === "string" && availableIds.includes(entry)) + : []; + if (enabledMethods.length === 0) { + return sendError(res, 400, "user.auth_methods.invalid", "Mindestens eine Bestaetigungsart muss aktiv sein"); + } + const primaryMethod = typeof (body && body.primaryMethod) === "string" && enabledMethods.includes(body.primaryMethod) + ? body.primaryMethod + : enabledMethods[0]; + + user.enabledAuthMethods = Array.from(new Set(enabledMethods)); + user.primaryAuthMethod = primaryMethod; + await writeJson(files.users, runtime.users); + await appendAudit("admin.user.auth_methods.update", actor, { + userId: user.id, + email: user.email, + enabledMethods: user.enabledAuthMethods, + primaryMethod: user.primaryAuthMethod + }); + + return sendJson(res, 200, { ok: true, user: sanitizeUser(user) }); +} + +async function handleApprovalDecision(res, actor, pathname) { + const match = pathname.match(/^\/v1\/approvals\/([^/]+)\/(approve|reject)$/); + if (!match) { + return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden"); + } + const approvalId = decodeURIComponent(match[1]); + const action = match[2]; + const approval = runtime.approvalRequests.find((entry) => entry.id === approvalId); + if (!approval) { + return sendError(res, 404, "approval.not_found", "Freigabe nicht gefunden"); + } + const user = runtime.users.find((entry) => entry.id === approval.userId); + if (!user) { + return sendError(res, 404, "user.not_found", "Benutzer nicht gefunden"); + } + + approval.updatedAt = new Date().toISOString(); + approval.updatedBy = actor.email; + approval.status = action === "approve" ? "approved" : "rejected"; + approval.decidedAt = approval.updatedAt; + approval.decidedBy = actor.email; + + if (action === "approve") { + user.status = "active"; + user.approvedAt = approval.updatedAt; + user.deniedAt = null; + } else { + user.status = "denied"; + user.deniedAt = approval.updatedAt; + } + + await writeJson(files.users, runtime.users); + await saveApprovalRequests(); + await appendAudit(`approval.${action}`, actor, { approvalId, userId: user.id, email: user.email }); + broadcastEvent("approval.status.changed", { approvalId, status: approval.status, userId: user.id, email: user.email }); + + await sendEmailMessage( + user.email, + action === "approve" ? "ARCG Zugriff freigegeben" : "ARCG Zugriff abgelehnt", + action === "approve" + ? "Dein Zugriff wurde freigegeben. Du kannst jetzt einen Login-Link anfordern." + : "Dein Zugriff wurde abgelehnt." + ); + + const approvalView = listApprovalsView().find((entry) => entry.id === approval.id) || approval; + return sendJson(res, 200, { ok: true, approval: approvalView, user: sanitizeUser(user) }); +} + +function listApprovalsView() { + return runtime.approvalRequests.map((approval) => { + const user = runtime.users.find((entry) => entry.id === approval.userId); + return { + ...approval, + userStatus: user ? user.status : "unknown", + userRole: user ? user.role : "unknown" + }; + }); +} + +async function handleMaintenanceUpdate(res, actor, body) { + const enabled = Boolean(body && body.enabled); + const message = typeof (body && body.message) === "string" && body.message.trim() + ? body.message.trim() + : "Wartungsmodus aktiv. Login ist derzeit deaktiviert."; + + runtime.systemState.maintenanceMode = enabled; + runtime.systemState.maintenanceMessage = message; + runtime.systemState.updatedAt = new Date().toISOString(); + await saveSystemState(); + await appendAudit("admin.maintenance.update", actor, { enabled, message }); + + if (enabled) { + await forceLogoutAllUsers(); + broadcastEvent("system.maintenance.enabled", { message }); + } else { + broadcastEvent("system.maintenance.disabled", { message }); + } + + return sendJson(res, 200, { + ok: true, + maintenanceMode: runtime.systemState.maintenanceMode, + maintenanceMessage: runtime.systemState.maintenanceMessage, + branding: runtime.systemState.branding, + updatedAt: runtime.systemState.updatedAt + }); +} + +async function handleAdminBrandingLogoUpdate(res, actor, body) { + const theme = typeof (body && body.theme) === "string" ? body.theme.trim().toLowerCase() : ""; + if (theme !== "light" && theme !== "dark") { + return sendError(res, 400, "branding.theme.invalid", "Theme muss 'light' oder 'dark' sein"); + } + const dataUrl = typeof (body && body.dataUrl) === "string" ? body.dataUrl.trim() : ""; + if (!dataUrl) { + return sendError(res, 400, "branding.data.missing", "Logo-Bild fehlt"); + } + const parsed = parseImageDataUrl(dataUrl); + if (!parsed.ok) { + return sendError(res, 400, "branding.data.invalid", parsed.message || "Ungueltiges Bildformat"); + } + if (parsed.buffer.length > 2 * 1024 * 1024) { + return sendError(res, 413, "branding.data.too_large", "Bild ist zu gross (max 2MB)"); + } + + const uploadsDir = config.brandingUploadsDir; + await fsp.mkdir(uploadsDir, { recursive: true }); + const fileName = `logo-${theme}.${parsed.ext}`; + const filePath = path.join(uploadsDir, fileName); + await fsp.writeFile(filePath, parsed.buffer); + + runtime.systemState = normalizeSystemState({ + ...runtime.systemState, + branding: { + ...runtime.systemState.branding, + ...(theme === "light" ? { logoLightUrl: `/uploads/${fileName}` } : { logoDarkUrl: `/uploads/${fileName}` }) + }, + updatedAt: new Date().toISOString() + }); + await saveSystemState(); + await appendAudit("admin.branding.logo.update", actor, { + theme, + fileName, + bytes: parsed.buffer.length + }); + broadcastEvent("branding.updated", { theme, branding: runtime.systemState.branding }); + + return sendJson(res, 200, { + ok: true, + branding: runtime.systemState.branding, + updatedAt: runtime.systemState.updatedAt + }); +} + +async function handleAdminBrandingLogoDelete(res, actor, theme) { + if (theme !== "light" && theme !== "dark") { + return sendError(res, 400, "branding.theme.invalid", "Theme muss 'light' oder 'dark' sein"); + } + const key = theme === "light" ? "logoLightUrl" : "logoDarkUrl"; + const currentUrl = runtime.systemState.branding && runtime.systemState.branding[key]; + await removeBrandingFileIfExists(currentUrl); + + runtime.systemState = normalizeSystemState({ + ...runtime.systemState, + branding: { + ...runtime.systemState.branding, + [key]: null + }, + updatedAt: new Date().toISOString() + }); + await saveSystemState(); + await appendAudit("admin.branding.logo.delete", actor, { theme }); + broadcastEvent("branding.updated", { theme, branding: runtime.systemState.branding }); + + return sendJson(res, 200, { + ok: true, + branding: runtime.systemState.branding, + updatedAt: runtime.systemState.updatedAt + }); +} + +async function removeBrandingFileIfExists(urlPath) { + if (!urlPath || typeof urlPath !== "string") { + return; + } + const clean = urlPath.split("?")[0]; + if (!clean.startsWith("/uploads/")) { + return; + } + const publicDir = path.resolve(config.brandingUploadsDir); + const relativeUploadPath = clean.replace(/^\/uploads\//, ""); + const safePath = path + .normalize(relativeUploadPath) + .replace(/^[/\\]+/, "") + .replace(/^([.][.][/\\])+/, ""); + const fullPath = path.resolve(publicDir, safePath); + const resolvedPublicDir = path.resolve(publicDir); + if (fullPath !== resolvedPublicDir && !fullPath.startsWith(resolvedPublicDir + path.sep)) { + return; + } + try { + await fsp.unlink(fullPath); + } catch { + // ignore missing file + } +} + +async function reconcileBrandingFromUploads() { + const light = runtime.systemState.branding && runtime.systemState.branding.logoLightUrl + ? runtime.systemState.branding.logoLightUrl + : await findExistingLogoUrl("light"); + const dark = runtime.systemState.branding && runtime.systemState.branding.logoDarkUrl + ? runtime.systemState.branding.logoDarkUrl + : await findExistingLogoUrl("dark"); + + if (light === runtime.systemState.branding.logoLightUrl && dark === runtime.systemState.branding.logoDarkUrl) { + return; + } + + runtime.systemState = normalizeSystemState({ + ...runtime.systemState, + branding: { + ...runtime.systemState.branding, + logoLightUrl: light, + logoDarkUrl: dark + }, + updatedAt: runtime.systemState.updatedAt || new Date().toISOString() + }); + await saveSystemState(); +} + +async function findExistingLogoUrl(theme) { + const exts = ["png", "jpg", "jpeg", "svg", "webp"]; + for (const ext of exts) { + const fileName = `logo-${theme}.${ext}`; + const fullPath = path.join(config.brandingUploadsDir, fileName); + try { + await fsp.access(fullPath, fs.constants.R_OK); + return `/uploads/${fileName}`; + } catch { + // try next extension + } + } + return null; +} + +function parseImageDataUrl(value) { + const match = /^data:(image\/(png|jpeg|jpg|svg\+xml|webp));base64,([A-Za-z0-9+/=\s]+)$/i.exec(value); + if (!match) { + return { ok: false, message: "Nur PNG/JPEG/SVG/WEBP als Data-URL erlaubt" }; + } + const mime = match[1].toLowerCase(); + const extMap = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/svg+xml": "svg", + "image/webp": "webp" + }; + const ext = extMap[mime]; + if (!ext) { + return { ok: false, message: "Dateiformat nicht unterstuetzt" }; + } + try { + const base64 = match[3].replace(/\s+/g, ""); + const buffer = Buffer.from(base64, "base64"); + if (!buffer.length) { + return { ok: false, message: "Leeres Bild" }; + } + return { ok: true, mime, ext, buffer }; + } catch { + return { ok: false, message: "Bilddaten konnten nicht gelesen werden" }; + } +} + +function normalizeSystemState(state) { + const input = state && typeof state === "object" ? state : {}; + const branding = input.branding && typeof input.branding === "object" ? input.branding : {}; + return { + maintenanceMode: Boolean(input.maintenanceMode), + maintenanceMessage: typeof input.maintenanceMessage === "string" && input.maintenanceMessage.trim() + ? input.maintenanceMessage + : "Wartungsmodus aktiv. Login ist derzeit deaktiviert.", + branding: { + logoLightUrl: typeof branding.logoLightUrl === "string" && branding.logoLightUrl.trim() ? branding.logoLightUrl : null, + logoDarkUrl: typeof branding.logoDarkUrl === "string" && branding.logoDarkUrl.trim() ? branding.logoDarkUrl : null + }, + updatedAt: input.updatedAt || null + }; +} + +async function migratePluginSettingsDefaults() { + runtime.pluginState.settings = runtime.pluginState.settings && typeof runtime.pluginState.settings === "object" + ? runtime.pluginState.settings + : {}; + + const dataDir = config.dataDir; + let changed = false; + changed = ensureVswrNativeSettings(dataDir) || changed; + changed = ensureVswrNanoVnaSettings(dataDir) || changed; + changed = ensureVswrReportReaderSettings(dataDir) || changed; + changed = ensureOpenWebRxBandmapSettings() || changed; + changed = ensureMicrohamSettings() || changed; + + if (changed) { + await savePluginState(); + } +} + +function ensureOpenWebRxBandmapSettings() { + const pluginId = "rms.openwebrx.bandmap"; + const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" + ? { ...runtime.pluginState.settings[pluginId] } + : {}; + const next = { ...current }; + + const syncFromEnv = boolFromEnv("OPENWEBRX_BANDMAP_SYNC_SETTINGS_FROM_ENV", config.execMode === "prod"); + if (syncFromEnv) { + next.csvPath = String(process.env.OPENWEBRX_BANDMAP_CSV_PATH || "").trim(); + next.configFilePath = String(process.env.OPENWEBRX_CONFIG_PATH || "").trim(); + next.setCommandTemplate = String(process.env.OPENWEBRX_BAND_SET_CMD_TEMPLATE || "").trim(); + next.stateFilePath = String(process.env.OPENWEBRX_BAND_STATE_PATH || "").trim(); + next.timeoutMs = clampInteger(Number(process.env.OPENWEBRX_BAND_TIMEOUT_MS || 20000), 1000, 120000, 20000); + } else if (!Number.isFinite(Number(next.timeoutMs)) || Number(next.timeoutMs) < 1000) { + next.timeoutMs = 20000; + } + + if (JSON.stringify(current) !== JSON.stringify(next)) { + runtime.pluginState.settings[pluginId] = next; + return true; + } + return false; +} + +function ensureVswrNativeSettings(dataDir) { + const pluginId = "rms.vswr.native"; + const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" + ? { ...runtime.pluginState.settings[pluginId] } + : {}; + const next = { ...current }; + + const defaultReportPath = String(process.env.VSWR_REPORT_JSON_PATH || path.join(dataDir, "vswr", "swr-report.json")); + const defaultOutputBase = String(process.env.VSWR_OUTPUT_BASE_DIR || path.join(dataDir, "vswr", "output")); + const defaultCheckCommand = String(process.env.VSWR_CHECK_CMD || "/opt/remotestation/bin/vswr-check.sh").trim(); + const defaultCommand = String( + process.env.NANOVNA_COMMAND_TEMPLATE + || process.env.VSWR_CHECK_CMD + || `${defaultCheckCommand} {band} {startHz} {endHz} {bandDir}` + ); + + next.reportJsonPath = normalizeVswrPath(next.reportJsonPath, dataDir, defaultReportPath); + next.outputBaseDir = normalizeVswrPath(next.outputBaseDir, dataDir, defaultOutputBase); + if (!String(next.nanovnaCommandTemplate || "").trim()) { + next.nanovnaCommandTemplate = defaultCommand; + } + if (String(next.nanovnaCommandTemplate || "").includes("NanoVNASaver.py")) { + next.nanovnaCommandTemplate = defaultCheckCommand; + } + if (!Number.isFinite(Number(next.timeoutMsPerBand)) || Number(next.timeoutMsPerBand) < 1000) { + next.timeoutMsPerBand = Number(process.env.VSWR_TIMEOUT_MS_PER_BAND || 90000); + } + if (!String(next.publicImagesBaseUrl || "").trim()) { + next.publicImagesBaseUrl = String(process.env.VSWR_IMAGES_BASE_URL || deriveVswrImagesBaseUrl()); + } + + if (JSON.stringify(current) !== JSON.stringify(next)) { + runtime.pluginState.settings[pluginId] = next; + return true; + } + return false; +} + +function ensureVswrNanoVnaSettings(dataDir) { + const pluginId = "rms.vswr.nanovna"; + const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" + ? { ...runtime.pluginState.settings[pluginId] } + : {}; + const next = { ...current }; + + const defaultMeta = String(process.env.VSWR_METADATA_PATH || path.join(dataDir, "vswr", "metadata.txt")); + next.metadataPath = normalizeVswrPath(next.metadataPath, dataDir, defaultMeta); + if (!String(next.checkCommand || "").trim()) { + next.checkCommand = String(process.env.VSWR_CHECK_CMD || "/opt/remotestation/bin/vswr-check.sh"); + } + if (!Number.isFinite(Number(next.timeoutMs)) || Number(next.timeoutMs) < 1000) { + next.timeoutMs = Number(process.env.VSWR_CHECK_TIMEOUT_MS || 240000); + } + if (!Number.isFinite(Number(next.expectedDurationMs)) || Number(next.expectedDurationMs) < 1000) { + next.expectedDurationMs = Number(process.env.SWR_CHECK_DURATION_MS || 54000); + } + + if (JSON.stringify(current) !== JSON.stringify(next)) { + runtime.pluginState.settings[pluginId] = next; + return true; + } + return false; +} + +function ensureVswrReportReaderSettings(dataDir) { + const pluginId = "rms.vswr.report_reader"; + const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" + ? { ...runtime.pluginState.settings[pluginId] } + : {}; + const next = { ...current }; + + const defaultHtml = String(process.env.VSWR_OVERVIEW_HTML_PATH || path.join(dataDir, "vswr", "index.html")); + const defaultMeta = String(process.env.VSWR_METADATA_PATH || path.join(dataDir, "vswr", "metadata.txt")); + const defaultImages = String(process.env.VSWR_IMAGES_DIR_PATH || path.join(dataDir, "vswr", "images")); + + next.overviewHtmlPath = normalizeVswrPath(next.overviewHtmlPath, dataDir, defaultHtml); + next.metadataPath = normalizeVswrPath(next.metadataPath, dataDir, defaultMeta); + next.imagesDirPath = normalizeVswrPath(next.imagesDirPath, dataDir, defaultImages); + if (!String(next.publicImagesBaseUrl || "").trim()) { + next.publicImagesBaseUrl = String(process.env.VSWR_IMAGES_BASE_URL || deriveVswrImagesBaseUrl()); + } + + if (JSON.stringify(current) !== JSON.stringify(next)) { + runtime.pluginState.settings[pluginId] = next; + return true; + } + return false; +} + +function ensureMicrohamSettings() { + const pluginId = "rms.microham"; + const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" + ? { ...runtime.pluginState.settings[pluginId] } + : {}; + const next = { ...current }; + + const syncFromEnv = boolFromEnv("MICROHAM_SYNC_SETTINGS_FROM_ENV", config.execMode === "prod"); + if (!syncFromEnv) { + return false; + } + + next.device = String(process.env.MICROHAM_DEVICE || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim() || "/dev/rms-microham-u3"; + next.pttCommandsEnabled = boolFromEnv("MICROHAM_PTT_COMMANDS_ENABLED", true); + next.pttDownCommand = String(process.env.MICROHAM_PTT_DOWN_CMD || "").trim(); + next.pttUpCommand = String(process.env.MICROHAM_PTT_UP_CMD || "").trim(); + next.pttTimeoutMs = clampInteger(Number(process.env.MICROHAM_PTT_TIMEOUT_MS || 5000), 1000, 60000, 5000); + next.pttApplyBandState = boolFromEnv("MICROHAM_PTT_APPLY_BAND_STATE", true); + next.pttRigctlModel = String(process.env.MICROHAM_PTT_RIGCTL_MODEL || "3023").trim() || "3023"; + next.pttRigctlBaud = String(process.env.MICROHAM_PTT_RIGCTL_BAUD || "19200").trim() || "19200"; + next.pttRigctlSetConf = String(process.env.MICROHAM_PTT_RIGCTL_SETCONF || "rts_state=OFF,dtr_state=OFF").trim() || "rts_state=OFF,dtr_state=OFF"; + next.audioEnabled = boolFromEnv("MICROHAM_AUDIO_ENABLED", true); + next.audioAlsaDevice = String(process.env.MICROHAM_AUDIO_ALSA_DEVICE || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0"; + next.audioInputMime = String(process.env.MICROHAM_AUDIO_INPUT_MIME || "webm").trim().toLowerCase() === "ogg" ? "ogg" : "webm"; + next.audioStopOnDisconnect = boolFromEnv("MICROHAM_AUDIO_STOP_ON_DISCONNECT", true); + next.audioChunkMs = clampInteger(Number(process.env.MICROHAM_AUDIO_CHUNK_MS || 100), 40, 2000, 100); + next.audioSessionTimeoutMs = clampInteger(Number(process.env.MICROHAM_AUDIO_SESSION_TIMEOUT_MS || 120000), 1000, 3600000, 120000); + next.audioFfmpegPath = String(process.env.MICROHAM_AUDIO_FFMPEG_PATH || "").trim(); + if (next.audioFfmpegExtraArgs === undefined || next.audioFfmpegExtraArgs === null) { + next.audioFfmpegExtraArgs = String(process.env.MICROHAM_AUDIO_FFMPEG_EXTRA_ARGS || "").trim(); + } + + if (JSON.stringify(current) !== JSON.stringify(next)) { + runtime.pluginState.settings[pluginId] = next; + return true; + } + return false; +} + +function boolFromEnv(name, fallback) { + const raw = String(process.env[name] ?? "").trim().toLowerCase(); + if (!raw) { + return Boolean(fallback); + } + return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; +} + +function clampInteger(value, min, max, fallback) { + if (!Number.isFinite(Number(value))) { + return fallback; + } + return Math.max(min, Math.min(max, Math.trunc(Number(value)))); +} + +function normalizeVswrPath(value, dataDir, fallback) { + const raw = String(value || "").trim(); + if (!raw) { + return String(fallback || ""); + } + if (path.isAbsolute(raw)) { + return raw; + } + const cleaned = raw.replace(/^\.\//, ""); + if (cleaned.startsWith("data/")) { + return path.join(dataDir, cleaned.slice("data/".length)); + } + return path.join(dataDir, cleaned); +} + +function deriveVswrImagesBaseUrl() { + const overview = String(process.env.SWR_OVERVIEW_URL || "").trim(); + if (!overview) { + return ""; + } + return `${overview.replace(/\/$/, "")}/images`; +} + +async function forceLogoutAllUsers() { + for (const user of runtime.users) { + if (user.role === "admin") { + continue; + } + runtime.authState.tokenVersionByUser[user.id] = (runtime.authState.tokenVersionByUser[user.id] || 0) + 1; + } + for (const token of runtime.authState.refreshTokens) { + const tokenUser = runtime.users.find((user) => user.id === token.userId); + if (tokenUser && tokenUser.role === "admin") { + continue; + } + if (!token.revokedAt) { + token.revokedAt = new Date().toISOString(); + } + } + await saveAuthState(); +} + +function listPublicAuthMethods() { + const methods = []; + for (const plugin of runtime.plugins.values()) { + const def = plugin && plugin.manifest && plugin.manifest.authMethod; + if (!def || !def.id || !def.type) { + continue; + } + if (!runtime.pluginState.enabled[plugin.manifest.id]) { + continue; + } + methods.push({ + id: String(def.id), + type: String(def.type), + label: String(def.label || def.id), + pluginId: plugin.manifest.id + }); + } + methods.sort((a, b) => { + if (a.id === "smtp-link" && b.id !== "smtp-link") return -1; + if (b.id === "smtp-link" && a.id !== "smtp-link") return 1; + return a.id.localeCompare(b.id); + }); + return methods; +} + +function preferredAuthMethodId(methods) { + const list = Array.isArray(methods) ? methods : []; + if (list.some((entry) => entry.id === "smtp-link")) { + return "smtp-link"; + } + return list[0] ? list[0].id : null; +} + +function resolveAuthMethodForUser(user, requestedMethodId) { + const all = listPublicAuthMethods(); + const enabledSet = new Set(Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : []); + const allowed = all.filter((entry) => enabledSet.has(entry.id)); + if (allowed.length === 0) { + return null; + } + if (requestedMethodId) { + return allowed.find((entry) => entry.id === requestedMethodId) || null; + } + if (user.primaryAuthMethod) { + const primary = allowed.find((entry) => entry.id === user.primaryAuthMethod); + if (primary) return primary; + } + return allowed[0] || null; +} + +async function dispatchAuthChallenge(req, user, method, payload) { + const plugin = runtime.plugins.get(method.pluginId); + if (!plugin || typeof plugin.instance.execute !== "function") { + throw new Error(`Auth method plugin missing: ${method.id}`); + } + + await appendMailOutboxEntry({ + at: new Date().toISOString(), + via: method.pluginId, + to: user.email, + subject: String(payload && payload.subject ? payload.subject : ""), + text: String(payload && payload.text ? payload.text : ""), + html: String(payload && payload.html ? payload.html : ""), + challengeType: String(payload && payload.type ? payload.type : method.type || "unknown"), + source: "server-prelog" + }); + + await plugin.instance.execute("send_challenge", { + methodId: method.id, + methodType: method.type, + user: sanitizeUser(user), + recipient: user.email, + origin: publicBaseUrlFor(req), + payload + }, { user }); +} + +async function issueOtpChallenge(userId, purpose) { + const code = String(Math.floor(100000 + Math.random() * 900000)); + runtime.authState.otpChallenges.push({ + id: crypto.randomUUID(), + userId, + purpose, + codeHash: sha256(code), + createdAt: new Date().toISOString(), + expiresAtMs: Date.now() + 10 * 60 * 1000, + consumedAt: null, + failedAttempts: 0 + }); + pruneOtpChallenges(); + await saveAuthState(); + return code; +} + +function consumeOtpChallenge(userId, code) { + const now = Date.now(); + const record = runtime.authState.otpChallenges + .filter((entry) => entry.userId === userId && !entry.consumedAt && now <= entry.expiresAtMs) + .sort((a, b) => b.expiresAtMs - a.expiresAtMs)[0]; + if (!record) { + return { ok: false, message: "Kein gueltiger OTP-Code" }; + } + if (!safeEquals(record.codeHash, sha256(code))) { + record.failedAttempts = Number(record.failedAttempts || 0) + 1; + if (record.failedAttempts >= 5) { + record.consumedAt = new Date().toISOString(); + } + saveAuthState().catch(() => {}); + return { ok: false, message: "OTP-Code ungueltig" }; + } + record.consumedAt = new Date().toISOString(); + saveAuthState().catch(() => {}); + return { ok: true, userId, purpose: record.purpose }; +} + +function pruneOtpChallenges() { + const now = Date.now(); + runtime.authState.otpChallenges = runtime.authState.otpChallenges.filter((entry) => { + if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) { + return false; + } + return entry.expiresAtMs > now - 24 * 60 * 60 * 1000; + }); +} + +async function createApprovalRequest(user, req) { + const existing = runtime.approvalRequests.find((entry) => entry.userId === user.id && entry.status === "pending"); + if (existing) { + return existing; + } + const approval = { + id: `apr_${crypto.randomUUID()}`, + userId: user.id, + email: user.email, + status: "pending", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + updatedBy: user.email, + decidedAt: null, + decidedBy: null, + notes: `Externes Domain-Konto (${domainForEmail(user.email)})` + }; + runtime.approvalRequests.push(approval); + await saveApprovalRequests(); + + const recipients = runtime.users + .filter((entry) => entry.status === "active" && (entry.role === "admin" || entry.role === "approver")) + .map((entry) => entry.email); + const approvalsUrl = `${publicBaseUrlFor(req)}/rms/freigaben`; + for (const recipient of recipients) { + await sendEmailMessage( + recipient, + "Neue Freigabe-Anfrage", + `Neue Freigabe fuer ${user.email}. Bitte pruefen: ${approvalsUrl}` + ); + } + return approval; +} + +async function issueEmailToken(userId, purpose) { + const token = crypto.randomBytes(32).toString("base64url"); + const ttlMs = purpose === "login" ? 15 * 60 * 1000 : 24 * 60 * 60 * 1000; + runtime.authState.emailTokens.push({ + tokenHash: sha256(token), + userId, + purpose, + createdAt: new Date().toISOString(), + expiresAtMs: Date.now() + ttlMs, + consumedAt: null + }); + pruneEmailTokens(); + await saveAuthState(); + return token; +} + +function consumeEmailToken(rawToken) { + const tokenHash = sha256(rawToken); + const entry = runtime.authState.emailTokens.find((token) => token.tokenHash === tokenHash); + if (!entry) { + return { ok: false, message: "Token nicht gefunden" }; + } + if (entry.consumedAt) { + return { ok: false, message: "Token bereits verwendet" }; + } + if (Date.now() > entry.expiresAtMs) { + return { ok: false, message: "Token abgelaufen" }; + } + entry.consumedAt = new Date().toISOString(); + saveAuthState().catch(() => {}); + return { ok: true, userId: entry.userId, purpose: entry.purpose }; +} + +function pruneEmailTokens() { + const now = Date.now(); + runtime.authState.emailTokens = runtime.authState.emailTokens.filter((entry) => { + if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) { + return false; + } + return entry.expiresAtMs > now - 24 * 60 * 60 * 1000; + }); +} + +function publicBaseUrlFor(req) { + if (config.publicBaseUrl) { + return config.publicBaseUrl.replace(/\/+$/, ""); + } + const proto = String(req.headers["x-forwarded-proto"] || "http").split(",")[0].trim(); + const host = String(req.headers["x-forwarded-host"] || req.headers.host || `localhost:${config.port}`).split(",")[0].trim(); + return `${proto}://${host}`; +} + +function serializeMailOutboxEntry(entry) { + return `${JSON.stringify(entry)}\n`; +} + +async function appendMailOutboxEntry(entry) { + const line = serializeMailOutboxEntry(entry); + await storage.appendText(files.mailOutbox, line); + + // With SQLite storage, logs are persisted in DB tables. + // Mirror mail outbox additionally to a plain file for ops visibility. + if (storage && storage.id === "sqlite") { + await fsp.mkdir(path.dirname(files.mailOutbox), { recursive: true }); + await fsp.appendFile(files.mailOutbox, line, "utf8"); + } + + // In dev mode, always mirror into local ./data/mail-outbox.log + // so developers can inspect logs independent of DATA_DIR overrides. + if (config.execMode === "dev") { + const localMirror = path.join(rootDir, "data", "mail-outbox.log"); + if (localMirror !== files.mailOutbox) { + await fsp.mkdir(path.dirname(localMirror), { recursive: true }); + await fsp.appendFile(localMirror, line, "utf8"); + } + } +} + +async function ensureMailOutboxInitialized() { + if (!(await storage.exists(files.mailOutbox))) { + await storage.writeText(files.mailOutbox, ""); + } + if (storage && storage.id === "sqlite" && !fs.existsSync(files.mailOutbox)) { + await fsp.mkdir(path.dirname(files.mailOutbox), { recursive: true }); + await fsp.writeFile(files.mailOutbox, "", "utf8"); + } + if (config.execMode === "dev") { + const localMirror = path.join(rootDir, "data", "mail-outbox.log"); + if (!fs.existsSync(localMirror)) { + await fsp.mkdir(path.dirname(localMirror), { recursive: true }); + await fsp.writeFile(localMirror, "", "utf8"); + } + } +} + +async function sendEmailMessage(to, subject, text, html = "") { + const smtpMethod = listPublicAuthMethods().find((entry) => entry.id === "smtp-link"); + if (smtpMethod) { + const plugin = runtime.plugins.get(smtpMethod.pluginId); + if (plugin && typeof plugin.instance.execute === "function") { + await plugin.instance.execute("send_challenge", { + methodId: smtpMethod.id, + methodType: smtpMethod.type, + user: null, + recipient: to, + origin: config.publicBaseUrl || "", + payload: { + type: "link", + subject, + text, + html: String(html || "") + } + }, { user: null }); + return; + } + } + const entry = { + at: new Date().toISOString(), + to, + subject, + text, + html: String(html || "") + }; + await appendMailOutboxEntry(entry); +} + +function buildAuthEmailMessage(req, options) { + const subject = String(options && options.subject ? options.subject : "ARCG Login"); + const text = String(options && options.text ? options.text : ""); + const user = options && options.user ? options.user : null; + const actionLink = options && options.actionLink ? String(options.actionLink) : ""; + const actionLabel = options && options.actionLabel ? String(options.actionLabel) : "Aktion ausfuehren"; + const code = options && options.code ? String(options.code) : ""; + const baseUrl = publicBaseUrlFor(req); + const branding = runtime.systemState && runtime.systemState.branding ? runtime.systemState.branding : {}; + const logoCandidate = branding.logoLightUrl || branding.logoDarkUrl || ""; + const logoUrl = toAbsoluteUrl(baseUrl, logoCandidate); + const stationName = config.stationName || "ARCG RemoteStation"; + const safeSubject = escapeHtml(subject); + const safeStationName = escapeHtml(stationName); + const safeTextHtml = escapeHtml(text).replace(/\n/g, "
    "); + const safeActionLabel = escapeHtml(actionLabel); + const safeActionLink = escapeHtml(actionLink); + const safeCode = escapeHtml(code); + + const html = [ + "", + '', + '' + safeSubject + "", + '', + '', + '", + "
    ', + '', + '", + '", + '", + "
    ', + (logoUrl ? 'Logo' : ""), + '
    ARCG RemoteStation
    ', + '

    ' + safeSubject + "

    ", + "
    ', + '

    Hallo' + (user && user.email ? " " + escapeHtml(user.email) : "") + ",

    ", + '

    ' + safeTextHtml + "

    ", + (code ? '
    Dein Code' + safeCode + "
    " : ""), + (actionLink ? '

    ' + safeActionLabel + "

    " : ""), + (actionLink ? '

    Falls der Button nicht funktioniert, oeffne diesen Link im Browser:
    ' + safeActionLink + "

    " : ""), + "
    ', + "Diese Nachricht wurde automatisch von " + safeStationName + " gesendet.", + "
    ", + "
    ", + "", + "" + ].join(""); + + return { subject, text, html }; +} + +function toAbsoluteUrl(baseUrl, maybeRelative) { + const value = String(maybeRelative || "").trim(); + if (!value) { + return ""; + } + if (/^https?:\/\//i.test(value)) { + return value; + } + const base = String(baseUrl || "").trim(); + if (!base) { + return value; + } + const baseTrimmed = base.replace(/\/$/, ""); + return `${baseTrimmed}/${value.replace(/^\/+/, "")}`; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + +function domainForEmail(email) { + const at = email.indexOf("@"); + return at === -1 ? "" : email.slice(at + 1).toLowerCase(); +} + +function isPrimaryDomainEmail(email) { + return domainForEmail(email) === config.primaryEmailDomain; +} + +function stationUsageDurationMs() { + return Math.max(1, Number(config.stationMaxUsageSec || 3600)) * 1000; +} + +function computeLeaseEndIso(startedAtIso) { + const startedMs = new Date(startedAtIso || Date.now()).getTime(); + return new Date(startedMs + stationUsageDurationMs()).toISOString(); +} + +function parseIsoMs(value) { + const ms = Date.parse(String(value || "")); + return Number.isFinite(ms) ? ms : null; +} + +function normalizeStationReservations(value, options = {}) { + const slotMs = stationUsageDurationMs(); + const nowMs = Number.isFinite(Number(options.nowMs)) ? Number(options.nowMs) : Date.now(); + const baseStart = Number.isFinite(Number(options.slotStartBaseMs)) ? Math.floor(Number(options.slotStartBaseMs)) : null; + let cursorMs = baseStart; + const source = Array.isArray(value) ? value : []; + const out = []; + const seenUserIds = new Set(); + for (const entry of source) { + if (!entry || typeof entry !== "object") { + continue; + } + const userId = String(entry.userId || "").trim(); + const email = String(entry.email || "").trim(); + if (!userId || !email || seenUserIds.has(userId)) { + continue; + } + seenUserIds.add(userId); + + let fromMs = parseIsoMs(entry.from); + let toMs = parseIsoMs(entry.to); + if (!(Number.isFinite(fromMs) && Number.isFinite(toMs) && toMs > fromMs)) { + if (Number.isFinite(cursorMs)) { + fromMs = cursorMs; + toMs = fromMs + slotMs; + } else { + fromMs = nowMs; + toMs = fromMs + slotMs; + } + } + cursorMs = toMs; + + out.push({ + userId, + email, + from: new Date(fromMs).toISOString(), + to: new Date(toMs).toISOString(), + createdAt: entry.createdAt ? String(entry.createdAt) : new Date(nowMs).toISOString() + }); + } + out.sort((a, b) => { + const aFrom = parseIsoMs(a.from) || 0; + const bFrom = parseIsoMs(b.from) || 0; + return aFrom - bFrom; + }); + return out; +} + +function removeReservationByUserId(reservations, userId) { + const targetId = String(userId || ""); + if (!targetId) { + return { changed: false, reservations: normalizeStationReservations(reservations) }; + } + const normalized = normalizeStationReservations(reservations); + const next = normalized.filter((entry) => String(entry.userId) !== targetId); + return { + changed: next.length !== normalized.length, + reservations: next + }; +} + +function getFirstFutureReservationStartMs(reservations, nowMs) { + let minStart = null; + for (const entry of reservations) { + const fromMs = parseIsoMs(entry.from); + if (!Number.isFinite(fromMs) || fromMs <= nowMs) { + continue; + } + if (minStart === null || fromMs < minStart) { + minStart = fromMs; + } + } + return minStart; +} + +function findActiveReservationEntry(reservations, nowMs = Date.now()) { + for (const entry of reservations) { + const fromMs = parseIsoMs(entry.from); + const toMs = parseIsoMs(entry.to); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) { + continue; + } + if (fromMs <= nowMs && nowMs < toMs) { + return entry; + } + } + return null; +} + +function pruneExpiredReservations(reservations, nowMs = Date.now()) { + const next = []; + for (const entry of reservations) { + const toMs = parseIsoMs(entry.to); + if (!Number.isFinite(toMs) || toMs <= nowMs) { + continue; + } + next.push(entry); + } + return next; +} + +function getReservationBaseStartMs(nowMs = Date.now()) { + const currentEndsAtMs = parseIsoMs(runtime.station && runtime.station.endsAt); + if (runtime.station && runtime.station.isInUse && Number.isFinite(currentEndsAtMs) && currentEndsAtMs > nowMs) { + return currentEndsAtMs; + } + return nowMs + stationUsageDurationMs(); +} + +function getNormalizedStationReservations(nowMs = Date.now()) { + let normalized = normalizeStationReservations(runtime.station && runtime.station.reservations, { + nowMs, + slotStartBaseMs: getReservationBaseStartMs(nowMs) + }); + normalized = pruneExpiredReservations(normalized, nowMs); + if (runtime.station && runtime.station.isInUse && runtime.station.activeByUserId && runtime.station.activeByEmail) { + const activeEntry = findActiveReservationEntry(normalized, nowMs); + if (!activeEntry || String(activeEntry.userId || "") !== String(runtime.station.activeByUserId || "")) { + const withoutOwner = removeReservationByUserId(normalized, runtime.station.activeByUserId).reservations; + const fromMs = parseIsoMs(runtime.station.startedAt) || nowMs; + let toMs = parseIsoMs(runtime.station.endsAt) || (fromMs + stationUsageDurationMs()); + if (toMs <= fromMs) { + toMs = fromMs + 1000; + } + normalized = normalizeStationReservations([ + { + userId: String(runtime.station.activeByUserId || ""), + email: String(runtime.station.activeByEmail || "unknown"), + from: new Date(fromMs).toISOString(), + to: new Date(toMs).toISOString(), + createdAt: runtime.station.startedAt || new Date(nowMs).toISOString() + }, + ...withoutOwner + ], { + nowMs, + slotStartBaseMs: getReservationBaseStartMs(nowMs) + }); + } + } + return normalized; +} + +function getReservationAccessLock(nowMs = Date.now()) { + const reservations = getNormalizedStationReservations(nowMs); + const activeEntry = findActiveReservationEntry(reservations, nowMs); + return { + reservations, + activeEntry, + locked: Boolean(activeEntry) + }; +} + +function hasStationReservationAccess(user, lockInfo) { + if (!lockInfo || !lockInfo.activeEntry) { + return true; + } + if (user && user.role === "admin") { + return true; + } + return String(lockInfo.activeEntry.userId || "") === String(user && user.id ? user.id : ""); +} + +function buildCurrentSlotForUser(user, reservations, nowMs = Date.now()) { + const cleaned = removeReservationByUserId(reservations, user.id).reservations; + const nextStartMs = getFirstFutureReservationStartMs(cleaned, nowMs); + let toMs = nowMs + stationUsageDurationMs(); + if (Number.isFinite(nextStartMs)) { + toMs = Math.min(toMs, nextStartMs); + } + if (toMs <= nowMs) { + toMs = nowMs + 1000; + } + const current = { + userId: String(user.id || ""), + email: String(user.email || "unknown"), + from: new Date(nowMs).toISOString(), + to: new Date(toMs).toISOString(), + createdAt: new Date(nowMs).toISOString() + }; + return normalizeStationReservations([current, ...cleaned], { + nowMs, + slotStartBaseMs: getReservationBaseStartMs(nowMs) + }); +} + +async function persistStationReservationsIfChanged(nextReservations, meta = {}) { + const currentRaw = Array.isArray(runtime.station && runtime.station.reservations) + ? runtime.station.reservations + : []; + const changed = JSON.stringify(currentRaw) !== JSON.stringify(nextReservations); + if (!changed) { + return false; + } + runtime.station = { + ...runtime.station, + reservations: nextReservations, + updatedAt: new Date().toISOString(), + lastAction: meta.lastAction || runtime.station.lastAction + }; + await writeJson(files.station, runtime.station); + return true; +} + +async function autoActivateReservationSlotIfNeeded() { + return withLock(async () => autoActivateReservationSlotIfNeededUnlocked({})); +} + +async function autoActivateReservationSlotIfNeededUnlocked(options = {}) { + if (runtime.currentActivationJobId || (runtime.station && runtime.station.isInUse)) { + return false; + } + const excludeUserId = options && options.excludeUserId ? String(options.excludeUserId) : ""; + const nowMs = Date.now(); + const reservations = getNormalizedStationReservations(nowMs); + await persistStationReservationsIfChanged(reservations, { lastAction: "reserve-sync" }); + const activeEntry = findActiveReservationEntry(reservations, nowMs); + if (!activeEntry) { + return false; + } + if (excludeUserId && String(activeEntry.userId || "") === excludeUserId) { + return false; + } + const user = runtime.users.find((entry) => String(entry.id || "") === String(activeEntry.userId || "")); + if (!user) { + return false; + } + try { + await executeCapability("station.activate", "activate", { userEmail: user.email }, { user, skipTxSafety: true }); + } catch (error) { + await appendAudit("station.activate.auto_slot.failed", user, { + error: String(error && error.message ? error.message : error) + }); + return false; + } + + runtime.station = { + ...runtime.station, + isInUse: true, + activeByUserId: user.id, + activeByEmail: user.email, + startedAt: activeEntry.from, + endsAt: activeEntry.to, + reservations, + updatedAt: new Date().toISOString(), + lastAction: "activate-auto-slot" + }; + await writeJson(files.station, runtime.station); + await syncStationAccessPolicyOwner(user, user.email); + await appendAudit("station.activate.auto_slot", user, { + from: activeEntry.from, + to: activeEntry.to + }); + broadcastEvent("station.status.changed", buildStationStatusView()); + return true; +} + +async function reconcileStationLeaseOnStartup() { + if (!runtime.station) { + return; + } + let changed = false; + const normalizedReservations = getNormalizedStationReservations(Date.now()); + const reservationsChanged = !Array.isArray(runtime.station.reservations) + || JSON.stringify(normalizedReservations) !== JSON.stringify(runtime.station.reservations); + if (reservationsChanged) { + runtime.station.reservations = normalizedReservations; + changed = true; + } + if (!("endsAt" in runtime.station)) { + runtime.station.endsAt = null; + changed = true; + } + if (runtime.station.isInUse && runtime.station.startedAt && !runtime.station.endsAt) { + runtime.station.endsAt = computeLeaseEndIso(runtime.station.startedAt); + changed = true; + } + if (!runtime.station.isInUse && runtime.station.endsAt) { + runtime.station.endsAt = null; + changed = true; + } + if (changed) { + runtime.station.updatedAt = new Date().toISOString(); + await writeJson(files.station, runtime.station); + } + await enforceStationLeaseTimeout(); +} + +async function enforceStationLeaseTimeout() { + if (!runtime.station) { + return; + } + if (!runtime.station.isInUse || runtime.currentActivationJobId) { + await autoActivateReservationSlotIfNeeded(); + return; + } + if (!runtime.station.endsAt && runtime.station.startedAt) { + runtime.station.endsAt = computeLeaseEndIso(runtime.station.startedAt); + runtime.station.updatedAt = new Date().toISOString(); + await writeJson(files.station, runtime.station); + broadcastEvent("station.status.changed", buildStationStatusView()); + } + if (!runtime.station.endsAt) { + return; + } + if (Date.now() < new Date(runtime.station.endsAt).getTime()) { + return; + } + await releaseStationByTimeout(); +} + +async function releaseStationByTimeout() { + return withLock(async () => { + if (!runtime.station.isInUse || runtime.currentActivationJobId) { + return; + } + const activeEmail = runtime.station.activeByEmail || "unknown"; + const activeUser = runtime.users.find((entry) => entry.id === runtime.station.activeByUserId) + || { id: null, email: activeEmail, role: "operator" }; + let deactivateError = null; + try { + await safeShutdownStationSession(activeUser, "lease-timeout"); + } catch (error) { + deactivateError = String(error && error.message ? error.message : error); + await appendAudit("station.deactivate.timeout.failed", activeUser, { deactivateError }); + return; + } + + const keptReservations = getNormalizedStationReservations(Date.now()); + runtime.station = { + ...runtime.station, + isInUse: false, + activeByUserId: null, + activeByEmail: null, + startedAt: null, + endsAt: null, + reservations: keptReservations, + updatedAt: new Date().toISOString(), + lastAction: deactivateError ? "deactivate-timeout-forced" : "deactivate-timeout" + }; + await writeJson(files.station, runtime.station); + await appendAudit("station.deactivate.timeout", activeUser, { + maxUsageSec: config.stationMaxUsageSec, + deactivateError + }); + broadcastEvent("station.deactivate.timeout", { + email: activeEmail, + deactivateError, + maxUsageSec: config.stationMaxUsageSec + }); + const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ + excludeUserId: activeUser && activeUser.id ? String(activeUser.id) : "" + }); + if (!autoActivated) { + broadcastEvent("station.status.changed", buildStationStatusView()); + } + }); +} + +async function handleActivationStart(res, user) { + return withLock(async () => { + const nowMs = Date.now(); + const lockInfo = getReservationAccessLock(nowMs); + if (!hasStationReservationAccess(user, lockInfo)) { + return sendError(res, 403, "station.slot_owner_only", "Nur der reservierte Benutzer darf diesen Slot aktivieren/deaktivieren"); + } + if (runtime.swrRun && runtime.swrRun.running) { + return sendError(res, 409, "swr.running", "Stationaktivierung ist waehrend laufendem SWR-Check gesperrt"); + } + if (runtime.currentActivationJobId) { + const current = runtime.jobs.get(runtime.currentActivationJobId); + if (current && current.startedBy && String(current.startedBy) !== String(user.email || "")) { + return sendError(res, 409, "station.in_use", "Stationaktivierung laeuft bereits fuer einen anderen Benutzer"); + } + return sendJson(res, 202, { ok: true, pending: true, job: current }); + } + + if (!runtime.station.stationOnline) { + return sendError(res, 409, "station.offline", "Station ist offline"); + } + if (runtime.station.isInUse) { + return sendError(res, 409, "station.in_use", "Station wird bereits verwendet"); + } + + const activeReservation = lockInfo.activeEntry; + let reservations = lockInfo.reservations; + if (activeReservation) { + reservations = normalizeStationReservations(reservations.map((entry) => { + if (String(entry.userId) !== String(user.id || "")) { + return entry; + } + return { + ...entry, + email: String(user.email || entry.email || "unknown") + }; + }), { nowMs, slotStartBaseMs: getReservationBaseStartMs(nowMs) }); + } else { + reservations = buildCurrentSlotForUser(user, reservations, nowMs); + } + await persistStationReservationsIfChanged(reservations, { lastAction: "reserve-current" }); + + const selectedSlot = findActiveReservationEntry(reservations, nowMs); + if (!selectedSlot || String(selectedSlot.userId || "") !== String(user.id || "")) { + return sendError(res, 409, "station.slot_unavailable", "Aktueller Slot ist nicht verfuegbar"); + } + + let txState = await readTransmitState(user); + if (txState.txActive && shouldAutoDisableTxBeforeActivation()) { + try { + await executeCapability("tx.control", "disableTx", { source: "activation-precheck" }, { user, skipTxSafety: true }); + await appendAudit("tx.disable", user, { source: "activation-precheck", auto: true }); + txState = await readTransmitState(user); + } catch (error) { + await appendAudit("tx.disable.failed", user, { + source: "activation-precheck", + error: String(error && error.message ? error.message : error) + }); + } + } + if (txState.txActive) { + return sendError(res, 409, "tx.switch_locked", "Umschalten ist waehrend aktivem Senden gesperrt", { + ...txState, + hint: "TX muss zuerst deaktiviert werden" + }); + } + + const job = { + id: `job_${crypto.randomUUID()}`, + type: "station.activate", + status: "running", + phase: "swr-check", + percent: 0, + etaSec: null, + startedAt: new Date().toISOString(), + startedBy: user.email, + startedByUserId: user.id, + slotFrom: selectedSlot.from, + slotTo: selectedSlot.to, + finishedAt: null, + error: null + }; + runtime.currentActivationJobId = job.id; + runtime.jobs.set(job.id, job); + broadcastEvent("station.activation.started", { job }); + appendAudit("station.activate.start", user, { jobId: job.id }).catch(() => {}); + + runActivationJob(job, user).catch((error) => { + console.error("Activation job failed", error); + }); + + return sendJson(res, 202, { ok: true, pending: true, job }); + }); +} + +async function runActivationJob(job, user) { + let progressTimer = null; + try { + const expectedDurationMs = Number(getPluginSetting("rms.vswr.nanovna", "expectedDurationMs", process.env.SWR_CHECK_DURATION_MS || 54000)); + const startedMs = Date.now(); + progressTimer = setInterval(() => { + const elapsed = Date.now() - startedMs; + const pct = Math.max(0, Math.min(99, Math.floor((elapsed / Math.max(1, expectedDurationMs)) * 100))); + job.percent = pct; + job.etaSec = Math.max(0, Math.ceil((expectedDurationMs - elapsed) / 1000)); + broadcastEvent("station.activation.progress", { + jobId: job.id, + phase: job.phase, + percent: job.percent, + etaSec: job.etaSec + }); + }, 1000); + + let swrRunError = null; + let swrReport = null; + const swrToken = await startGlobalSwrRun(user, { + source: "station-activation", + phase: "swr-check", + expectedDurationMs + }); + try { + await executeCapability("vswr.run", "runCheck", {}, { user }); + swrReport = await buildSwrReportView(user); + broadcastEvent("swr.report.changed", { + source: "station-activation", + generatedAt: swrReport && swrReport.generatedAt ? swrReport.generatedAt : null, + overallStatus: swrReport && swrReport.overallStatus ? swrReport.overallStatus : null + }); + } catch (error) { + swrRunError = String(error && error.message ? error.message : error); + await appendAudit("station.activate.swr_failed_continue", user, { + jobId: job.id, + error: swrRunError + }); + } finally { + await finishGlobalSwrRun(swrToken, { + status: swrRunError ? "failed" : "succeeded", + error: swrRunError + }); + } + + job.phase = "switch-to-transceiver"; + await executeCapability("station.activate", "activate", { userEmail: user.email }, { user, skipTxSafety: true }); + + await withLock(async () => { + const nowMs = Date.now(); + const startedAt = job.slotFrom || new Date(nowMs).toISOString(); + const endsAt = job.slotTo || computeLeaseEndIso(startedAt); + const reservationsAfterActivate = normalizeStationReservations(runtime.station.reservations, { + nowMs, + slotStartBaseMs: getReservationBaseStartMs(nowMs) + }); + runtime.station = { + ...runtime.station, + isInUse: true, + activeByUserId: user.id, + activeByEmail: user.email, + startedAt, + endsAt, + reservations: reservationsAfterActivate, + updatedAt: new Date().toISOString(), + lastAction: "activate" + }; + await writeJson(files.station, runtime.station); + runtime.currentActivationJobId = null; + }); + + await syncStationAccessPolicyOwner(user, user.email); + + job.status = "succeeded"; + job.percent = 100; + job.etaSec = 0; + job.finishedAt = new Date().toISOString(); + broadcastEvent("station.activation.completed", { jobId: job.id, swrReport }); + broadcastEvent("station.status.changed", buildStationStatusView()); + await appendAudit("station.activate.done", user, { + jobId: job.id, + swrFailed: Boolean(swrRunError), + swrError: swrRunError + }); + } catch (error) { + try { + await withLock(async () => { + runtime.currentActivationJobId = null; + runtime.station = { + ...runtime.station, + updatedAt: new Date().toISOString(), + lastAction: "activate-failed" + }; + await writeJson(files.station, runtime.station); + }); + } catch { + runtime.currentActivationJobId = null; + } + job.status = "failed"; + job.finishedAt = new Date().toISOString(); + job.error = String(error && error.message ? error.message : error); + broadcastEvent("station.activation.failed", { jobId: job.id, error: job.error }); + await appendAudit("station.activate.failed", user, { jobId: job.id, error: job.error }); + } finally { + if (progressTimer) { + clearInterval(progressTimer); + } + if (runtime.currentActivationJobId === job.id) { + runtime.currentActivationJobId = null; + } + } +} + +async function runSwrCheckAndBuildReport(user) { + const swrToken = await startGlobalSwrRun(user, { + source: "manual", + phase: "swr-check", + expectedDurationMs: getExpectedSWRDurationMs() + }); + let runError = null; + try { + const result = await executeCapability("vswr.run", "runCheck", {}, { user }); + const report = await buildSwrReportView(user); + return { result, report }; + } catch (error) { + runError = error; + throw error; + } finally { + await finishGlobalSwrRun(swrToken, { + status: runError ? "failed" : "succeeded", + error: runError ? String(runError && runError.message ? runError.message : runError) : null + }); + } +} + +async function handleStationRelease(res, user) { + return withLock(async () => { + const lockInfo = getReservationAccessLock(Date.now()); + if (!hasStationReservationAccess(user, lockInfo)) { + return sendError(res, 403, "station.slot_owner_only", "Nur der reservierte Benutzer darf diesen Slot aktivieren/deaktivieren"); + } + if (runtime.currentActivationJobId) { + return sendError(res, 423, "station.activation_running", "Waehrend SWR-Check nicht moeglich"); + } + if (!runtime.station.isInUse) { + return sendJson(res, 200, { ok: true, status: buildStationStatusView() }); + } + if (runtime.station.activeByUserId !== user.id && user.role !== "admin") { + return sendError(res, 403, "station.not_owner", "Nur aktiver Benutzer oder Admin darf freigeben"); + } + + try { + await safeShutdownStationSession(user, "manual-release"); + } catch (error) { + if (error && error.code === "TX_DISABLE_FAILED") { + return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null); + } + throw error; + } + + runtime.station = { + ...runtime.station, + isInUse: false, + activeByUserId: null, + activeByEmail: null, + startedAt: null, + endsAt: null, + reservations: lockInfo.reservations, + updatedAt: new Date().toISOString(), + lastAction: "deactivate" + }; + await writeJson(files.station, runtime.station); + await appendAudit("station.deactivate", user, null); + const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ + excludeUserId: user && user.id ? String(user.id) : "" + }); + if (!autoActivated) { + broadcastEvent("station.status.changed", buildStationStatusView()); + } + return sendJson(res, 200, { ok: true, status: buildStationStatusView() }); + }); +} + +async function handleReserveNextSlot(res, user) { + return withLock(async () => { + const nowMs = Date.now(); + const activation = getCurrentActivationView(); + const stationOccupied = Boolean(runtime.station.isInUse || activation.running); + if (!stationOccupied) { + return sendError(res, 409, "station.not_occupied", "Reservierung nur moeglich solange die Station belegt ist"); + } + const reservations = getNormalizedStationReservations(nowMs); + const existing = reservations.find((entry) => String(entry.userId) === String(user.id || "")); + if (existing) { + return sendJson(res, 200, { ok: true, alreadyReserved: true, status: buildStationStatusView() }); + } + + const lastToMs = reservations.reduce((max, entry) => { + const toMs = parseIsoMs(entry.to); + return Number.isFinite(toMs) ? Math.max(max, toMs) : max; + }, 0); + const startMs = lastToMs > 0 ? lastToMs : getReservationBaseStartMs(nowMs); + const endMs = startMs + stationUsageDurationMs(); + + const nextReservations = [ + ...reservations, + { + userId: String(user.id || ""), + email: String(user.email || "unknown"), + from: new Date(startMs).toISOString(), + to: new Date(endMs).toISOString(), + createdAt: new Date(nowMs).toISOString() + } + ]; + runtime.station = { + ...runtime.station, + reservations: nextReservations, + updatedAt: new Date().toISOString(), + lastAction: "reserve-next" + }; + await writeJson(files.station, runtime.station); + await appendAudit("station.reserve.next", user, { queueLength: nextReservations.length }); + broadcastEvent("station.status.changed", buildStationStatusView()); + return sendJson(res, 200, { ok: true, status: buildStationStatusView() }); + }); +} + +async function handleDeleteOwnReservation(res, user) { + return withLock(async () => { + const nowMs = Date.now(); + const reservations = getNormalizedStationReservations(nowMs); + const ownEntry = reservations.find((entry) => String(entry.userId || "") === String(user.id || "")); + if (!ownEntry) { + return sendError(res, 404, "station.reservation.not_found", "Keine eigene Reservierung gefunden"); + } + const removed = removeReservationByUserId(reservations, user.id); + if (!removed.changed) { + return sendError(res, 404, "station.reservation.not_found", "Keine eigene Reservierung gefunden"); + } + + const ownFromMs = parseIsoMs(ownEntry.from); + const ownToMs = parseIsoMs(ownEntry.to); + const deletingActiveOwnSlot = Number.isFinite(ownFromMs) + && Number.isFinite(ownToMs) + && ownFromMs <= nowMs + && nowMs < ownToMs; + + if (deletingActiveOwnSlot && runtime.station.isInUse && String(runtime.station.activeByUserId || "") === String(user.id || "")) { + try { + await safeShutdownStationSession(user, "reservation-delete-current"); + } catch (error) { + if (error && error.code === "TX_DISABLE_FAILED") { + return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null); + } + throw error; + } + runtime.station = { + ...runtime.station, + isInUse: false, + activeByUserId: null, + activeByEmail: null, + startedAt: null, + endsAt: null, + reservations: removed.reservations, + updatedAt: new Date().toISOString(), + lastAction: "reserve-remove-current" + }; + await writeJson(files.station, runtime.station); + await appendAudit("station.reserve.remove", user, { queueLength: removed.reservations.length, currentSlot: true }); + const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ + excludeUserId: user && user.id ? String(user.id) : "" + }); + if (!autoActivated) { + broadcastEvent("station.status.changed", buildStationStatusView()); + } + return sendJson(res, 200, { ok: true, status: buildStationStatusView() }); + } + + runtime.station = { + ...runtime.station, + reservations: removed.reservations, + updatedAt: new Date().toISOString(), + lastAction: "reserve-remove" + }; + await writeJson(files.station, runtime.station); + await appendAudit("station.reserve.remove", user, { queueLength: removed.reservations.length }); + broadcastEvent("station.status.changed", buildStationStatusView()); + return sendJson(res, 200, { ok: true, status: buildStationStatusView() }); + }); +} + +async function safeShutdownStationSession(user, reason) { + if (runtime.pttActive) { + try { + await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true }); + } catch { + // best effort + } + try { + await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true }); + } catch { + // best effort + } + runtime.pttActive = false; + } + const txStateBefore = await readTransmitState(user); + if (txStateBefore.txActive) { + try { + await executeCapability("tx.control", "disableTx", { reason }, { user, skipTxSafety: true }); + await appendAudit("tx.disable", user, { reason, source: "session-shutdown" }); + } catch (error) { + const wrapped = new Error("TX konnte vor Session-Ende nicht deaktiviert werden"); + wrapped.code = "TX_DISABLE_FAILED"; + wrapped.details = { + reason, + error: String(error && error.message ? error.message : error) + }; + throw wrapped; + } + } + + await executeCapability("tx.audio", "audioDisconnect", { reason: `session-shutdown:${reason}` }, { user, skipTxSafety: true }).catch(() => {}); + + await revokeOpenWebRxAccess(user, reason); + await executeCapability("station.deactivate", "deactivate", { userEmail: user.email }, { user }); +} + +async function revokeOpenWebRxAccess(user, reason) { + const provider = runtime.pluginState.providers["openwebrx.access.issue"]; + if (!provider) { + return; + } + try { + await executeCapability("openwebrx.access.issue", "revokeOwner", { + ownerUserId: runtime.station.activeByUserId, + reason + }, { user, skipTxSafety: true }); + } catch { + // optional capability + } + try { + await executeCapability("openwebrx.service.control", "serviceStop", { reason }, { user, skipTxSafety: true }); + } catch { + // optional capability + } + clearOpenWebRxSession(); + await clearStationAccessPolicyOwner(user); +} + +async function handleOpenWebRxSessionIssue(res, user) { + if (!hasRole(user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX nur fuer den aktiven Stationsbenutzer"); + } + const hasProvider = runtime.pluginState.providers["openwebrx.access.issue"]; + if (!hasProvider) { + return sendError(res, 409, "openwebrx.not_configured", "OpenWebRX Provider nicht konfiguriert"); + } + + await ensureOpenWebRxSdrPath(user, { force: true }); + + const result = await executeCapability("openwebrx.access.issue", "issueAccess", { + userId: user.id, + userEmail: user.email, + ownerUserId: runtime.station.activeByUserId, + stationEndsAt: runtime.station.endsAt + }, { user, skipTxSafety: true }); + + await syncStationAccessPolicyOwner(user, user.email); + + try { + await executeCapability("openwebrx.service.control", "serviceStart", { reason: "session-issue" }, { user, skipTxSafety: true }); + } catch { + // optional capability + } + + markOpenWebRxSession(user, result); + await ensureOpenWebRxInitialAntennaRoute(user); + + return sendJson(res, 200, { + ok: true, + session: result + }); +} + +async function ensureOpenWebRxInitialAntennaRoute(user) { + if (runtime.pttActive) { + return; + } + const rfrouteProvider = runtime.pluginState.providers["rfroute.set"]; + if (!rfrouteProvider) { + return; + } + const bandState = await readOpenWebRxBandState(user); + const antennaRoute = normalizeAntennaRoute(bandState && bandState.antennaRoute ? bandState.antennaRoute : ""); + if (!antennaRoute) { + return; + } + try { + await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true }); + runtime.openWebRxAntennaRoute = antennaRoute; + runtime.txFollowRoute = antennaRoute; + } catch { + // keep session issue resilient; antenna follow is best effort + } +} + +async function handleOpenWebRxBands(res, user) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Baender nur fuer aktiven Stationsbenutzer"); + } + const provider = runtime.pluginState.providers["openwebrx.band.read"]; + if (!provider) { + return sendJson(res, 200, { ok: true, bands: [], selectedBand: null }); + } + const result = await executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true }); + return sendJson(res, 200, { ok: true, ...result }); +} + +async function handleOpenWebRxBandSelect(res, user, body) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Bandwechsel nur fuer aktiven Stationsbenutzer"); + } + const provider = runtime.pluginState.providers["openwebrx.band.set"]; + if (!provider) { + return sendError(res, 409, "openwebrx.band.not_configured", "OpenWebRX Band Plugin nicht konfiguriert"); + } + const band = String(body && body.band ? body.band : "").trim(); + if (!band) { + return sendError(res, 400, "openwebrx.band.missing", "Band fehlt"); + } + if (runtime.pttActive) { + return sendError(res, 409, "openwebrx.ptt.active", "Bandwechsel waehrend aktivem PTT ist gesperrt"); + } + try { + const result = await executeCapability("openwebrx.band.set", "setBand", { band }, { user, skipTxSafety: true }); + const antennaRoute = normalizeAntennaRoute(result && result.antennaRoute); + if (!antennaRoute) { + return sendError(res, 409, "band.route_missing", `Keine antennaRoute fuer Band ${band} konfiguriert`); + } + const rfrouteProvider = runtime.pluginState.providers["rfroute.set"]; + if (!rfrouteProvider) { + return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert"); + } + await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true }); + runtime.txFollowRoute = antennaRoute; + runtime.openWebRxAntennaRoute = antennaRoute; + await appendAudit("openwebrx.band.set", user, { band, result: { centerFreqHz: result.centerFreqHz || null, skipped: Boolean(result.skipped) } }); + return sendJson(res, 200, { ok: true, result }); + } catch (error) { + const message = String(error && error.message ? error.message : error); + if ((error && error.code === "ENOENT") || message.toLowerCase().includes("config_webrx.py")) { + return sendError(res, 409, "openwebrx.band.config_missing", "Bandwechsel-Config fehlt (OPENWEBRX_CONFIG_PATH/SET_CMD pruefen)"); + } + return sendError(res, 409, "openwebrx.band.set_failed", message); + } +} + +async function handleOpenWebRxTxEnable(res, user) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer"); + } + try { + const result = await executeCapability("tx.control", "enableTx", { source: "openwebrx" }, { user, skipTxSafety: true }); + if (!runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) { + await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true }); + runtime.txFollowRoute = "rx"; + } + await appendAudit("tx.enable", user, { source: "openwebrx" }); + return sendJson(res, 200, { ok: true, result }); + } catch (error) { + return sendError(res, 409, "tx.enable_failed", String(error && error.message ? error.message : error)); + } +} + +async function handleOpenWebRxTxDisable(res, user) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX nur fuer aktiven Stationsbenutzer"); + } + if (runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) { + try { + await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true }); + } catch (error) { + return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error)); + } + await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true }); + runtime.pttActive = false; + runtime.txFollowRoute = "rx"; + } + try { + const result = await executeCapability("tx.control", "disableTx", { source: "openwebrx-ptt" }, { user, skipTxSafety: true }); + await appendAudit("tx.disable", user, { source: "openwebrx-ptt" }); + return sendJson(res, 200, { ok: true, result }); + } catch (error) { + return sendError(res, 409, "tx.disable_failed", String(error && error.message ? error.message : error)); + } +} + +async function handleOpenWebRxPttDown(res, user, input = {}) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX PTT nur fuer aktiven Stationsbenutzer"); + } + const provider = runtime.pluginState.providers["rfroute.set"]; + if (!provider) { + return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert"); + } + if (runtime.pttActive) { + return sendJson(res, 200, { ok: true, result: { message: "PTT bereits aktiv" } }); + } + const txState = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true }); + if (!txState || !txState.txActive) { + return sendError(res, 409, "tx.not_enabled", "TX ist nicht aktiviert"); + } + + const bandState = await readOpenWebRxBandState(user); + const selectedBandConfigId = String(bandState && (bandState.selectedBandConfigId || bandState.selectedBand) ? (bandState.selectedBandConfigId || bandState.selectedBand) : "").trim(); + if (isPttBlockedForBandConfigId(selectedBandConfigId)) { + return sendError(res, 409, "openwebrx.ptt.band_config_blocked", "PTT fuer dieses Band ist gesperrt"); + } + const clientLiveState = parseOpenWebRxLiveState(input || {}); + if (!clientLiveState.frequencyHz || !clientLiveState.mode) { + return sendError(res, 409, "openwebrx.ptt.live_state_missing", "PTT gesperrt: Live-Frequenz/Mode fehlt"); + } + const serverLiveState = getOpenWebRxLiveStateForUser(user.id); + if (!serverLiveState) { + return sendError(res, 409, "openwebrx.ptt.live_state_missing", "PTT gesperrt: Server-Live-State fehlt"); + } + if (!openWebRxLiveStatesMatch(clientLiveState, serverLiveState)) { + return sendError(res, 409, "openwebrx.ptt.live_state_mismatch", "PTT gesperrt: Live-Daten stimmen nicht ueberein"); + } + bandState.centerFreqHz = clientLiveState.frequencyHz; + bandState.startMod = clientLiveState.mode; + const antennaRoute = normalizeAntennaRoute(bandState.antennaRoute); + if (!antennaRoute) { + return sendError(res, 409, "band.route_missing", "Antenne fuer aktuelles Band ist nicht konfiguriert"); + } + + await executeCapability("rfroute.set", "setRoute", { route: antennaRoute }, { user, skipTxSafety: true }); + runtime.openWebRxAntennaRoute = antennaRoute; + await executeCapability("rfroute.set", "setRoute", { route: "tx" }, { user, skipTxSafety: true }); + let result; + try { + result = await executeCapability("microham.ptt", "pttDown", { + bandState + }, { user, skipTxSafety: true }); + } catch (error) { + return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error)); + } + runtime.pttActive = true; + runtime.txFollowRoute = "tx"; + await appendAudit("openwebrx.ptt.down", user, { result: result && result.direction ? result.direction : "down" }); + return sendJson(res, 200, { ok: true, result }); +} + +async function handleOpenWebRxPttUp(res, user) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX PTT nur fuer aktiven Stationsbenutzer"); + } + const provider = runtime.pluginState.providers["rfroute.set"]; + if (!provider) { + return sendError(res, 409, "rfroute.not_configured", "RFROUTE Plugin nicht konfiguriert"); + } + if (!runtime.pttActive) { + return sendJson(res, 200, { ok: true, result: { message: "PTT bereits inaktiv" } }); + } + try { + await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true }); + } catch (error) { + return sendError(res, 409, "openwebrx.ptt.command_failed", String(error && error.message ? error.message : error)); + } + const result = await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true }); + runtime.pttActive = false; + runtime.txFollowRoute = "rx"; + await appendAudit("openwebrx.ptt.up", user, { result: result && result.message ? result.message : null }); + return sendJson(res, 200, { ok: true, result }); +} + +async function handleOpenWebRxTxStatus(res, user) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX TX Status nur fuer aktiven Stationsbenutzer"); + } + const state = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true }); + await maybeFollowTxRoute(user, Boolean(state && state.txActive)); + let pttConfigured = false; + try { + const pttState = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true }); + pttConfigured = Boolean(pttState && pttState.commandConfigured); + } catch { + pttConfigured = false; + } + return sendJson(res, 200, { + ok: true, + txActive: Boolean(state && state.txActive), + powerCommandConfigured: isPowerControlConfigured(), + pttCommandConfigured: pttConfigured, + state: state || null + }); +} + +async function handleOpenWebRxRotorStatus(res, user) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Rotorstatus nur fuer aktiven Stationsbenutzer"); + } + return sendJson(res, 200, { + ok: true, + rotor: normalizeRotorPayload(runtime.rotor) + }); +} + +async function handleOpenWebRxRotorSet(res, user, body, source = "api") { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Rotorsteuerung nur fuer aktiven Stationsbenutzer"); + } + if (!rotorSetEnabled()) { + return sendError(res, 409, "rotor.set_disabled", "Rotorsteuerung ist derzeit deaktiviert"); + } + const trigger = String(body && body.trigger ? body.trigger : "").trim().toLowerCase(); + if (trigger !== "user") { + return sendError(res, 409, "rotor.set_guard", "Rotor-Set nur nach expliziter Benutzeraktion erlaubt"); + } + const targetRaw = body && (body.target ?? body.azimuth ?? body.deg); + if (targetRaw === "" || targetRaw === null || targetRaw === undefined) { + return sendError(res, 400, "rotor.target.invalid", "target muss zwischen 0 und 360 sein"); + } + const target = Number(targetRaw); + if (!Number.isFinite(target) || target < 0 || target > 360) { + return sendError(res, 400, "rotor.target.invalid", "target muss zwischen 0 und 360 sein"); + } + + const enqueueResult = enqueueRotorTarget(target, user, source); + void processRotorQueue(); + await appendAudit("openwebrx.rotor.set.requested", user, { + source, + target: enqueueResult.pendingTarget, + queued: enqueueResult.queued, + replacedPending: enqueueResult.replacedPending + }); + return sendJson(res, 200, { + ok: true, + accepted: true, + queued: enqueueResult.queued, + replacedPending: enqueueResult.replacedPending, + result: { + message: enqueueResult.queued ? "Rotorziel angenommen (queued)" : "Rotorziel angenommen" + }, + rotor: normalizeRotorPayload(runtime.rotor) + }); +} + +function enqueueRotorTarget(target, user, source) { + const normalizedTarget = normalizeRotorAzimuth(target); + if (normalizedTarget === null) { + throw new Error("target muss zwischen 0 und 360 sein"); + } + const hadPending = getPendingRotorTarget() !== null; + runtime.rotor.pendingTargetAzimuth = normalizedTarget; + runtime.rotor.pendingRequestedByUserId = user && user.id ? String(user.id) : null; + runtime.rotor.pendingSource = String(source || "api"); + runtime.rotor.phase = runtime.rotor.commandInProgress ? "queued" : "accepted"; + runtime.rotor.updatedAt = new Date().toISOString(); + return { + queued: Boolean(runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive), + replacedPending: hadPending, + pendingTarget: normalizedTarget + }; +} + +async function processRotorQueue() { + if (runtime.rotor.queueWorkerActive) { + return; + } + runtime.rotor.queueWorkerActive = true; + try { + while (getPendingRotorTarget() !== null) { + const target = Number(getPendingRotorTarget()); + const requestedByUserId = runtime.rotor.pendingRequestedByUserId; + const source = runtime.rotor.pendingSource || "api"; + runtime.rotor.pendingTargetAzimuth = null; + runtime.rotor.pendingRequestedByUserId = null; + runtime.rotor.pendingSource = null; + runtime.rotor.phase = "executing"; + + try { + const result = await setNativeRotorAzimuth(target); + runtime.rotor.lastResult = result && result.message ? String(result.message) : `Rotor auf ${Math.round(target)} Grad gesetzt`; + runtime.rotor.lastError = null; + const auditUser = requestedByUserId + ? (runtime.users.find((entry) => String(entry.id) === String(requestedByUserId)) || { id: String(requestedByUserId), email: "openwebrx-owner", role: "operator" }) + : null; + await appendAudit("openwebrx.rotor.set", auditUser, { source, target, result: runtime.rotor.lastResult }); + } catch (error) { + runtime.rotor.lastError = String(error && error.message ? error.message : error); + runtime.rotor.lastResult = null; + } + + runtime.rotor.updatedAt = new Date().toISOString(); + runtime.rotor.phase = getPendingRotorTarget() !== null ? "queued" : "idle"; + } + } finally { + runtime.rotor.queueWorkerActive = false; + if (!runtime.rotor.commandInProgress && getPendingRotorTarget() === null) { + runtime.rotor.phase = "idle"; + } + } +} + +async function readOpenWebRxBandState(user) { + const provider = runtime.pluginState.providers["openwebrx.band.read"]; + if (!provider) { + return { selectedBandConfigId: null, selectedBand: null, antennaRoute: "", centerFreqHz: null, startMod: null }; + } + + try { + const state = await executeCapability("openwebrx.band.read", "getState", {}, { user, skipTxSafety: true }); + if (state && typeof state === "object") { + return { + selectedBandConfigId: String(state.selectedBand || "").trim() || null, + selectedBand: String(state.selectedBand || "").trim() || null, + antennaRoute: String(state.antennaRoute || "").trim().toLowerCase(), + centerFreqHz: Number.isFinite(Number(state.centerFreqHz)) ? Number(state.centerFreqHz) : null, + startMod: String(state.startMod || "").trim().toLowerCase() || null + }; + } + } catch { + // fallback to getBands + } + + const result = await executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true }); + const selectedBandConfigId = String(result && result.selectedBand ? result.selectedBand : "").trim() || null; + let antennaRoute = ""; + let centerFreqHz = null; + let startMod = null; + const bands = Array.isArray(result && result.bands) ? result.bands : []; + if (selectedBandConfigId) { + const selected = bands.find((entry) => { + if (!entry || typeof entry !== "object") { + return false; + } + return String(entry.band || entry.id || "").trim() === selectedBandConfigId; + }); + if (selected && typeof selected === "object") { + antennaRoute = String(selected.antennaRoute || "").trim().toLowerCase(); + centerFreqHz = Number.isFinite(Number(selected.centerFreqHz)) ? Number(selected.centerFreqHz) : null; + startMod = String(selected.startMod || "").trim().toLowerCase() || null; + } + } + return { selectedBandConfigId, selectedBand: selectedBandConfigId, antennaRoute, centerFreqHz, startMod }; +} + +function normalizeAntennaRoute(value) { + const route = String(value || "").trim().toLowerCase(); + if (!route) { + return ""; + } + const allowed = new Set(["draht", "beam", "wrtc"]); + return allowed.has(route) ? route : ""; +} + +function normalizeLiveMode(value) { + const raw = String(value || "").trim().toLowerCase(); + if (!raw) { + return ""; + } + const map = { + usb: "usb", + lsb: "lsb", + am: "am", + fm: "fm", + nfm: "fm", + wfm: "fm", + cw: "cw", + cwr: "cwr" + }; + return map[raw] || ""; +} + +function parseOpenWebRxLiveState(input) { + const frequencyHz = Number(input && input.liveFrequencyHz); + const normalizedFrequency = Number.isFinite(frequencyHz) && frequencyHz > 0 + ? Math.floor(frequencyHz) + : null; + const mode = normalizeLiveMode(input && input.liveMode); + return { + frequencyHz: normalizedFrequency, + mode + }; +} + +function setOpenWebRxLiveStateForUser(userId, state) { + const key = String(userId || "").trim(); + if (!key) { + return; + } + runtime.openWebRxLiveStateByUserId[key] = { + frequencyHz: Number.isFinite(Number(state && state.frequencyHz)) ? Math.floor(Number(state.frequencyHz)) : null, + mode: normalizeLiveMode(state && state.mode), + updatedAtMs: Number.isFinite(Number(state && state.updatedAtMs)) ? Number(state.updatedAtMs) : Date.now(), + source: String(state && state.source ? state.source : "unknown") + }; +} + +function getOpenWebRxLiveStateForUser(userId) { + const key = String(userId || "").trim(); + if (!key) { + return null; + } + const state = runtime.openWebRxLiveStateByUserId[key]; + if (!state || typeof state !== "object") { + return null; + } + const now = Date.now(); + const ttlMs = Number.isFinite(config.openWebRxLiveStateTtlMs) ? Math.max(1000, config.openWebRxLiveStateTtlMs) : 10000; + if (!Number.isFinite(Number(state.updatedAtMs)) || now - Number(state.updatedAtMs) > ttlMs) { + delete runtime.openWebRxLiveStateByUserId[key]; + return null; + } + const frequencyHz = Number.isFinite(Number(state.frequencyHz)) ? Math.floor(Number(state.frequencyHz)) : null; + const mode = normalizeLiveMode(state.mode); + if (!frequencyHz || !mode) { + return null; + } + return { + frequencyHz, + mode, + updatedAtMs: Number(state.updatedAtMs), + source: String(state.source || "unknown") + }; +} + +function openWebRxLiveStatesMatch(clientState, serverState) { + if (!clientState || !serverState) { + return false; + } + const clientMode = normalizeLiveMode(clientState.mode); + const serverMode = normalizeLiveMode(serverState.mode); + if (!clientMode || !serverMode || clientMode !== serverMode) { + return false; + } + const a = Number(clientState.frequencyHz); + const b = Number(serverState.frequencyHz); + if (!Number.isFinite(a) || !Number.isFinite(b)) { + return false; + } + const tolerance = Number.isFinite(config.openWebRxPttLiveFreqToleranceHz) + ? Math.max(0, Math.floor(config.openWebRxPttLiveFreqToleranceHz)) + : 50; + return Math.abs(Math.floor(a) - Math.floor(b)) <= tolerance; +} + +function openWebRxPttCommandsEnabled() { + return String(config.openWebRxPttCommandsEnabled || "false").trim().toLowerCase() === "true"; +} + +function rotorSetEnabled() { + return String(config.rotorSetEnabled || "true").trim().toLowerCase() !== "false"; +} + +function renderOpenWebRxPttCommand(template) { + const raw = String(template || "").trim(); + if (!raw) { + return ""; + } + const pttDevice = String(config.openWebRxPttDevice || process.env.RMS_MICROHAM_DEV || "/dev/rms-microham-u3").trim() || "/dev/rms-microham-u3"; + if (!/^\/dev\/[A-Za-z0-9._\/-]+$/.test(pttDevice)) { + throw new Error(`invalid ptt device path: ${pttDevice}`); + } + return raw.replaceAll("{pttDevice}", pttDevice); +} + +async function runConfiguredOpenWebRxPttCommand(direction) { + if (!openWebRxPttCommandsEnabled()) { + throw new Error("OPENWEBRX_PTT_COMMANDS_ENABLED must be true"); + } + const normalized = String(direction || "").trim().toLowerCase(); + if (normalized !== "down" && normalized !== "up") { + throw new Error(`invalid ptt direction: ${normalized}`); + } + const commandTemplate = normalized === "down" ? config.openWebRxPttDownCmd : config.openWebRxPttUpCmd; + const command = renderOpenWebRxPttCommand(commandTemplate); + if (!command) { + throw new Error(normalized === "down" + ? "OPENWEBRX_PTT_DOWN_CMD missing" + : "OPENWEBRX_PTT_UP_CMD missing"); + } + const result = await runCommand(command, { + timeoutMs: Number.isFinite(Number(config.openWebRxPttTimeoutMs)) ? Number(config.openWebRxPttTimeoutMs) : 5000 + }); + if (!result.ok) { + throw new Error(result.stderr || result.error || `ptt ${normalized} command failed`); + } + return { ok: true, skipped: false, direction: normalized }; +} + +function normalizeRotorPayload(result) { + const pendingTarget = getPendingRotorTarget(); + const activeTarget = (runtime.rotor.targetAzimuth === null || runtime.rotor.targetAzimuth === undefined) + ? null + : (Number.isFinite(Number(runtime.rotor.targetAzimuth)) ? Number(runtime.rotor.targetAzimuth) : null); + const queueDepth = runtime.rotor.commandInProgress + ? (pendingTarget !== null ? 2 : 1) + : (pendingTarget !== null ? 1 : 0); + return { + azimuth: (result && result.azimuth !== null && result.azimuth !== undefined && result.azimuth !== "" && Number.isFinite(Number(result.azimuth))) + ? Number(result.azimuth) + : null, + rawAzimuth: (result && result.rawAzimuth !== null && result.rawAzimuth !== undefined && result.rawAzimuth !== "" && Number.isFinite(Number(result.rawAzimuth))) + ? Number(result.rawAzimuth) + : null, + moving: Boolean(result && result.moving), + stale: Boolean(result && result.stale), + phase: String(runtime.rotor.phase || "idle"), + queueDepth, + activeTarget, + pendingTarget, + lastResult: runtime.rotor.lastResult ? String(runtime.rotor.lastResult) : null, + lastError: runtime.rotor.lastError ? String(runtime.rotor.lastError) : null, + updatedAt: result && result.updatedAt ? String(result.updatedAt) : null, + error: result && result.error ? String(result.error) : null, + min: Number.isFinite(Number(result && result.min)) ? Number(result.min) : 0, + max: Number.isFinite(Number(result && result.max)) ? Number(result.max) : 360 + }; +} + +function getPendingRotorTarget() { + if (runtime.rotor.pendingTargetAzimuth === null + || runtime.rotor.pendingTargetAzimuth === undefined + || runtime.rotor.pendingTargetAzimuth === "") { + return null; + } + const pending = Number(runtime.rotor.pendingTargetAzimuth); + return Number.isFinite(pending) ? pending : null; +} + +function normalizeRotorAzimuth(value) { + if (!Number.isFinite(Number(value))) { + return null; + } + let az = Number(value); + while (az < 0) az += 360; + while (az >= 360) az -= 360; + return az; +} + +function circularAzimuthDistance(a, b) { + const left = normalizeRotorAzimuth(a); + const right = normalizeRotorAzimuth(b); + if (left === null || right === null) { + return Number.POSITIVE_INFINITY; + } + const diff = Math.abs(left - right); + return Math.min(diff, 360 - diff); +} + +function waitMs(ms) { + const delay = Number.isFinite(Number(ms)) ? Math.max(0, Math.floor(Number(ms))) : 0; + return new Promise((resolve) => setTimeout(resolve, delay)); +} + +function isHardRotorSetFailure(result) { + if (!result || typeof result !== "object") { + return true; + } + if (Number(result.code) === -1) { + return true; + } + const msg = `${result.stderr || ""}\n${result.error || ""}`.toLowerCase(); + if (!msg.trim()) { + return false; + } + const hardPatterns = [ + "no such file", + "not found", + "permission denied", + "cannot open", + "failed to open", + "unable to open", + "device not found" + ]; + return hardPatterns.some((pattern) => msg.includes(pattern)); +} + +function rotorCommandBaseArgs() { + const model = Number.isFinite(Number(config.rotorModel)) ? Math.floor(Number(config.rotorModel)) : 902; + const device = resolveRotorDevicePath(); + return `-m ${model} -r ${device} -Z --set-conf=post_write_delay=0`; +} + +function resolveRotorDevicePath() { + const device = String(config.rotorDevice || "/dev/rms-ftdi-uart").trim() || "/dev/rms-ftdi-uart"; + if (!/^\/dev\/[A-Za-z0-9._\/-]+$/.test(device)) { + throw new Error(`invalid rotor device path: ${device}`); + } + return device; +} + +async function readNativeRotorStatus() { + if (config.simulateHardware) { + return { + azimuth: runtime.rotor.azimuth, + rawAzimuth: runtime.rotor.rawAzimuth, + moving: Boolean(runtime.rotor.moving), + min: 0, + max: 360, + simulated: true + }; + } + + const configuredAttempts = Number.isFinite(Number(config.rotorStatusRetryCount)) + ? Math.max(1, Math.floor(Number(config.rotorStatusRetryCount))) + : 1; + const attempts = (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive) + ? 1 + : configuredAttempts; + const retryDelayMs = (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive) + ? 0 + : (Number.isFinite(Number(config.rotorStatusRetryDelayMs)) + ? Math.max(0, Math.floor(Number(config.rotorStatusRetryDelayMs))) + : 0); + + let lastError = ""; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const command = `rotctl ${rotorCommandBaseArgs()} p p`; + const result = await runCommand(command, { + timeoutMs: Number.isFinite(Number(config.rotorGetTimeoutMs)) ? Number(config.rotorGetTimeoutMs) : 10000 + }); + if (result.ok) { + const parsed = parseRotorAzimuthFromOutput(result.stdout || ""); + const rawAzimuth = Number.isFinite(parsed) ? parsed : null; + const azimuth = normalizeRotorAzimuth(parsed); + if (azimuth !== null) { + runtime.rotor.rawAzimuth = rawAzimuth; + runtime.rotor.azimuth = azimuth; + runtime.rotor.updatedAt = new Date().toISOString(); + return { + azimuth: runtime.rotor.azimuth, + rawAzimuth: runtime.rotor.rawAzimuth, + moving: Boolean(runtime.rotor.moving), + min: 0, + max: 360, + updatedAt: runtime.rotor.updatedAt + }; + } + lastError = "rotctl get returned invalid azimuth"; + } else { + lastError = result.stderr || result.error || "rotctl get failed"; + } + + if (attempt < attempts && retryDelayMs > 0) { + await waitMs(retryDelayMs); + } + } + + if (runtime.rotor.azimuth !== null) { + return { + azimuth: runtime.rotor.azimuth, + rawAzimuth: runtime.rotor.rawAzimuth, + moving: Boolean(runtime.rotor.moving), + min: 0, + max: 360, + updatedAt: runtime.rotor.updatedAt, + stale: true, + error: lastError || "rotctl get unavailable" + }; + } + + throw new Error(lastError || "rotctl get failed"); +} + +async function refreshRotorStatusCache() { + if (config.simulateHardware) { + return; + } + if (runtime.rotor.commandInProgress || runtime.rotor.queueWorkerActive || runtime.rotor.statusRefreshInFlight) { + return; + } + runtime.rotor.statusRefreshInFlight = true; + try { + await readNativeRotorStatus(); + } catch { + // best effort background refresh + } finally { + runtime.rotor.statusRefreshInFlight = false; + } +} + +function parseRotorAzimuthFromOutput(stdout) { + const lines = String(stdout || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !/^rprt\b/i.test(line)); + if (lines.length === 0) { + return Number.NaN; + } + + for (const line of lines) { + const prefixed = line.match(/(?:azimuth|azi)\s*[:=]\s*([-+]?\d+(?:\.\d+)?)/i); + if (prefixed) { + const parsed = Number(prefixed[1]); + if (Number.isFinite(parsed) && parsed >= -360 && parsed <= 360) { + return parsed; + } + } + } + // For rotctl "p p", first numeric line is azimuth, second is elevation. + // If we only get one unlabeled numeric line (often elevation=0), do not accept it + // as azimuth to avoid false "0°" status updates. + const numericLines = lines.filter((line) => /^[-+]?\d+(?:\.\d+)?$/.test(line)); + if (numericLines.length >= 2) { + const parsed = Number(numericLines[0]); + if (Number.isFinite(parsed) && parsed >= -360 && parsed <= 360) { + return parsed; + } + } + return Number.NaN; +} + +async function waitForRotorStatusAfterSet(target, timeoutMs) { + const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); + let lastStatus = null; + let lastError = null; + + do { + try { + const status = await readNativeRotorStatus(); + lastStatus = status; + if (!status || status.stale !== true) { + return status; + } + const distance = circularAzimuthDistance(status.azimuth, target); + if (Number.isFinite(distance) && distance <= 5) { + return status; + } + } catch (error) { + lastError = error; + } + await waitMs(Math.max(100, Number(config.rotorStatusRetryDelayMs) || 300)); + } while (Date.now() < deadline); + + if (lastStatus) { + return lastStatus; + } + if (lastError) { + throw lastError; + } + throw new Error("rotor status unavailable after set"); +} + +async function setNativeRotorAzimuth(target) { + const normalizedTarget = normalizeRotorAzimuth(target); + if (normalizedTarget === null) { + throw new Error("target muss zwischen 0 und 360 sein"); + } + if (runtime.rotor.commandInProgress) { + throw new Error("rotor command already in progress"); + } + + runtime.rotor.commandInProgress = true; + runtime.rotor.commandStartedAt = new Date().toISOString(); + runtime.rotor.targetAzimuth = normalizedTarget; + runtime.rotor.moving = true; + runtime.rotor.phase = "setting"; + runtime.rotor.lastChangeAt = Date.now(); + + if (config.simulateHardware) { + runtime.rotor.azimuth = normalizedTarget; + runtime.rotor.updatedAt = new Date().toISOString(); + runtime.rotor.moving = false; + runtime.rotor.commandInProgress = false; + runtime.rotor.targetAzimuth = null; + runtime.rotor.commandStartedAt = null; + return { + ok: true, + message: `Rotor auf ${Math.round(normalizedTarget)} Grad simuliert`, + rotor: { + azimuth: runtime.rotor.azimuth, + moving: false, + min: 0, + max: 360 + } + }; + } + + try { + let initialAzimuth = runtime.rotor.azimuth; + try { + const initialStatus = await readNativeRotorStatus(); + if (Number.isFinite(Number(initialStatus && initialStatus.azimuth))) { + initialAzimuth = Number(initialStatus.azimuth); + } + } catch { + // continue with cached status + } + + const setpoint = Number(normalizedTarget); + const command = `rotctl ${rotorCommandBaseArgs()} p p P ${Math.round(setpoint)} 0 p p`; + const result = await runCommand(command, { + timeoutMs: Number.isFinite(Number(config.rotorSetTimeoutMs)) ? Number(config.rotorSetTimeoutMs) : 20000 + }); + if (!result.ok && isHardRotorSetFailure(result)) { + throw new Error(result.stderr || result.error || "rotctl set failed"); + } + runtime.rotor.phase = "monitoring"; + const observed = await monitorRotorMoveToTarget(normalizedTarget, initialAzimuth); + return { + ok: true, + message: `Rotor auf ${Math.round(normalizedTarget)} Grad gesetzt`, + warning: result.ok ? null : (result.stderr || result.error || "rotctl set returned non-zero"), + rotor: normalizeRotorPayload(observed) + }; + } finally { + runtime.rotor.moving = false; + runtime.rotor.commandInProgress = false; + runtime.rotor.targetAzimuth = null; + runtime.rotor.commandStartedAt = null; + if (getPendingRotorTarget() === null) { + runtime.rotor.phase = "idle"; + } + } +} + +async function monitorRotorMoveToTarget(targetAzimuth, initialAzimuth) { + const firstCheckDelayMs = 2000; + const pollMs = 1000; + const stallMs = 7000; + const configuredMaxMs = Number.isFinite(Number(config.rotorMonitorMaxMs)) + ? Math.max(15000, Number(config.rotorMonitorMaxMs)) + : 120000; + const estimatedDistance = Number.isFinite(Number(initialAzimuth)) + ? circularAzimuthDistance(Number(initialAzimuth), targetAzimuth) + : Number.POSITIVE_INFINITY; + const distanceBudgetMs = Number.isFinite(estimatedDistance) + ? Math.max(15000, Math.round(estimatedDistance * 450) + 12000) + : 30000; + const maxTotalMs = Math.max(configuredMaxMs, distanceBudgetMs); + + await waitMs(firstCheckDelayMs); + + const startAt = Date.now(); + let lastAzimuth = Number.isFinite(Number(initialAzimuth)) ? Number(initialAzimuth) : null; + let movementDetected = false; + let lastMovementAt = Date.now(); + let lastStatus = null; + let lastReadError = null; + + while ((Date.now() - startAt) <= maxTotalMs) { + try { + const status = await readNativeRotorStatus(); + lastStatus = status; + lastReadError = null; + const current = Number.isFinite(Number(status && status.azimuth)) ? Number(status.azimuth) : null; + if (current !== null) { + const toTarget = circularAzimuthDistance(current, targetAzimuth); + if (Number.isFinite(toTarget) && toTarget <= 5) { + runtime.rotor.azimuth = current; + runtime.rotor.updatedAt = new Date().toISOString(); + return { + ...status, + moving: false + }; + } + if (lastAzimuth === null || circularAzimuthDistance(lastAzimuth, current) >= 1) { + movementDetected = true; + lastMovementAt = Date.now(); + lastAzimuth = current; + } + runtime.rotor.azimuth = current; + runtime.rotor.updatedAt = new Date().toISOString(); + } + } catch (error) { + lastReadError = error; + } + + if (!movementDetected && (Date.now() - startAt) >= stallMs) { + throw new Error("rotor did not start moving after command"); + } + if (movementDetected && (Date.now() - lastMovementAt) >= stallMs) { + throw new Error("rotor movement stalled before reaching target"); + } + + await waitMs(pollMs); + } + + if (lastStatus) { + throw new Error("rotor did not reach target within timeout"); + } + if (lastReadError) { + throw new Error(String(lastReadError && lastReadError.message ? lastReadError.message : lastReadError)); + } + throw new Error("rotor status unavailable while monitoring movement"); +} + +async function maybeFollowTxRoute(user, txActive) { + if (runtime.pttActive) { + return; + } + if (txActive) { + return; + } + const provider = runtime.pluginState.providers["rfroute.set"]; + if (!provider) { + return; + } + if (runtime.txFollowRoute !== "tx") { + return; + } + const desiredRoute = "rx"; + if (runtime.txFollowRoute === desiredRoute) { + return; + } + try { + await executeCapability("rfroute.set", "setRoute", { route: desiredRoute }, { user, skipTxSafety: true }); + runtime.txFollowRoute = desiredRoute; + } catch { + // rfroute follow is best effort + } +} + +async function enforceOpenWebRxPttConnectionWatchdog() { + if (!runtime.pttActive) { + return; + } + const activeOwnerId = String(runtime.station && runtime.station.activeByUserId ? runtime.station.activeByUserId : ""); + if (!activeOwnerId) { + await forceOpenWebRxPttUpForConnectionLoss(null, "missing-owner"); + return; + } + const liveState = getOpenWebRxLiveStateForUser(activeOwnerId); + if (!liveState) { + await forceOpenWebRxPttUpForConnectionLoss(activeOwnerId, "missing-live-state"); + return; + } + const staleAfterMs = Math.max(1500, Number(config.openWebRxLiveStateTtlMs || 10000)); + const lastUpdatedMs = Number(liveState.updatedAtMs || 0); + if (!Number.isFinite(lastUpdatedMs) || (Date.now() - lastUpdatedMs) > staleAfterMs) { + await forceOpenWebRxPttUpForConnectionLoss(activeOwnerId, "stale-live-state"); + } +} + +async function forceOpenWebRxPttUpForConnectionLoss(ownerUserId, reason) { + return withLock(async () => { + if (!runtime.pttActive) { + return; + } + const actingUser = runtime.users.find((entry) => String(entry && entry.id ? entry.id : "") === String(ownerUserId || "")) || null; + try { + await executeCapability("microham.ptt", "pttUp", {}, { user: actingUser, skipTxSafety: true }); + } catch { + // best effort + } + try { + await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user: actingUser, skipTxSafety: true, allowPttOverride: true }); + } catch { + // best effort + } + runtime.pttActive = false; + runtime.txFollowRoute = "rx"; + await appendAudit("openwebrx.ptt.auto_up", actingUser, { reason: reason || "connection-lost" }); + broadcastEvent("station.status.changed", buildStationStatusView()); + }); +} + +async function handleOpenWebRxSessionClose(res, user) { + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "Nur aktiver Stationsbenutzer darf schliessen"); + } + try { + if (runtime.pttActive && runtime.pluginState.providers["rfroute.set"]) { + try { + await executeCapability("microham.ptt", "pttUp", {}, { user, skipTxSafety: true }); + } catch { + // best effort + } + try { + await executeCapability("rfroute.set", "setRoute", { route: "rx" }, { user, skipTxSafety: true, allowPttOverride: true }); + } catch { + // best effort + } + runtime.pttActive = false; + runtime.txFollowRoute = "rx"; + } + await executeCapability("tx.control", "disableTx", { source: "openwebrx-close" }, { user, skipTxSafety: true }); + await executeCapability("tx.audio", "audioDisconnect", { reason: "openwebrx-close" }, { user, skipTxSafety: true }).catch(() => {}); + await appendAudit("tx.disable", user, { source: "openwebrx-close" }); + } catch (error) { + return sendError(res, 409, "tx.disable_failed", String(error && error.message ? error.message : error)); + } + await revokeOpenWebRxAccess(user, "openwebrx-close"); + clearOpenWebRxSession(); + return sendJson(res, 200, { ok: true }); +} + +async function handleOpenWebRxPluginState(req, res, url) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + const user = auth.user; + + const payload = { + ok: true, + txActive: false, + pttActive: Boolean(runtime.pttActive), + power: { + active: false, + commandConfigured: isPowerControlConfigured() + }, + ptt: { + active: Boolean(runtime.pttActive), + commandConfigured: false, + providerAvailable: Boolean(runtime.pluginState.providers["microham.ptt"]), + providerId: runtime.pluginState.providers["microham.ptt"] || null, + providerEnabled: null, + liveStateReady: false, + liveState: null, + error: null, + blockedBandConfigIds: Array.isArray(config.openWebRxPttBlockedBandConfigIds) + ? [...config.openWebRxPttBlockedBandConfigIds] + : [], + blockedByBand: false + }, + rfPath: { + current: null, + commandConfigured: isRfrouteTxRxConfigured() + }, + antenna: { + current: null, + commandConfigured: isAntennaRoutingConfigured() + }, + stationEndsAt: runtime.station && runtime.station.endsAt ? runtime.station.endsAt : null, + remainingUsageSec: runtime.station && runtime.station.isInUse && runtime.station.endsAt + ? Math.max(0, Math.ceil((new Date(runtime.station.endsAt).getTime() - Date.now()) / 1000)) + : 0, + rfroute: { + current: null, + options: [] + }, + txAudio: { + enabled: isLikelyMicrohamAudioEnabledFromEnv(), + state: isLikelyMicrohamAudioEnabledFromEnv() ? "disconnected" : "disabled", + running: false, + clients: 0, + ownerUserId: null, + ownerMatchesCaller: false, + startedAt: null, + lastError: null, + lastExit: null, + ffmpegPath: null, + alsaDevice: null, + chunkMs: 100, + wsPath: "/v1/openwebrx/plugin/audio/ws" + }, + bands: [], + selectedBand: null, + selectedBandConfigId: null, + antennaRoute: null, + rotor: { + azimuth: null, + moving: false, + min: 0, + max: 360 + } + }; + + try { + const txState = await withTimeout( + executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true }), + 1200, + "tx.state.read timeout" + ); + payload.txActive = Boolean(txState && txState.txActive); + payload.power.active = payload.txActive; + payload.txState = txState || null; + } catch { + payload.txState = null; + } + + const pttProviderId = runtime.pluginState.providers["microham.ptt"] || null; + payload.ptt.providerEnabled = pttProviderId ? Boolean(runtime.pluginState.enabled[pttProviderId]) : null; + let pttStatus = null; + let pttError = null; + try { + pttStatus = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true }); + } catch (error) { + pttError = String(error && error.message ? error.message : error); + const likelyProviderIssue = /No provider for capability microham\.ptt|Provider plugin .* missing|Provider plugin .* disabled/i.test(pttError); + if (likelyProviderIssue) { + try { + await healMicrohamProviders(); + pttStatus = await executeCapability("microham.ptt", "pttStatus", {}, { user, skipTxSafety: true }); + pttError = null; + } catch (retryError) { + pttError = String(retryError && retryError.message ? retryError.message : retryError); + } + } + } + + if (pttStatus) { + payload.ptt.commandConfigured = Boolean(pttStatus && pttStatus.commandConfigured); + payload.ptt.active = Boolean(pttStatus && pttStatus.active); + payload.pttActive = payload.ptt.active; + } else if (pttError) { + payload.ptt.error = pttError; + payload.ptt.commandConfigured = isLikelyPttConfiguredFromEnv(); + console.warn("openwebrx.plugin.state pttStatus failed:", { + error: payload.ptt.error, + providerId: payload.ptt.providerId, + providerEnabled: payload.ptt.providerEnabled, + userId: user && user.id ? String(user.id) : null + }); + } + + const liveState = getOpenWebRxLiveStateForUser(user && user.id ? user.id : ""); + payload.ptt.liveState = liveState; + payload.ptt.liveStateReady = Boolean(payload.ptt.commandConfigured && liveState && liveState.frequencyHz && liveState.mode); + + try { + const audioStatus = await executeCapability("tx.audio", "audioStatus", { userId: user && user.id ? String(user.id) : "" }, { user, skipTxSafety: true }); + if (audioStatus && typeof audioStatus === "object") { + payload.txAudio = audioStatus; + } + } catch (error) { + if (isLikelyMicrohamProviderIssue(error)) { + try { + await healMicrohamProviders(); + const audioStatus = await executeCapability("tx.audio", "audioStatus", { userId: user && user.id ? String(user.id) : "" }, { user, skipTxSafety: true }); + if (audioStatus && typeof audioStatus === "object") { + payload.txAudio = audioStatus; + } + } catch { + payload.txAudio.lastError = String(error && error.message ? error.message : error); + } + } else { + payload.txAudio.lastError = String(error && error.message ? error.message : error); + } + } + + try { + const providerId = runtime.pluginState.providers["rfroute.set"]; + if (providerId) { + const plugin = runtime.plugins.get(providerId); + if (plugin && plugin.instance && typeof plugin.instance.getStatus === "function") { + const status = await withTimeout(safeStatus(plugin.instance), 1000, "rfroute status timeout"); + payload.rfroute = { + current: status && status.current ? String(status.current) : null, + options: Array.isArray(status && status.options) ? status.options.map((entry) => String(entry)) : [] + }; + } + } + } catch { + payload.rfroute = { current: null, options: [] }; + } + + const rfRouteAsAntenna = normalizeAntennaRoute(payload.rfroute && payload.rfroute.current ? payload.rfroute.current : ""); + payload.rfPath.current = payload.pttActive ? "tx" : "rx"; + if (rfRouteAsAntenna) { + payload.antennaRoute = rfRouteAsAntenna; + payload.antenna.current = rfRouteAsAntenna; + } + if (!payload.antennaRoute) { + payload.antennaRoute = normalizeAntennaRoute(runtime.openWebRxAntennaRoute || "") || null; + payload.antenna.current = payload.antennaRoute; + } + + try { + const provider = runtime.pluginState.providers["openwebrx.band.read"]; + if (provider) { + try { + const stateResult = await withTimeout( + executeCapability("openwebrx.band.read", "getState", {}, { user, skipTxSafety: true }), + 1000, + "band state timeout" + ); + if (stateResult && stateResult.selectedBand) { + payload.selectedBand = stateResult.selectedBand; + payload.selectedBandConfigId = stateResult.selectedBand; + } + const directRoute = normalizeAntennaRoute(stateResult && stateResult.antennaRoute ? stateResult.antennaRoute : ""); + if (directRoute) { + payload.antennaRoute = directRoute; + payload.antenna.current = directRoute; + } + } catch { + // fallback to getBands only + } + + const result = await withTimeout( + executeCapability("openwebrx.band.read", "getBands", {}, { user, skipTxSafety: true }), + 1200, + "bands read timeout" + ); + payload.bands = Array.isArray(result && result.bands) ? result.bands : []; + if (!payload.selectedBand) { + payload.selectedBand = result && result.selectedBand ? result.selectedBand : null; + } + if (!payload.selectedBandConfigId) { + payload.selectedBandConfigId = payload.selectedBand; + } + if (!payload.antennaRoute) { + const selectedBand = String(payload.selectedBand || "").trim(); + if (selectedBand) { + const selected = payload.bands.find((entry) => { + if (!entry || typeof entry !== "object") { + return false; + } + return String(entry.band || entry.id || "").trim() === selectedBand; + }); + if (selected && typeof selected === "object") { + payload.antennaRoute = normalizeAntennaRoute(selected.antennaRoute || "") || null; + payload.antenna.current = payload.antennaRoute; + } + } + } + } + } catch { + payload.bands = []; + payload.selectedBand = null; + payload.selectedBandConfigId = null; + } + + const selectedBandConfigId = String(payload.selectedBandConfigId || payload.selectedBand || "").trim(); + payload.ptt.blockedByBand = isPttBlockedForBandConfigId(selectedBandConfigId); + + payload.rotor = normalizeRotorPayload(runtime.rotor); + + return sendJson(res, 200, payload); +} + +function isPowerControlConfigured() { + const enable = String(process.env.TX_ENABLE_REAL_CMD || process.env.POWER_ON_CMD || "").trim(); + const disable = String(process.env.TX_DISABLE_REAL_CMD || process.env.POWER_OFF_CMD || "").trim(); + return Boolean(enable && disable); +} + +function isRfroutePttConfigured() { + const enabled = openWebRxPttCommandsEnabled(); + const down = String(config.openWebRxPttDownCmd || "").trim(); + const up = String(config.openWebRxPttUpCmd || "").trim(); + return Boolean(enabled && down && up); +} + +function isLikelyPttConfiguredFromEnv() { + const enabled = String(process.env.MICROHAM_PTT_COMMANDS_ENABLED || "false").trim().toLowerCase(); + if (!(enabled === "true" || enabled === "1" || enabled === "yes" || enabled === "on")) { + return false; + } + const down = String(process.env.MICROHAM_PTT_DOWN_CMD || "").trim(); + const up = String(process.env.MICROHAM_PTT_UP_CMD || "").trim(); + return Boolean(down && up); +} + +function isLikelyMicrohamProviderIssue(error) { + const message = String(error && error.message ? error.message : error || ""); + return /No provider for capability (microham\.|tx\.audio)|Provider plugin .* missing|Provider plugin .* disabled/i.test(message); +} + +function isLikelyMicrohamAudioEnabledFromEnv() { + const raw = String(process.env.MICROHAM_AUDIO_ENABLED || "true").trim().toLowerCase(); + if (!raw) { + return true; + } + return raw === "true" || raw === "1" || raw === "yes" || raw === "on"; +} + +async function healMicrohamProviders() { + ensureDefaultProviders(); + await savePluginState(); +} + +function isRfrouteTxRxConfigured() { + const tx = String(process.env.RFROUTE_CMD_TX || "").trim(); + const rx = String(process.env.RFROUTE_CMD_RX || "").trim(); + return Boolean(tx && rx); +} + +function isAntennaRoutingConfigured() { + const draht = String(process.env.RFROUTE_CMD_DRAHT || "").trim(); + const beam = String(process.env.RFROUTE_CMD_BEAM || "").trim(); + const wrtc = String(process.env.RFROUTE_CMD_WRTC || "").trim(); + return Boolean(draht && beam && wrtc); +} + +function withTimeout(promise, timeoutMs, message) { + const ms = Number.isFinite(Number(timeoutMs)) ? Math.max(50, Number(timeoutMs)) : 1000; + let timer = null; + return Promise.race([ + Promise.resolve(promise), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(String(message || "timeout"))); + }, ms); + if (timer && typeof timer.unref === "function") { + timer.unref(); + } + }) + ]).finally(() => { + if (timer) { + clearTimeout(timer); + } + }); +} + +async function handleOpenWebRxPluginTx(req, res, url, enable) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + const user = auth.user; + return enable ? handleOpenWebRxTxEnable(res, user) : handleOpenWebRxTxDisable(res, user); +} + +async function handleOpenWebRxPluginPtt(req, res, url, down) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + const body = down ? await readJsonBody(req) : {}; + return down ? handleOpenWebRxPttDown(res, auth.user, body || {}) : handleOpenWebRxPttUp(res, auth.user); +} + +async function handleOpenWebRxPluginLiveState(req, res, url, body) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + const user = auth.user; + if (!runtime.station.isInUse || runtime.station.activeByUserId !== user.id) { + return sendError(res, 403, "openwebrx.not_owner", "OpenWebRX Live-State nur fuer aktiven Stationsbenutzer"); + } + const parsed = parseOpenWebRxLiveState(body || {}); + if (!parsed.frequencyHz || !parsed.mode) { + return sendError(res, 409, "openwebrx.live_state_invalid", "Live-Frequenz oder Mode fehlt"); + } + setOpenWebRxLiveStateForUser(user.id, { + frequencyHz: parsed.frequencyHz, + mode: parsed.mode, + updatedAtMs: Date.now(), + source: "plugin" + }); + return sendJson(res, 200, { + ok: true, + liveState: { + frequencyHz: parsed.frequencyHz, + mode: parsed.mode + } + }); +} + +async function handleOpenWebRxPluginAudioStatus(req, res, url) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + try { + const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true }); + return sendJson(res, 200, { ok: true, audio }); + } catch (error) { + if (isLikelyMicrohamProviderIssue(error)) { + await healMicrohamProviders(); + try { + const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true }); + return sendJson(res, 200, { ok: true, audio }); + } catch { + // fall through + } + } + return sendError(res, 409, "tx.audio.status_failed", String(error && error.message ? error.message : error)); + } +} + +async function handleOpenWebRxPluginAudioConnect(req, res, url) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + try { + await executeCapability("tx.audio", "audioConnect", { + userId: String(auth.user.id || ""), + reason: "plugin-connect" + }, { user: auth.user, skipTxSafety: true }); + const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true }); + return sendJson(res, 200, { + ok: true, + audio + }); + } catch (error) { + if (isLikelyMicrohamProviderIssue(error)) { + await healMicrohamProviders(); + try { + await executeCapability("tx.audio", "audioConnect", { + userId: String(auth.user.id || ""), + reason: "plugin-connect" + }, { user: auth.user, skipTxSafety: true }); + const audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true }); + return sendJson(res, 200, { ok: true, audio }); + } catch { + // fall through + } + } + return sendError(res, 409, "tx.audio.start_failed", String(error && error.message ? error.message : error)); + } +} + +async function handleOpenWebRxPluginAudioDisconnect(req, res, url) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + try { + await executeCapability("tx.audio", "audioDisconnect", { + userId: String(auth.user.id || ""), + reason: "plugin-disconnect" + }, { user: auth.user, skipTxSafety: true }); + } catch { + // best effort + } + let audio = null; + try { + audio = await executeCapability("tx.audio", "audioStatus", { userId: String(auth.user.id || "") }, { user: auth.user, skipTxSafety: true }); + } catch { + audio = null; + } + return sendJson(res, 200, { + ok: true, + audio + }); +} + +async function handleOpenWebRxPluginBandSelect(req, res, url, body) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + return handleOpenWebRxBandSelect(res, auth.user, body); +} + +async function handleOpenWebRxPluginRotorSet(req, res, url, body) { + const auth = await requireOpenWebRxTicketUser(req, res, url); + if (!auth) { + return; + } + return handleOpenWebRxRotorSet(res, auth.user, body, "owrx-plugin"); +} + +function txAudioEnabled() { + return String(config.txAudioEnabled || "true").trim().toLowerCase() !== "false"; +} + +function txAudioStopOnDisconnect() { + return String(config.txAudioStopOnDisconnect || "true").trim().toLowerCase() !== "false"; +} + +function txAudioInputFormatArg() { + const value = String(config.txAudioInputMime || "webm").trim().toLowerCase(); + if (value === "ogg") { + return "ogg"; + } + return "webm"; +} + +function txAudioChunkMs() { + const value = Number(config.txAudioChunkMs); + if (!Number.isFinite(value)) { + return 100; + } + return Math.max(40, Math.min(2000, Math.floor(value))); +} + +function buildTxAudioStatusPayload(user) { + const owner = runtime.txAudio.ownerUserId || null; + const currentUserId = user && user.id ? String(user.id) : null; + const enabled = txAudioEnabled(); + let state = "disconnected"; + if (!enabled) { + state = "disabled"; + } else if (runtime.txAudio.running) { + state = "running"; + } else if (runtime.txAudio.lastError) { + state = "error"; + } + return { + enabled, + state, + running: Boolean(runtime.txAudio.running), + clients: runtime.txAudio.clients ? runtime.txAudio.clients.size : 0, + ownerUserId: owner, + ownerMatchesCaller: Boolean(owner && currentUserId && owner === currentUserId), + startedAt: runtime.txAudio.startedAt || null, + lastError: runtime.txAudio.lastError || null, + lastExit: runtime.txAudio.lastExit || null, + ffmpegPath: resolveTxAudioFfmpegPath() || null, + alsaDevice: runtime.txAudio.alsaDevice || (String(config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0"), + chunkMs: txAudioChunkMs(), + wsPath: "/v1/openwebrx/plugin/audio/ws" + }; +} + +function clearTxAudioIdleTimer() { + if (!runtime.txAudio.idleTimer) { + return; + } + clearTimeout(runtime.txAudio.idleTimer); + runtime.txAudio.idleTimer = null; +} + +function scheduleTxAudioIdleStop() { + clearTxAudioIdleTimer(); + const timeoutMs = Number.isFinite(Number(config.txAudioSessionTimeoutMs)) + ? Math.max(1000, Math.floor(Number(config.txAudioSessionTimeoutMs))) + : 120000; + runtime.txAudio.idleTimer = setTimeout(() => { + stopTxAudioBridge("idle-timeout", null).catch(() => {}); + }, timeoutMs); + if (runtime.txAudio.idleTimer && typeof runtime.txAudio.idleTimer.unref === "function") { + runtime.txAudio.idleTimer.unref(); + } +} + +function spawnTxAudioFfmpeg(alsaDeviceOverride = null) { + const ffmpegPath = resolveTxAudioFfmpegPath(); + if (!ffmpegPath) { + throw new Error("ffmpeg binary not found (set TX_AUDIO_FFMPEG_PATH)"); + } + const alsaDevice = String(alsaDeviceOverride || config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0"; + const args = [ + "-hide_banner", + "-loglevel", "warning", + "-fflags", "+nobuffer", + "-flags", "low_delay", + "-thread_queue_size", "1024", + "-f", txAudioInputFormatArg(), + "-i", "pipe:0", + "-ac", "2", + "-f", "alsa", + alsaDevice + ]; + const extra = splitCommand(String(config.txAudioFfmpegExtraArgs || "")); + if (extra.length > 0) { + args.splice(args.length - 2, 0, ...extra); + } + const proc = spawn(ffmpegPath, args, { + stdio: ["pipe", "pipe", "pipe"], + cwd: process.cwd(), + env: process.env + }); + return proc; +} + +function txAudioAlsaCandidates() { + const configured = String(config.txAudioAlsaDevice || "plughw:CARD=CODEC,DEV=0").trim() || "plughw:CARD=CODEC,DEV=0"; + const candidates = [configured, "default", "plughw:0,0"]; + return [...new Set(candidates.map((entry) => String(entry || "").trim()).filter(Boolean))]; +} + +function resolveTxAudioFfmpegPath() { + const configured = String(config.txAudioFfmpegPath || "").trim(); + const linuxCandidates = [ + "/usr/bin/ffmpeg", + "/usr/local/bin/ffmpeg", + "/bin/ffmpeg" + ]; + if (configured) { + if (configured.includes(path.sep) || configured.includes("/")) { + if (fs.existsSync(configured)) { + return configured; + } + const fallbackName = path.basename(configured) || "ffmpeg"; + for (const candidate of linuxCandidates) { + if (fs.existsSync(candidate) && path.basename(candidate) === fallbackName) { + return candidate; + } + } + return fallbackName; + } + if (process.platform === "linux") { + for (const candidate of linuxCandidates) { + if (fs.existsSync(candidate) && path.basename(candidate) === configured) { + return candidate; + } + } + } + return configured; + } + if (process.platform === "linux") { + for (const candidate of linuxCandidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + return "ffmpeg"; +} + +async function ensureTxAudioBridgeForOwner(user, reason) { + if (!txAudioEnabled()) { + return; + } + if (!user || !user.id) { + throw new Error("no user context for tx audio"); + } + const ownerUserId = String(user.id); + if (runtime.txAudio.running) { + if (runtime.txAudio.ownerUserId && runtime.txAudio.ownerUserId !== ownerUserId) { + const hasClients = runtime.txAudio.clients && runtime.txAudio.clients.size > 0; + if (!hasClients) { + await stopTxAudioBridge("owner-handover", null).catch(() => {}); + await waitMs(100); + } + } + if (runtime.txAudio.running && runtime.txAudio.ownerUserId && runtime.txAudio.ownerUserId !== ownerUserId) { + throw new Error("TX Audio wird bereits von einem anderen Benutzer verwendet"); + } + runtime.txAudio.ownerUserId = ownerUserId; + clearTxAudioIdleTimer(); + return; + } + + clearTxAudioIdleTimer(); + const startupErrors = []; + for (const candidateDevice of txAudioAlsaCandidates()) { + runtime.txAudio.lastError = null; + let proc; + try { + proc = spawnTxAudioFfmpeg(candidateDevice); + } catch (error) { + startupErrors.push(`${candidateDevice}: ${String(error && error.message ? error.message : error)}`); + continue; + } + runtime.txAudio.ffmpeg = proc; + runtime.txAudio.running = true; + runtime.txAudio.startedAt = new Date().toISOString(); + runtime.txAudio.ownerUserId = ownerUserId; + runtime.txAudio.alsaDevice = candidateDevice; + runtime.txAudio.stopRequested = false; + + let stderrBuffer = ""; + if (proc.stderr) { + proc.stderr.on("data", (chunk) => { + const text = String(chunk || ""); + stderrBuffer = `${stderrBuffer}${text}`.slice(-4000); + if (!runtime.txAudio.stopRequested && text.trim()) { + runtime.txAudio.lastError = text.trim(); + } + }); + } + + proc.on("error", (error) => { + if (!runtime.txAudio.stopRequested) { + runtime.txAudio.lastError = String(error && error.message ? error.message : error); + } + }); + + proc.on("close", (code, signal) => { + runtime.txAudio.lastExit = { + at: new Date().toISOString(), + code: Number.isFinite(Number(code)) ? Number(code) : null, + signal: signal || null, + stderr: stderrBuffer || null + }; + runtime.txAudio.running = false; + runtime.txAudio.ffmpeg = null; + runtime.txAudio.startedAt = null; + runtime.txAudio.ownerUserId = null; + runtime.txAudio.alsaDevice = null; + runtime.txAudio.stopRequested = false; + clearTxAudioIdleTimer(); + for (const ws of runtime.txAudio.clients) { + try { + ws.close(1011, "audio backend closed"); + } catch { + // ignore + } + } + runtime.txAudio.clients.clear(); + }); + + await waitMs(180); + if (runtime.txAudio.running) { + await appendAudit("openwebrx.audio.start", user, { reason, alsaDevice: candidateDevice }); + return; + } + + startupErrors.push(`${candidateDevice}: ${runtime.txAudio.lastError || "start failed"}`); + } + + runtime.txAudio.alsaDevice = null; + throw new Error(startupErrors.length > 0 ? startupErrors.join(" | ") : "TX Audio Backend konnte nicht gestartet werden"); +} + +async function stopTxAudioBridge(reason, user) { + clearTxAudioIdleTimer(); + const proc = runtime.txAudio.ffmpeg; + runtime.txAudio.stopRequested = true; + if (!proc || !runtime.txAudio.running) { + runtime.txAudio.running = false; + runtime.txAudio.ffmpeg = null; + runtime.txAudio.ownerUserId = null; + runtime.txAudio.alsaDevice = null; + runtime.txAudio.stopRequested = false; + runtime.txAudio.lastError = null; + for (const ws of runtime.txAudio.clients) { + try { + ws.close(1000, "audio disconnected"); + } catch { + // ignore + } + } + runtime.txAudio.clients.clear(); + return; + } + + for (const ws of runtime.txAudio.clients) { + try { + ws.close(1000, "audio disconnected"); + } catch { + // ignore + } + } + runtime.txAudio.clients.clear(); + + try { + if (proc.stdin && !proc.stdin.destroyed) { + proc.stdin.end(); + } + } catch { + // ignore + } + + await waitMs(150); + if (runtime.txAudio.running && !proc.killed) { + try { + proc.kill("SIGTERM"); + } catch { + // ignore + } + } + + if (user) { + await appendAudit("openwebrx.audio.stop", user, { reason, alsaDevice: runtime.txAudio.alsaDevice || null }).catch(() => {}); + } + runtime.txAudio.lastError = null; +} + +function initTxAudioWebSocket(server) { + const wss = new WebSocketServer({ noServer: true }); + runtime.txAudio.wsServer = wss; + + server.on("upgrade", (req, socket, head) => { + let url; + try { + url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + } catch { + socket.destroy(); + return; + } + if (url.pathname !== "/v1/openwebrx/plugin/audio/ws") { + socket.destroy(); + return; + } + + resolveOpenWebRxTicketAccess(req, url).then((access) => { + if (!access || !access.ok || !access.user) { + socket.destroy(); + return; + } + if (denyByStationOwnerForUser(access.user)) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + ws._rmsUser = access.user; + wss.emit("connection", ws, req); + }); + }).catch(() => { + socket.destroy(); + }); + }); + + wss.on("connection", async (ws) => { + const user = ws._rmsUser; + const userId = String(user && user.id ? user.id : ""); + try { + await executeCapability("tx.audio", "audioRegisterClient", { + ws, + userId, + reason: "ws-connect" + }, { user, skipTxSafety: true }); + } catch (error) { + try { + ws.close(1011, String(error && error.message ? error.message : error)); + } catch { + // ignore + } + return; + } + + ws.on("message", async (message, isBinary) => { + if (!isBinary) { + return; + } + try { + await executeCapability("tx.audio", "audioWriteChunk", { + ws, + userId, + chunk: Buffer.isBuffer(message) ? message : Buffer.from(message) + }, { user, skipTxSafety: true }); + } catch { + // ignore chunk failure + } + }); + + ws.on("close", () => { + executeCapability("tx.audio", "audioUnregisterClient", { + ws, + userId, + reason: "ws-disconnect" + }, { user, skipTxSafety: true }).catch(() => {}); + }); + + ws.on("error", () => { + executeCapability("tx.audio", "audioUnregisterClient", { + ws, + userId, + reason: "ws-error" + }, { user, skipTxSafety: true }).catch(() => {}); + }); + }); +} + +function denyByStationOwnerForUser(user) { + if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) { + return false; + } + return String(user && user.id ? user.id : "") !== String(runtime.station.activeByUserId || ""); +} + +async function syncStationAccessPolicyOwner(user, ownerEmail) { + const provider = runtime.pluginState.providers["admin.station.access.policy.write"]; + if (!provider) { + return; + } + try { + await executeCapability("admin.station.access.policy.write", "syncOwner", { ownerEmail }, { user, skipTxSafety: true }); + } catch { + // optional capability + } +} + +async function clearStationAccessPolicyOwner(user) { + const provider = runtime.pluginState.providers["admin.station.access.policy.write"]; + if (!provider) { + return; + } + try { + await executeCapability("admin.station.access.policy.write", "clearOwner", {}, { user, skipTxSafety: true }); + } catch { + // optional capability + } +} + +async function requireOpenWebRxTicketUser(req, res, url) { + try { + const result = await resolveOpenWebRxTicketAccess(req, url); + if (!result || !result.ok || !result.user) { + res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "openwebrx.forbidden", message: "OpenWebRX Zugriff verweigert" })); + return null; + } + return { user: result.user }; + } catch { + res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: false, error: "openwebrx.forbidden", message: "OpenWebRX Zugriff verweigert" })); + return null; + } +} + +async function resolveOpenWebRxTicketAccess(req, url) { + if (canAuthorizeOpenWebRxByActiveSession()) { + const activeUser = runtime.users.find((entry) => String(entry.id) === String(runtime.station.activeByUserId || "")); + if (activeUser) { + return { ok: true, user: activeUser }; + } + } + + const queryTicket = String(url && url.searchParams ? (url.searchParams.get("ticket") || "") : "").trim(); + const cookieTicket = readCookie(req.headers && req.headers.cookie, "rms_owrx_ticket"); + const originalUriHeader = req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : ""; + const originalUriTicket = extractTicketFromUri(originalUriHeader); + const refererHeader = req.headers ? (req.headers.referer || req.headers.referrer || "") : ""; + const refererTicket = extractTicketFromUri(refererHeader); + + const tickets = []; + if (queryTicket) tickets.push(queryTicket); + if (cookieTicket && cookieTicket !== queryTicket) tickets.push(cookieTicket); + if (originalUriTicket && !tickets.includes(originalUriTicket)) tickets.push(originalUriTicket); + if (refererTicket && !tickets.includes(refererTicket)) tickets.push(refererTicket); + if (tickets.length === 0) { + const fallbackUser = resolveOpenWebRxOwnerFromRequestContext(req, url); + if (fallbackUser) { + return { ok: true, user: fallbackUser }; + } + return { ok: false }; + } + + const provider = runtime.pluginState.providers["openwebrx.access.verify"]; + if (!provider) { + return { ok: false }; + } + + let verified = null; + for (const ticket of tickets) { + const candidate = await executeCapability("openwebrx.access.verify", "verifyAccess", { ticket }, { skipTxSafety: true }); + if (candidate && candidate.ok) { + verified = candidate; + break; + } + } + const ownerUserId = runtime.station.activeByUserId; + const allowed = Boolean( + verified && + verified.ok && + runtime.station.isInUse && + ownerUserId && + String(verified.userId) === String(ownerUserId) + ); + if (!allowed) { + const fallbackUser = resolveOpenWebRxOwnerFromRequestContext(req, url); + if (fallbackUser) { + return { ok: true, user: fallbackUser }; + } + return { ok: false }; + } + + let user = runtime.users.find((entry) => String(entry.id) === String(verified.userId)); + if (!user) { + user = { + id: String(verified.userId), + email: String(runtime.station.activeByEmail || "openwebrx-owner"), + role: "operator" + }; + } + + markOpenWebRxSession(user, verified); + return { ok: true, user }; +} + +function resolveOpenWebRxOwnerFromRequestContext(req, url) { + if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) { + return null; + } + const openWebRxPath = String(config.openWebRxPath || "/sdr/").trim() || "/sdr/"; + const pathname = String(url && url.pathname ? url.pathname : ""); + const originalUri = String(req && req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : ""); + const referer = String(req && req.headers ? (req.headers.referer || req.headers.referrer || "") : ""); + + const looksLikeOpenWebRxContext = pathname.startsWith("/v1/openwebrx/plugin/") + || originalUri.includes(openWebRxPath) + || referer.includes(openWebRxPath); + if (!looksLikeOpenWebRxContext) { + return null; + } + + return runtime.users.find((entry) => String(entry.id) === String(runtime.station.activeByUserId || "")) || null; +} + +async function handleOpenWebRxAuthorize(req, res, url) { + if (canAuthorizeOpenWebRxByActiveSession()) { + await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 }); + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("ok"); + return; + } + + const queryTicket = String(url.searchParams.get("ticket") || "").trim(); + const cookieTicket = readCookie(req.headers && req.headers.cookie, "rms_owrx_ticket"); + const originalUriHeader = req.headers ? (req.headers["x-original-uri"] || req.headers["x-rewrite-uri"] || "") : ""; + const originalUriTicket = extractTicketFromUri(originalUriHeader); + const refererHeader = req.headers ? (req.headers.referer || req.headers.referrer || "") : ""; + const refererTicket = extractTicketFromUri(refererHeader); + const tickets = []; + if (queryTicket) { + tickets.push(queryTicket); + } + if (cookieTicket && cookieTicket !== queryTicket) { + tickets.push(cookieTicket); + } + if (originalUriTicket && !tickets.includes(originalUriTicket)) { + tickets.push(originalUriTicket); + } + if (refererTicket && !tickets.includes(refererTicket)) { + tickets.push(refererTicket); + } + if (tickets.length === 0) { + if (canAuthorizeOpenWebRxWebSocketWithoutTicket(req, originalUriHeader)) { + await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 }); + res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("ok"); + return; + } + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("forbidden"); + return; + } + + const provider = runtime.pluginState.providers["openwebrx.access.verify"]; + if (!provider) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("forbidden"); + return; + } + + try { + let result = null; + for (const ticket of tickets) { + const candidate = await executeCapability("openwebrx.access.verify", "verifyAccess", { ticket }, { skipTxSafety: true }); + if (candidate && candidate.ok) { + result = candidate; + break; + } + } + const ownerUserId = runtime.station.activeByUserId; + const allowed = Boolean( + result && + result.ok && + runtime.station.isInUse && + ownerUserId && + result.userId === ownerUserId + ); + if (!allowed) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("forbidden"); + return; + } + markOpenWebRxSession({ id: result.userId }, result); + await ensureOpenWebRxSdrPath(null, { force: false, minIntervalMs: 3000 }); + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "X-RMS-User-Id": String(result.userId) + }); + res.end("ok"); + } catch { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("forbidden"); + } +} + +function readCookie(cookieHeader, name) { + const target = String(name || "").trim(); + if (!target) { + return ""; + } + const raw = String(cookieHeader || ""); + if (!raw) { + return ""; + } + const pairs = raw.split(";"); + let lastValue = ""; + for (const pair of pairs) { + const idx = pair.indexOf("="); + if (idx === -1) { + continue; + } + const key = pair.slice(0, idx).trim(); + if (key !== target) { + continue; + } + const value = pair.slice(idx + 1).trim(); + if (!value) { + continue; + } + try { + lastValue = decodeURIComponent(value); + } catch { + lastValue = value; + } + } + return lastValue; +} + +function extractTicketFromUri(rawUri) { + const input = String(rawUri || "").trim(); + if (!input) { + return ""; + } + const queryIndex = input.indexOf("?"); + if (queryIndex === -1) { + return ""; + } + const query = input.slice(queryIndex + 1); + const params = new URLSearchParams(query); + return String(params.get("ticket") || "").trim(); +} + +function canAuthorizeOpenWebRxWebSocketWithoutTicket(req, originalUriHeader) { + const headers = req && req.headers ? req.headers : {}; + const upgrade = String(headers.upgrade || "").toLowerCase(); + const hasWebSocketKey = Boolean(headers["sec-websocket-key"]); + const isWebSocket = upgrade === "websocket" || hasWebSocketKey; + if (!isWebSocket) { + return false; + } + if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) { + return false; + } + return true; +} + +function canAuthorizeOpenWebRxByActiveSession() { + if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) { + return false; + } + return true; +} + +async function handleHelpContent(res, user) { + if (!hasRole(user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + + const provider = runtime.pluginState.providers["help.content.read"]; + if (!provider) { + return sendJson(res, 200, { + content: { + version: 1, + title: "RMS Hilfe", + quickStart: { + title: "Schnellstart", + steps: ["Plugin 'rms.help.basic' aktivieren, um Hilfetexte anzuzeigen."] + }, + sections: [] + } + }); + } + + const content = await executeCapability("help.content.read", "getContent", {}, { user, skipTxSafety: true }); + return sendJson(res, 200, { content }); +} + +async function handleDebugCollect(req, res, url, scope = "owrx") { + const auth = authorizeDebugRequest(req, res, url); + if (!auth) { + return; + } + const settings = getDebugRemoteSettings(); + if (!settings.enabled) { + return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert"); + } + const requestedLines = Number(url.searchParams.get("lines") || ""); + const lineOverride = Number.isFinite(requestedLines) ? Math.max(50, Math.min(4000, Math.trunc(requestedLines))) : null; + const result = await collectDebugLogs(scope, settings, lineOverride); + return sendJson(res, 200, { + ok: true, + scope, + collectedAt: result.collectedAt, + totalLines: result.totalLines, + keptLines: result.keptLines, + outputPath: result.outputPath, + snapshotPath: result.snapshotPath + }); +} + +async function handleDebugLogs(req, res, url, scope = "owrx") { + const auth = authorizeDebugRequest(req, res, url); + if (!auth) { + return; + } + const settings = getDebugRemoteSettings(); + if (!settings.enabled) { + return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert"); + } + const paths = resolveDebugPaths(scope); + let raw = ""; + try { + raw = await fsp.readFile(paths.logFilePath, "utf8"); + } catch { + raw = ""; + } + const requestedLines = Number(url.searchParams.get("lines") || settings.collectLines); + const lineLimit = Math.max(50, Math.min(4000, Number.isFinite(requestedLines) ? Math.trunc(requestedLines) : settings.collectLines)); + const lines = raw.split(/\r?\n/).filter(Boolean); + const tail = lines.slice(Math.max(0, lines.length - lineLimit)); + const snapshot = await readDebugSnapshot(scope); + return sendJson(res, 200, { + ok: true, + scope, + lines: tail, + total: lines.length, + limit: lineLimit, + collectedAt: snapshot && snapshot.collectedAt ? snapshot.collectedAt : null + }); +} + +async function handleDebugSnapshot(req, res, url, scope = "owrx") { + const auth = authorizeDebugRequest(req, res, url); + if (!auth) { + return; + } + const settings = getDebugRemoteSettings(); + if (!settings.enabled) { + return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert"); + } + let snapshot = buildDebugRuntimeSnapshot(scope); + const saved = await readDebugSnapshot(scope); + if (saved && typeof saved === "object") { + snapshot = { + ...snapshot, + ...saved, + ok: true + }; + } + return sendJson(res, 200, snapshot); +} + +async function handleDebugClear(req, res, url, scope = "owrx") { + const auth = authorizeDebugRequest(req, res, url); + if (!auth) { + return; + } + const settings = getDebugRemoteSettings(); + if (!settings.enabled) { + return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert"); + } + const paths = resolveDebugPaths(scope); + await fsp.mkdir(paths.debugDir, { recursive: true }); + await fsp.writeFile(paths.logFilePath, "", "utf8"); + await fsp.writeFile(paths.snapshotFilePath, JSON.stringify({ scope, collectedAt: new Date().toISOString(), cleared: true }, null, 2), "utf8"); + return sendJson(res, 200, { ok: true, scope, cleared: true }); +} + +async function handleDebugWhich(req, res, url) { + const auth = authorizeDebugRequest(req, res, url); + if (!auth) { + return; + } + const settings = getDebugRemoteSettings(); + if (!settings.enabled) { + return sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert"); + } + const name = String(url.searchParams.get("bin") || url.searchParams.get("name") || "ffmpeg").trim().toLowerCase(); + if (!/^[a-z0-9._+-]{1,64}$/.test(name)) { + return sendError(res, 400, "debug.which.invalid", "ungueltiger binary name"); + } + const found = await resolveBinaryPath(name); + const collectedAt = new Date().toISOString(); + const paths = resolveDebugPaths("which"); + await fsp.mkdir(paths.debugDir, { recursive: true }); + const line = `${collectedAt} ${name} => ${found.path || "NOT_FOUND"}`; + await fsp.appendFile(paths.logFilePath, `${line}\n`, "utf8"); + const snapshot = { + ok: true, + scope: "which", + collectedAt, + name, + found: found.found, + path: found.path || null + }; + await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); + return sendJson(res, 200, snapshot); +} + +async function resolveBinaryPath(name) { + if (process.platform === "win32") { + const whereResult = await runCommand(`where ${name}`, { timeoutMs: 4000 }); + const pathLine = whereResult.ok ? firstOutputLine(whereResult.stdout || "") : ""; + return { found: Boolean(pathLine), path: pathLine || "" }; + } + const commandResult = await runCommand(`command -v ${name}`, { timeoutMs: 4000 }); + let pathLine = commandResult.ok ? firstOutputLine(commandResult.stdout || "") : ""; + if (!pathLine) { + const whichResult = await runCommand(`which ${name}`, { timeoutMs: 4000 }); + pathLine = whichResult.ok ? firstOutputLine(whichResult.stdout || "") : ""; + } + return { found: Boolean(pathLine), path: pathLine || "" }; +} + +function firstOutputLine(value) { + const lines = String(value || "").split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean); + return lines.length > 0 ? lines[0] : ""; +} + +function authorizeDebugRequest(req, res, url) { + if (!DEBUG_REMOTE_INTERFACE_ENABLED) { + sendError(res, 404, "debug.disabled", "Debug endpoint deaktiviert"); + return null; + } + const settings = getDebugRemoteSettings(); + const token = readDebugBearerToken(req); + if (token && token === settings.remoteToken) { + return { via: "token" }; + } + const auth = requireAuth(req, res); + if (!auth) { + return null; + } + if (!hasRole(auth.user, ["admin"])) { + sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + return null; + } + return { via: "auth", user: auth.user }; +} + +function readDebugBearerToken(req) { + const header = String(req && req.headers ? (req.headers.authorization || "") : "").trim(); + if (!header) { + return ""; + } + const match = /^Bearer\s+(.+)$/i.exec(header); + return match ? String(match[1] || "").trim() : ""; +} + +function getDebugRemoteSettings() { + const pluginId = "rms.debug.remote"; + const current = runtime.pluginState && runtime.pluginState.settings && runtime.pluginState.settings[pluginId] + ? runtime.pluginState.settings[pluginId] + : {}; + const collectLinesRaw = Number(current.collectLines); + const collectLines = Number.isFinite(collectLinesRaw) ? Math.max(100, Math.min(4000, Math.trunc(collectLinesRaw))) : 800; + const unitName = String(current.unitName || "remotestation-arcg").trim() || "remotestation-arcg"; + const enabled = current.enabled !== false; + const redactSensitive = current.redactSensitive !== false; + const includePatternsRaw = String(current.includePatterns || "").trim(); + const includePatterns = includePatternsRaw + ? includePatternsRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean) + : defaultDebugIncludePatterns("owrx"); + const includePatternsUsbRaw = String(current.includePatternsUsb || "").trim(); + const includePatternsUsb = includePatternsUsbRaw + ? includePatternsUsbRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean) + : defaultDebugIncludePatterns("usb"); + const includePatternsAlsaRaw = String(current.includePatternsAlsa || "").trim(); + const includePatternsAlsa = includePatternsAlsaRaw + ? includePatternsAlsaRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean) + : defaultDebugIncludePatterns("alsa"); + const includePatternsSoapyRaw = String(current.includePatternsSoapy || "").trim(); + const includePatternsSoapy = includePatternsSoapyRaw + ? includePatternsSoapyRaw.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean) + : defaultDebugIncludePatterns("soapy"); + return { + enabled, + remoteToken: String(current.remoteToken || DEBUG_REMOTE_DEFAULT_TOKEN).trim(), + collectLines, + includePatterns, + includePatternsUsb, + includePatternsAlsa, + includePatternsSoapy, + redactSensitive, + unitName + }; +} + +function defaultDebugIncludePatterns(scope) { + if (scope === "usb" || scope === "which" || scope === "alsa" || scope === "soapy") { + return []; + } + return [ + "openwebrx/plugin/state", + "openwebrx/plugin/bands/select", + "openwebrx/plugin/audio/connect", + "openwebrx/rotor/status", + "upstream timed out", + "connection refused", + "prematurely closed" + ]; +} + +function resolveDebugPaths(scope = "owrx") { + const normalizedScope = String(scope || "owrx").toLowerCase(); + const safeScope = normalizedScope === "usb" + ? "usb" + : (normalizedScope === "which" + ? "which" + : (normalizedScope === "alsa" ? "alsa" : (normalizedScope === "soapy" ? "soapy" : "owrx"))); + const debugDir = path.join(config.dataDir, "debug"); + return { + debugDir, + logFilePath: path.join(debugDir, `${safeScope}-debug.log`), + snapshotFilePath: path.join(debugDir, `${safeScope}-debug-snapshot.json`) + }; +} + +async function collectDebugLogs(scope, settings, lineOverride = null) { + const normalizedScope = String(scope || "owrx").toLowerCase(); + const safeScope = normalizedScope === "usb" + ? "usb" + : (normalizedScope === "alsa" ? "alsa" : (normalizedScope === "soapy" ? "soapy" : "owrx")); + const paths = resolveDebugPaths(safeScope); + await fsp.mkdir(paths.debugDir, { recursive: true }); + const collectLines = Number.isFinite(Number(lineOverride)) + ? Math.max(50, Math.min(4000, Math.trunc(Number(lineOverride)))) + : settings.collectLines; + let collection; + if (safeScope === "usb") { + collection = await collectUsbDebugLogs(collectLines); + } else if (safeScope === "alsa") { + collection = await collectAlsaDebugLogs(); + } else if (safeScope === "soapy") { + collection = await collectSoapyDebugLogs(); + } else { + collection = await collectOpenWebRxDebugLogs(settings.unitName, collectLines); + } + const allLines = collection.lines; + const kept = filterDebugLines( + allLines, + safeScope === "usb" + ? settings.includePatternsUsb + : (safeScope === "alsa" + ? settings.includePatternsAlsa + : (safeScope === "soapy" ? settings.includePatternsSoapy : settings.includePatterns)), + settings.redactSensitive + ); + const collectedAt = new Date().toISOString(); + await fsp.writeFile(paths.logFilePath, kept.join("\n") + (kept.length ? "\n" : ""), "utf8"); + const snapshot = safeScope === "usb" + ? { + ...buildDebugRuntimeSnapshot("usb"), + collectedAt, + scope: "usb", + collectLines, + totalLines: allLines.length, + keptLines: kept.length, + commands: collection.commands + } + : (safeScope === "alsa" + ? { + ...buildDebugRuntimeSnapshot("alsa"), + collectedAt, + scope: "alsa", + collectLines, + totalLines: allLines.length, + keptLines: kept.length, + commands: collection.commands + } + : (safeScope === "soapy" + ? { + ...buildDebugRuntimeSnapshot("soapy"), + collectedAt, + scope: "soapy", + collectLines, + totalLines: allLines.length, + keptLines: kept.length, + commands: collection.commands + } + : { + ...buildDebugRuntimeSnapshot("owrx"), + collectedAt, + scope: "owrx", + collectLines, + totalLines: allLines.length, + keptLines: kept.length, + unitName: settings.unitName + })); + await fsp.writeFile(paths.snapshotFilePath, JSON.stringify(snapshot, null, 2), "utf8"); + return { + scope: safeScope, + collectedAt, + totalLines: allLines.length, + keptLines: kept.length, + outputPath: paths.logFilePath, + snapshotPath: paths.snapshotFilePath + }; +} + +async function collectOpenWebRxDebugLogs(unitName, collectLines) { + const cmd = `sudo -n journalctl -u ${unitName} -n ${collectLines} --no-pager -o short-iso || journalctl -u ${unitName} -n ${collectLines} --no-pager -o short-iso`; + const result = await runCommand(cmd, { timeoutMs: 12000 }); + const raw = String(result.stdout || result.stderr || ""); + return { + lines: raw.split(/\r?\n/).filter(Boolean), + commands: [ + { + command: cmd, + ok: Boolean(result.ok), + code: result.code + } + ] + }; +} + +async function collectUsbDebugLogs(collectLines) { + const commands = [ + "id", + "whoami", + "groups", + "command -v usbrelay", + "command -v rigctl", + "usbrelay --help", + "rigctl --version", + "ls -l /dev/rms-*", + "ls -l /dev/ttyUSB*", + "ls -l /dev/serial/by-id", + "lsusb", + "lsusb -t", + `sudo -n journalctl -k -n ${collectLines} --no-pager -o short-iso || journalctl -k -n ${collectLines} --no-pager -o short-iso`, + "sudo -n dmesg -T | tail -n 300 || dmesg -T | tail -n 300", + "sudo -n journalctl -u remotestation-arcg -n 300 --no-pager -o short-iso || journalctl -u remotestation-arcg -n 300 --no-pager -o short-iso" + ]; + const all = []; + const meta = []; + for (const command of commands) { + const result = await runCommand(command, { timeoutMs: 12000 }); + const body = String(result.stdout || result.stderr || "").trim(); + all.push(`[cmd] ${command}`); + all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`); + if (body) { + for (const line of body.split(/\r?\n/)) { + all.push(line); + } + } + all.push(""); + meta.push({ command, ok: Boolean(result.ok), code: result.code }); + } + return { + lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])), + commands: meta + }; +} + +async function collectAlsaDebugLogs() { + const commands = [ + "aplay -l", + "aplay -L", + "arecord -l", + "arecord -L", + "cat /proc/asound/cards", + "cat /proc/asound/devices" + ]; + const all = []; + const meta = []; + for (const command of commands) { + const result = await runCommand(command, { timeoutMs: 12000 }); + const body = String(result.stdout || result.stderr || "").trim(); + all.push(`[cmd] ${command}`); + all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`); + if (body) { + for (const line of body.split(/\r?\n/)) { + all.push(line); + } + } + all.push(""); + meta.push({ command, ok: Boolean(result.ok), code: result.code }); + } + return { + lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])), + commands: meta + }; +} + +async function collectSoapyDebugLogs() { + const commands = [ + "id", + "groups", + "lsusb -d 03eb:800c", + "soapy_connector --listdrivers", + "soapy_connector --listmodules", + "SoapySDRUtil --find=\"driver=airspyhf\"", + "SoapySDRUtil --find", + "ls -l /dev/bus/usb/*/*" + ]; + const all = []; + const meta = []; + for (const command of commands) { + const result = await runCommand(command, { timeoutMs: 12000 }); + const body = String(result.stdout || result.stderr || "").trim(); + all.push(`[cmd] ${command}`); + all.push(`[ok=${result.ok ? "true" : "false"} code=${result.code}]`); + if (body) { + for (const line of body.split(/\r?\n/)) { + all.push(line); + } + } + all.push(""); + meta.push({ command, ok: Boolean(result.ok), code: result.code }); + } + return { + lines: all.filter((line, idx, arr) => line || (idx > 0 && arr[idx - 1])), + commands: meta + }; +} + +function buildDebugRuntimeSnapshot(scope) { + if (scope === "usb") { + return { + ok: true, + scope: "usb", + collectedAt: null, + station: buildStationStatusView(), + usb: { + note: "USB snapshot from command dump" + } + }; + } + if (scope === "which") { + return { + ok: true, + scope: "which", + collectedAt: null, + station: buildStationStatusView(), + which: { + note: "binary path checks" + } + }; + } + if (scope === "alsa") { + return { + ok: true, + scope: "alsa", + collectedAt: null, + station: buildStationStatusView(), + alsa: { + note: "ALSA playback/capture device dump" + } + }; + } + if (scope === "soapy") { + return { + ok: true, + scope: "soapy", + collectedAt: null, + station: buildStationStatusView(), + soapy: { + note: "SoapySDR and USB access diagnostics" + } + }; + } + return { + ok: true, + scope: "owrx", + collectedAt: null, + station: buildStationStatusView(), + openwebrx: { + pttActive: runtime.pttActive, + antennaRoute: runtime.openWebRxAntennaRoute || null, + session: runtime.openWebRxSession || null + }, + txAudio: { + running: Boolean(runtime.txAudio && runtime.txAudio.running), + clients: runtime.txAudio && runtime.txAudio.clients ? runtime.txAudio.clients.size : 0, + lastError: runtime.txAudio ? runtime.txAudio.lastError || null : null, + lastExit: runtime.txAudio ? runtime.txAudio.lastExit || null : null + } + }; +} + +async function readDebugSnapshot(scope = "owrx") { + const paths = resolveDebugPaths(scope); + try { + const raw = await fsp.readFile(paths.snapshotFilePath, "utf8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +function filterDebugLines(lines, includePatterns, redactSensitive) { + const patterns = Array.isArray(includePatterns) + ? includePatterns.map((entry) => String(entry || "").toLowerCase()).filter(Boolean) + : []; + const out = []; + for (const line of lines) { + const normalized = String(line || ""); + const lower = normalized.toLowerCase(); + if (patterns.length > 0 && !patterns.some((entry) => lower.includes(entry))) { + continue; + } + out.push(redactSensitive ? redactDebugLine(normalized) : normalized); + } + return out; +} + +function redactDebugLine(line) { + let next = String(line || ""); + next = next.replace(/(accessToken=)[^\s&]+/gi, "$1[redacted]"); + next = next.replace(/(ticket=)[^\s&]+/gi, "$1[redacted]"); + next = next.replace(/(Authorization:\s*Bearer\s+)[A-Za-z0-9_\-.]+/gi, "$1[redacted]"); + next = next.replace(/(eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.)[A-Za-z0-9_\-]+/g, "$1[redacted]"); + return next; +} + +async function handleUiControlAction(req, res, user, pathname, body) { + const parts = pathname.split("/").filter(Boolean); + const controlsIdx = parts.indexOf("controls"); + const actionsIdx = parts.indexOf("actions"); + if (controlsIdx === -1 || actionsIdx === -1 || actionsIdx <= controlsIdx + 1) { + return sendError(res, 404, "ui.control.not_found", "Control nicht gefunden"); + } + + const controlId = decodeURIComponent(parts[controlsIdx + 1]); + const action = decodeURIComponent(parts[actionsIdx + 1] || ""); + const input = body && typeof body.input === "object" ? body.input : {}; + + if (controlId === "station-main") { + if (!hasRole(user, ["operator", "approver", "admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + if (action === "activate") { + return handleActivationStart(res, user); + } + if (action === "release") { + return handleStationRelease(res, user); + } + return sendError(res, 404, "ui.action.not_found", "Action nicht gefunden"); + } + + const controls = await buildUiControls(user); + const control = controls.find((entry) => entry.controlId === controlId); + if (!control) { + return sendError(res, 404, "ui.control.not_found", "Control nicht gefunden"); + } + const descriptor = control.actions.find((entry) => entry.name === action); + if (!descriptor) { + return sendError(res, 404, "ui.action.not_found", "Action nicht gefunden"); + } + + if (!control.capabilityProvider || !control.capabilityProvider.capability) { + return sendError(res, 500, "ui.provider.missing", "Kein Plugin Provider zugewiesen"); + } + if (!hasCapability(user, control.capabilityProvider.capability)) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + + try { + const result = await executeCapability(control.capabilityProvider.capability, action, input, { user }); + return sendJson(res, 200, { + ok: true, + result: { + accepted: true, + message: result && result.message ? result.message : "Action ausgefuehrt", + ...result + } + }); + } catch (error) { + if (error && error.code === "TX_SWITCH_LOCK") { + return sendError(res, 409, "tx.switch_locked", String(error.message || error), error.details || null); + } + if (error && error.code === "VSWR_WHILE_STATION_ACTIVE") { + return sendError(res, 409, "vswr.unsafe_station_active", String(error.message || error)); + } + return sendError(res, 500, "plugin.execute_failed", String(error.message || error)); + } +} + +async function handlePluginToggle(req, res, user, pathname) { + const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/(enable|disable)$/); + if (!match) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + const pluginId = decodeURIComponent(match[1]); + const action = match[2]; + const plugin = runtime.plugins.get(pluginId); + if (!plugin) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + + if (action === "enable") { + runtime.pluginState.enabled[pluginId] = true; + if (plugin.instance.start) { + await plugin.instance.start(); + } + } else { + runtime.pluginState.enabled[pluginId] = false; + if (plugin.instance.stop) { + await plugin.instance.stop(); + } + } + + await savePluginState(); + await appendAudit(`plugin.${action}`, user, { pluginId }); + broadcastEvent("plugin.enabled.changed", { pluginId, enabled: runtime.pluginState.enabled[pluginId] }); + return sendJson(res, 200, { ok: true, plugin: describePlugin(plugin) }); +} + +function handlePluginSettingsSchema(res, pathname) { + const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings-schema$/); + if (!match) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + const pluginId = decodeURIComponent(match[1]); + const plugin = runtime.plugins.get(pluginId); + if (!plugin) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + return sendJson(res, 200, { + pluginId, + settingsSchema: plugin.manifest.settingsSchema || { type: "object", properties: {} } + }); +} + +function handlePluginSettingsGet(res, pathname) { + const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings$/); + if (!match) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + const pluginId = decodeURIComponent(match[1]); + const plugin = runtime.plugins.get(pluginId); + if (!plugin) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + return sendJson(res, 200, { + pluginId, + settings: runtime.pluginState.settings[pluginId] || {} + }); +} + +async function handlePluginSettingsPut(res, user, pathname, body) { + const match = pathname.match(/^\/v1\/plugins\/([^/]+)\/settings$/); + if (!match) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + const pluginId = decodeURIComponent(match[1]); + const plugin = runtime.plugins.get(pluginId); + if (!plugin) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + const nextSettings = body && typeof body.settings === "object" ? body.settings : null; + if (!nextSettings) { + return sendError(res, 400, "plugin.settings.invalid", "settings Objekt erforderlich"); + } + + const validation = validateSettings(plugin.manifest.settingsSchema || { type: "object", properties: {} }, nextSettings); + if (!validation.ok) { + return sendError(res, 400, "plugin.settings.invalid", validation.message); + } + + runtime.pluginState.settings[pluginId] = nextSettings; + await savePluginState(); + if (typeof plugin.instance.onSettingsUpdated === "function") { + await plugin.instance.onSettingsUpdated(nextSettings); + } + await appendAudit("plugin.settings.update", user, { pluginId }); + broadcastEvent("plugin.settings.changed", { pluginId }); + return sendJson(res, 200, { ok: true, pluginId, settings: nextSettings }); +} + +async function handleCapabilityProviderSwitch(res, user, pathname, body) { + const prefix = "/v1/admin/capabilities/"; + const capability = decodeURIComponent(pathname.slice(prefix.length, pathname.length - "/provider".length)); + const pluginId = typeof (body && body.pluginId) === "string" ? body.pluginId : ""; + const dryRun = Boolean(body && body.dryRun); + const plugin = runtime.plugins.get(pluginId); + + if (!capability || !pluginId) { + return sendError(res, 400, "provider.invalid_request", "capability und pluginId erforderlich"); + } + if (!plugin) { + return sendError(res, 404, "plugin.not_found", "Plugin nicht gefunden"); + } + if (!plugin.manifest.capabilities.includes(capability)) { + return sendError(res, 409, "provider.capability_mismatch", "Plugin unterstuetzt Capability nicht"); + } + + const health = await safePluginHealth(plugin); + if (dryRun) { + return sendJson(res, 200, { + capability, + activePluginId: runtime.pluginState.providers[capability] || null, + previousPluginId: runtime.pluginState.providers[capability] || null, + health, + switchedAt: new Date().toISOString() + }); + } + + const previous = runtime.pluginState.providers[capability] || null; + runtime.pluginState.providers[capability] = pluginId; + await savePluginState(); + await appendAudit("provider.switch", user, { capability, pluginId, previous }); + broadcastEvent("plugin.provider.changed", { capability, pluginId, previousPluginId: previous }); + return sendJson(res, 200, { + capability, + activePluginId: pluginId, + previousPluginId: previous, + health, + switchedAt: new Date().toISOString() + }); +} + +function handleEventStream(req, res, url) { + const auth = requireAuth(req, res, { allowQueryToken: true, queryTokenUrl: url }); + if (!auth) return; + + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive" + }); + res.write(`event: connected\n`); + res.write(`data: ${JSON.stringify({ ok: true, user: sanitizeUser(auth.user) })}\n\n`); + + const client = { res, userId: auth.user.id }; + runtime.sseClients.add(client); + + req.on("close", () => { + runtime.sseClients.delete(client); + }); +} + +async function buildUiControls(user) { + const controls = []; + const activation = getCurrentActivationView(); + + if (user.role === "admin" || user.role === "approver" || user.role === "operator") { + controls.push({ + controlId: "station-main", + controlType: "swr.progress", + title: "Stationssteuerung", + capabilityProvider: { + capability: "station.activate", + pluginId: runtime.pluginState.providers["station.activate"] || null + }, + status: buildStationStatusView(), + viewSchema: { + showLinksAfterActivation: true + }, + actions: [ + { + name: "activate", + inputSchema: { type: "object", properties: {}, additionalProperties: false } + }, + { + name: "release", + inputSchema: { type: "object", properties: {}, additionalProperties: false } + } + ] + }); + } + + for (const plugin of runtime.plugins.values()) { + if (!runtime.pluginState.enabled[plugin.manifest.id]) { + continue; + } + const pluginControls = Array.isArray(plugin.manifest.uiControls) ? plugin.manifest.uiControls : []; + for (const control of pluginControls) { + const capability = control.capability || ""; + const provider = runtime.pluginState.providers[capability] || null; + if (provider !== plugin.manifest.id) { + continue; + } + if (capability && !hasCapability(user, capability)) { + continue; + } + const pluginStatus = control.controlType === "swr.progress" ? activation : (plugin.instance.getStatus ? await safeStatus(plugin.instance) : {}); + controls.push({ + controlId: control.controlId, + controlType: control.controlType, + title: control.title, + capabilityProvider: { + capability, + pluginId: plugin.manifest.id + }, + status: pluginStatus, + viewSchema: control.viewSchema || {}, + actions: (control.actions || []).map((entry) => ({ + name: entry.name, + inputSchema: entry.inputSchema || { type: "object", additionalProperties: true } + })) + }); + } + } + + return controls; +} + +async function buildSwrReportView(user) { + let report = { + source: "none", + generatedAt: null, + overallStatus: "UNKNOWN", + bands: [], + overviewUrl: String(process.env.SWR_OVERVIEW_URL || "") || null + }; + + try { + const pluginReport = await executeCapability("vswr.report.read", "getReport", {}, { user }); + if (pluginReport && typeof pluginReport === "object") { + report = { + ...report, + ...pluginReport, + bands: Array.isArray(pluginReport.bands) ? pluginReport.bands : [] + }; + } + } catch { + // optional capability + } + + if (!report.generatedAt) { + report.generatedAt = runtime.station.updatedAt || null; + } + return report; +} + +function buildStationStatusView() { + const activation = getCurrentActivationView(); + const swrRun = getCurrentSwrRunView(); + const links = { + swrOverview: String(process.env.SWR_OVERVIEW_URL || "") || null, + webSdr: String(process.env.WEBSDR_URL || "") || null, + rotorControl: String(process.env.ROTOR_CONTROL_URL || "") || null, + openWebRxPath: String(process.env.OPENWEBRX_PATH || "/sdr/") || "/sdr/" + }; + const linksReady = runtime.station.isInUse && !activation.running; + const nowMs = Date.now(); + const reservations = getNormalizedStationReservations(nowMs); + const activeReservation = findActiveReservationEntry(reservations, nowMs); + const activationReserved = Boolean(activation.running && !runtime.station.isInUse && activation.startedAt); + const effectiveStartedAt = runtime.station.isInUse + ? (runtime.station.startedAt || null) + : (activeReservation ? activeReservation.from : (activationReserved ? String(activation.startedAt) : null)); + const effectiveEndsAt = runtime.station.isInUse + ? (runtime.station.endsAt || null) + : (activeReservation ? activeReservation.to : (activationReserved ? computeLeaseEndIso(effectiveStartedAt) : null)); + const effectiveActiveByEmail = runtime.station.isInUse + ? runtime.station.activeByEmail + : (activeReservation ? activeReservation.email : (activationReserved ? (activation.startedBy || null) : null)); + const effectiveActiveByUserId = runtime.station.isInUse + ? runtime.station.activeByUserId + : (activeReservation ? activeReservation.userId : (activationReserved ? (activation.startedByUserId || null) : null)); + const remainingUsageSec = effectiveEndsAt + ? Math.max(0, Math.ceil((new Date(effectiveEndsAt).getTime() - Date.now()) / 1000)) + : 0; + const slotMs = stationUsageDurationMs(); + const reservationEntries = reservations.map((entry, index) => { + return { + position: index + 1, + userId: entry.userId, + email: entry.email, + createdAt: entry.createdAt, + from: entry.from, + to: entry.to, + active: Boolean(activeReservation + && String(activeReservation.userId || "") === String(entry.userId || "") + && String(activeReservation.from || "") === String(entry.from || "") + && String(activeReservation.to || "") === String(entry.to || "")) + }; + }); + const stationOccupied = Boolean(runtime.station.isInUse || activation.running); + const queueVisible = stationOccupied || reservationEntries.length > 0 || Boolean(activeReservation); + + return { + stationName: runtime.station.stationName, + stationOnline: Boolean(runtime.station.stationOnline), + maintenanceMode: Boolean(runtime.systemState.maintenanceMode), + maintenanceMessage: runtime.systemState.maintenanceMessage, + isInUse: Boolean(runtime.station.isInUse), + activeByUserId: effectiveActiveByUserId, + activeByEmail: effectiveActiveByEmail, + startedAt: effectiveStartedAt, + endsAt: effectiveEndsAt, + remainingUsageSec, + updatedAt: runtime.station.updatedAt, + lastAction: runtime.station.lastAction, + execMode: config.execMode, + simulateHardware: config.simulateHardware, + openWebRxTxPollMs: Math.max(1000, Math.min(60000, Number(config.openWebRxTxPollMs || 4000))), + activation, + swrRun, + reservationQueue: { + slotDurationSec: Math.floor(slotMs / 1000), + canReserve: stationOccupied, + visible: queueVisible, + slotLockActive: Boolean(activeReservation), + activeEntry: activeReservation + ? { + userId: activeReservation.userId, + email: activeReservation.email, + from: activeReservation.from, + to: activeReservation.to + } + : null, + entries: reservationEntries + }, + links, + linksReady + }; +} + +function getExpectedSWRDurationMs() { + const configured = Number(getPluginSetting("rms.vswr.nanovna", "expectedDurationMs", process.env.SWR_CHECK_DURATION_MS || 54000)); + if (!Number.isFinite(configured)) { + return 54000; + } + return Math.max(1000, Math.floor(configured)); +} + +function getCurrentSwrRunView() { + const current = runtime.swrRun || {}; + if (!current.running) { + return { + running: false, + source: null, + phase: null, + percent: 0, + elapsedSec: 0, + remainingSec: 0, + startedAt: null, + startedBy: null, + lastStatus: current.lastStatus || null, + lastError: current.lastError || null, + lastFinishedAt: current.lastFinishedAt || null + }; + } + const startedAtMs = Date.parse(String(current.startedAt || "")); + const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, Date.now() - startedAtMs) : 0; + const expectedMs = Number.isFinite(Number(current.expectedDurationMs)) + ? Math.max(1, Math.floor(Number(current.expectedDurationMs))) + : getExpectedSWRDurationMs(); + const percent = Math.max(0, Math.min(99, Math.floor((elapsedMs / Math.max(1, expectedMs)) * 100))); + return { + running: true, + source: current.source || null, + phase: current.phase || "swr-check", + percent, + elapsedSec: Math.floor(elapsedMs / 1000), + remainingSec: Math.max(0, Math.ceil((expectedMs - elapsedMs) / 1000)), + startedAt: current.startedAt || null, + startedBy: current.startedBy || null, + lastStatus: null, + lastError: null, + lastFinishedAt: null + }; +} + +function getCurrentActivationView() { + const job = runtime.currentActivationJobId ? runtime.jobs.get(runtime.currentActivationJobId) : null; + if (!job || job.status !== "running") { + const latest = getLatestActivationJob(); + return { + running: false, + phase: null, + percent: 0, + elapsedSec: 0, + remainingSec: 0, + startedAt: null, + startedBy: null, + startedByUserId: null, + lastStatus: latest && latest.status ? String(latest.status) : null, + lastError: latest && latest.status === "failed" && latest.error ? String(latest.error) : null, + lastFinishedAt: latest && latest.finishedAt ? latest.finishedAt : null + }; + } + const elapsedSec = Math.max(0, Math.floor((Date.now() - new Date(job.startedAt).getTime()) / 1000)); + return { + running: true, + phase: job.phase, + percent: Number(job.percent || 0), + elapsedSec, + remainingSec: Number(job.etaSec || 0), + startedAt: job.startedAt || null, + startedBy: job.startedBy || null, + startedByUserId: job.startedByUserId || null, + lastStatus: String(job.status || "running"), + lastError: null, + lastFinishedAt: null + }; +} + +function getLatestActivationJob() { + let latest = null; + for (const candidate of runtime.jobs.values()) { + if (!candidate || candidate.type !== "station.activate") { + continue; + } + if (!latest) { + latest = candidate; + continue; + } + const candidateTime = Date.parse(candidate.finishedAt || candidate.startedAt || 0); + const latestTime = Date.parse(latest.finishedAt || latest.startedAt || 0); + if (Number.isFinite(candidateTime) && (!Number.isFinite(latestTime) || candidateTime >= latestTime)) { + latest = candidate; + } + } + return latest; +} + +async function startGlobalSwrRun(user, options = {}) { + return withLock(async () => { + if (runtime.swrRun && runtime.swrRun.running) { + const error = new Error("SWR-Check laeuft bereits"); + error.code = "SWR_ALREADY_RUNNING"; + throw error; + } + const token = `swr_${crypto.randomUUID()}`; + runtime.swrRun = { + ...runtime.swrRun, + running: true, + token, + source: String(options.source || "manual"), + phase: String(options.phase || "swr-check"), + startedAt: new Date().toISOString(), + expectedDurationMs: Number.isFinite(Number(options.expectedDurationMs)) + ? Math.max(1000, Math.floor(Number(options.expectedDurationMs))) + : getExpectedSWRDurationMs(), + startedBy: user && user.email ? String(user.email) : null, + lastStatus: "running", + lastError: null, + lastFinishedAt: null + }; + broadcastEvent("swr.run.started", { + source: runtime.swrRun.source, + startedBy: runtime.swrRun.startedBy + }); + broadcastEvent("station.status.changed", buildStationStatusView()); + return token; + }); +} + +async function finishGlobalSwrRun(token, options = {}) { + return withLock(async () => { + if (!runtime.swrRun || !runtime.swrRun.running) { + return; + } + if (!token || String(runtime.swrRun.token || "") !== String(token)) { + return; + } + const status = String(options.status || "succeeded"); + runtime.swrRun = { + ...runtime.swrRun, + running: false, + token: null, + source: null, + phase: null, + startedAt: null, + expectedDurationMs: 0, + startedBy: null, + lastStatus: status, + lastError: options.error ? String(options.error) : null, + lastFinishedAt: new Date().toISOString() + }; + broadcastEvent("swr.run.finished", { + status, + error: runtime.swrRun.lastError, + finishedAt: runtime.swrRun.lastFinishedAt + }); + broadcastEvent("station.status.changed", buildStationStatusView()); + }); +} + +async function loadPlugins() { + runtime.plugins.clear(); + if (!fs.existsSync(config.pluginDir)) { + await fsp.mkdir(config.pluginDir, { recursive: true }); + return; + } + + const entries = await fsp.readdir(config.pluginDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const pluginPath = path.join(config.pluginDir, entry.name); + const manifestPath = path.join(pluginPath, "manifest.json"); + const indexPath = path.join(pluginPath, "index.js"); + if (!fs.existsSync(manifestPath) || !fs.existsSync(indexPath)) { + continue; + } + const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8")); + const factory = require(indexPath); + const createPlugin = typeof factory.createPlugin === "function" ? factory.createPlugin : factory; + const instance = await createPlugin({ + manifest, + rootDir, + env: process.env, + execMode: config.execMode, + simulateHardware: config.simulateHardware, + getSetting: (key, fallback = null) => getPluginSetting(manifest.id, key, fallback), + commandRunner: runCommand, + executeCapability: async (capability, action, input = {}, meta = {}) => executeCapability(capability, action, input, meta), + emit: (type, data) => broadcastEvent(type, { pluginId: manifest.id, ...data }), + mailOutboxPath: files.mailOutbox, + appendMailOutbox: async (entry) => { + await appendMailOutboxEntry(entry); + } + }); + + runtime.plugins.set(manifest.id, { + manifest, + instance, + pluginPath + }); + } + + for (const [id, plugin] of runtime.plugins.entries()) { + if (!(id in runtime.pluginState.enabled)) { + runtime.pluginState.enabled[id] = true; + } + if (runtime.pluginState.enabled[id] && plugin.instance.start) { + await plugin.instance.start(); + } + runtime.pluginHealth[id] = await safePluginHealth(plugin); + } + + ensureDefaultProviders(); + await savePluginState(); +} + +function ensureDefaultProviders() { + const byCapability = new Map(); + for (const plugin of runtime.plugins.values()) { + for (const capability of plugin.manifest.capabilities || []) { + if (!byCapability.has(capability)) { + byCapability.set(capability, []); + } + byCapability.get(capability).push(plugin.manifest.id); + } + } + + for (const [capability, pluginIds] of byCapability.entries()) { + const preferred = preferredProviderForCapability(capability, pluginIds); + if (preferred) { + runtime.pluginState.providers[capability] = preferred; + continue; + } + const current = runtime.pluginState.providers[capability]; + if (current && pluginIds.includes(current)) { + continue; + } + runtime.pluginState.providers[capability] = pluginIds[0]; + } +} + +function preferredProviderForCapability(capability, pluginIds) { + if (!Array.isArray(pluginIds) || pluginIds.length === 0) { + return null; + } + if ((capability === "tx.control" || capability === "tx.state.read") && pluginIds.includes("rms.tx.control.native")) { + return "rms.tx.control.native"; + } + if (capability === "tx.audio" && pluginIds.includes("rms.tx.audio.core")) { + return "rms.tx.audio.core"; + } + if (capability === "tx.audio.backend" && pluginIds.includes("rms.microham")) { + return "rms.microham"; + } + if ((capability === "vswr.run" || capability === "vswr.report.read") && pluginIds.includes("rms.vswr.native")) { + return "rms.vswr.native"; + } + if ((capability === "openwebrx.access.issue" || capability === "openwebrx.access.verify" || capability === "openwebrx.service.control") + && pluginIds.includes("rms.openwebrx.guard")) { + return "rms.openwebrx.guard"; + } + if ((capability === "openwebrx.band.read" || capability === "openwebrx.band.set") && pluginIds.includes("rms.openwebrx.bandmap")) { + return "rms.openwebrx.bandmap"; + } + if ((capability === "station.access.policy.read" || capability === "admin.station.access.policy.write") + && pluginIds.includes("rms.station.access.policy")) { + return "rms.station.access.policy"; + } + if (capability === "help.content.read" && pluginIds.includes("rms.help.basic")) { + return "rms.help.basic"; + } + return null; +} + +function describePlugin(plugin) { + return { + id: plugin.manifest.id, + name: plugin.manifest.name, + version: plugin.manifest.version, + capabilities: plugin.manifest.capabilities || [], + authMethod: plugin.manifest.authMethod || null, + settingsSchema: plugin.manifest.settingsSchema || { type: "object", properties: {} }, + settings: runtime.pluginState.settings[plugin.manifest.id] || {}, + enabled: Boolean(runtime.pluginState.enabled[plugin.manifest.id]), + health: runtime.pluginHealth[plugin.manifest.id] || "unknown", + providerFor: Object.entries(runtime.pluginState.providers) + .filter(([, pluginId]) => pluginId === plugin.manifest.id) + .map(([capability]) => capability) + }; +} + +async function listPlugins() { + await refreshPluginHealth(); + return Array.from(runtime.plugins.values()).map((plugin) => describePlugin(plugin)); +} + +async function buildCapabilitiesMatrix() { + await refreshPluginHealth(); + const map = new Map(); + for (const plugin of runtime.plugins.values()) { + for (const capability of plugin.manifest.capabilities || []) { + if (!map.has(capability)) { + map.set(capability, { + capability, + activePluginId: runtime.pluginState.providers[capability] || null, + providers: [] + }); + } + map.get(capability).providers.push({ + pluginId: plugin.manifest.id, + enabled: Boolean(runtime.pluginState.enabled[plugin.manifest.id]), + health: runtime.pluginHealth[plugin.manifest.id] || "unknown" + }); + } + } + return Array.from(map.values()).sort((a, b) => a.capability.localeCompare(b.capability)); +} + +async function executeCapability(capability, action, input, meta) { + if (runtime.pttActive && capability === "rfroute.set" && action === "setRoute") { + const route = String(input && input.route ? input.route : "").toLowerCase(); + if (route && route !== "tx" && !(meta && meta.allowPttOverride)) { + const error = new Error("PTT aktiv: Antennenweg bleibt auf TX gesperrt"); + error.code = "PTT_ROUTE_LOCK"; + throw error; + } + } + if (isSwitchingCapability(capability, action) && !(meta && meta.skipTxSafety)) { + const txState = await readTransmitState(meta && meta.user ? meta.user : null); + if (txState.txActive) { + const error = new Error("Umschalten ist waehrend aktivem Senden gesperrt"); + error.code = "TX_SWITCH_LOCK"; + error.details = txState; + throw error; + } + } + if (capability === "vswr.run" && isOpenWebRxSessionActive()) { + const error = new Error("SWR-Check ist gesperrt solange OpenWebRX aktiv genutzt wird"); + error.code = "OPENWEBRX_SESSION_ACTIVE"; + throw error; + } + if (capability === "vswr.run" && runtime.station && runtime.station.isInUse) { + const error = new Error("SWR-Check ist gesperrt solange die Station aktiv ist"); + error.code = "VSWR_WHILE_STATION_ACTIVE"; + throw error; + } + const pluginId = runtime.pluginState.providers[capability]; + if (!pluginId) { + throw new Error(`No provider for capability ${capability}`); + } + const plugin = runtime.plugins.get(pluginId); + if (!plugin) { + throw new Error(`Provider plugin ${pluginId} missing`); + } + if (!runtime.pluginState.enabled[pluginId]) { + throw new Error(`Provider plugin ${pluginId} disabled`); + } + if (typeof plugin.instance.execute !== "function") { + throw new Error(`Provider plugin ${pluginId} has no execute()`); + } + return plugin.instance.execute(action, input || {}, meta || {}); +} + +function isOpenWebRxSessionActive() { + const session = runtime.openWebRxSession || {}; + if (!session.activeOwnerUserId) { + return false; + } + if (!session.expiresAtMs || session.expiresAtMs <= Date.now()) { + if (runtime.station + && runtime.station.isInUse + && runtime.station.activeByUserId + && String(runtime.station.activeByUserId) === String(session.activeOwnerUserId)) { + const ttlSec = Number(process.env.OPENWEBRX_TICKET_TTL_SEC || 3600); + const effectiveTtlSec = Number.isFinite(ttlSec) ? Math.max(60, ttlSec) : 120; + runtime.openWebRxSession.expiresAtMs = Date.now() + effectiveTtlSec * 1000; + return true; + } + clearOpenWebRxSession(); + return false; + } + return true; +} + +function markOpenWebRxSession(user, sessionResult) { + const ownerUserId = user && user.id ? String(user.id) : String(sessionResult && sessionResult.userId ? sessionResult.userId : ""); + const expiresAtMs = Date.parse(String(sessionResult && sessionResult.expiresAt ? sessionResult.expiresAt : "")); + runtime.openWebRxSession = { + activeOwnerUserId: ownerUserId || null, + expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : (Date.now() + 120000), + lastEnsureSdrAtMs: runtime.openWebRxSession && Number.isFinite(runtime.openWebRxSession.lastEnsureSdrAtMs) + ? runtime.openWebRxSession.lastEnsureSdrAtMs + : 0 + }; +} + +function clearOpenWebRxSession() { + const previousOwnerUserId = runtime.openWebRxSession && runtime.openWebRxSession.activeOwnerUserId + ? String(runtime.openWebRxSession.activeOwnerUserId) + : ""; + runtime.openWebRxSession = { + activeOwnerUserId: null, + expiresAtMs: 0, + lastEnsureSdrAtMs: 0 + }; + runtime.openWebRxAntennaRoute = null; + if (previousOwnerUserId) { + delete runtime.openWebRxLiveStateByUserId[previousOwnerUserId]; + } + executeCapability("tx.audio", "audioDisconnect", { reason: "session-cleared" }, { skipTxSafety: true }).catch(() => {}); +} + +async function ensureOpenWebRxSdrPath(user, options = {}) { + const provider = runtime.pluginState.providers["openwebrx.service.control"]; + if (!provider) { + return; + } + const force = Boolean(options.force); + const minIntervalMs = Number.isFinite(Number(options.minIntervalMs)) ? Number(options.minIntervalMs) : 3000; + const last = runtime.openWebRxSession && Number.isFinite(runtime.openWebRxSession.lastEnsureSdrAtMs) + ? runtime.openWebRxSession.lastEnsureSdrAtMs + : 0; + if (!force && Date.now() - last < Math.max(0, minIntervalMs)) { + return; + } + await executeCapability("openwebrx.service.control", "ensureSdrPath", { reason: "openwebrx-session" }, { user, skipTxSafety: true }); + runtime.openWebRxSession.lastEnsureSdrAtMs = Date.now(); +} + +function isSwitchingCapability(capability, action) { + if (capability === "rfroute.set") { + return action === "setRoute"; + } + return capability === "station.activate" + || capability === "station.deactivate" + || capability === "vswr.run"; +} + +function shouldAutoDisableTxBeforeActivation() { + const value = String(config.autoDisableTxBeforeActivation || "false").trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +async function readTransmitState(user) { + const provider = runtime.pluginState.providers["tx.state.read"]; + if (!provider) { + return { txActive: false, source: "none" }; + } + try { + const state = await executeCapability("tx.state.read", "getTxState", {}, { user, skipTxSafety: true }); + return { + txActive: Boolean(state && state.txActive), + source: state && state.source ? state.source : provider, + updatedAt: state && state.updatedAt ? state.updatedAt : null + }; + } catch { + return { txActive: false, source: provider }; + } +} + +function requireAuth(req, res, options = {}) { + const token = getBearerToken(req) || (options.allowQueryToken && options.queryTokenUrl ? options.queryTokenUrl.searchParams.get("accessToken") : ""); + if (!token) { + sendError(res, 401, "auth.missing_token", "Token fehlt"); + return null; + } + const verified = verifyJwt(token, "access"); + if (!verified.ok) { + sendError(res, 401, "auth.invalid_token", "Token ungueltig"); + return null; + } + const payload = verified.payload; + const user = runtime.users.find((entry) => entry.id === payload.sub); + if (!user) { + sendError(res, 401, "auth.invalid_token", "Token ungueltig"); + return null; + } + const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0; + if (payload.tv !== tokenVersion) { + sendError(res, 401, "auth.invalid_token", "Token ungueltig"); + return null; + } + if (user.status !== "active") { + sendError(res, 403, "auth.not_approved", "Benutzer ist nicht freigegeben"); + return null; + } + if (runtime.systemState.maintenanceMode && user.role !== "admin") { + sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage); + return null; + } + return { user, payload }; +} + +function getBearerToken(req) { + const header = req.headers.authorization || ""; + const match = /^Bearer\s+(.+)$/i.exec(header); + return match ? match[1].trim() : ""; +} + +async function issueTokenPair(user, req, options = {}) { + const nowSec = Math.floor(Date.now() / 1000); + const sid = options.sid || crypto.randomUUID(); + const rid = crypto.randomUUID(); + const tokenVersion = runtime.authState.tokenVersionByUser[user.id] || 0; + + const effectiveAccessTtlSec = getEffectiveAccessTokenTtlSec(user, nowSec); + + const accessPayload = { + sub: user.id, + email: user.email, + role: user.role, + sid, + tv: tokenVersion, + typ: "access", + iat: nowSec, + exp: nowSec + effectiveAccessTtlSec, + iss: config.jwtIssuer, + aud: config.jwtAudience + }; + const refreshPayload = { + sub: user.id, + sid, + rid, + tv: tokenVersion, + typ: "refresh", + iat: nowSec, + exp: nowSec + config.refreshTokenTtlSec, + iss: config.jwtIssuer, + aud: config.jwtAudience + }; + + const accessToken = signJwt(accessPayload); + const refreshToken = signJwt(refreshPayload); + + runtime.authState.refreshTokens.push({ + id: rid, + sid, + userId: user.id, + tokenHash: sha256(refreshToken), + createdAt: new Date().toISOString(), + expiresAtMs: refreshPayload.exp * 1000, + revokedAt: null, + rotatedFrom: options.rotatedFrom || null, + userAgent: req.headers["user-agent"] || "", + ip: clientIp(req) + }); + + pruneRefreshTokens(); + await saveAuthState(); + + return { + sid, + rid, + accessToken, + refreshToken + }; +} + +function getEffectiveAccessTokenTtlSec(user, nowSec) { + return getEffectiveAccessTokenTtlDetails(user, nowSec).effectiveTtlSec; +} + +function getEffectiveAccessTokenTtlDetails(user, nowSec) { + const minimumTtlSec = 3 * 60 * 60; + const configured = Number.isFinite(Number(config.accessTokenTtlSec)) ? Math.floor(Number(config.accessTokenTtlSec)) : minimumTtlSec; + let ttl = Math.max(minimumTtlSec, configured); + + const station = runtime.station; + const isOwnerActive = Boolean( + station && + station.isInUse && + station.activeByUserId && + user && + String(station.activeByUserId) === String(user.id) + ); + if (!isOwnerActive) { + return { + effectiveTtlSec: ttl, + minimumTtlSec, + configuredAccessTokenTtlSec: configured, + ownerSessionBoostApplied: false + }; + } + + const endsAtMs = Date.parse(String(station.endsAt || "")); + if (!Number.isFinite(endsAtMs)) { + return { + effectiveTtlSec: ttl, + minimumTtlSec, + configuredAccessTokenTtlSec: configured, + ownerSessionBoostApplied: false + }; + } + + const remainingSec = Math.max(0, Math.ceil((endsAtMs - (nowSec * 1000)) / 1000)); + const safetyBufferSec = 15 * 60; + const maxTtlSec = 24 * 60 * 60; + const boostedTtl = Math.min(maxTtlSec, Math.max(ttl, remainingSec + safetyBufferSec)); + return { + effectiveTtlSec: boostedTtl, + minimumTtlSec, + configuredAccessTokenTtlSec: configured, + ownerSessionBoostApplied: boostedTtl > ttl + }; +} + +function signJwt(payload) { + const header = { + alg: "HS256", + typ: "JWT" + }; + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const body = `${encodedHeader}.${encodedPayload}`; + const signature = crypto.createHmac("sha256", config.jwtSecret).update(body).digest("base64url"); + return `${body}.${signature}`; +} + +function verifyJwt(token, expectedType) { + try { + const parts = token.split("."); + if (parts.length !== 3) { + return { ok: false }; + } + const [encodedHeader, encodedPayload, signature] = parts; + const body = `${encodedHeader}.${encodedPayload}`; + const expectedSignature = crypto.createHmac("sha256", config.jwtSecret).update(body).digest("base64url"); + if (!safeEquals(signature, expectedSignature)) { + return { ok: false }; + } + + const payload = JSON.parse(base64UrlDecode(encodedPayload)); + const nowSec = Math.floor(Date.now() / 1000); + if (payload.exp && nowSec > payload.exp) { + return { ok: false }; + } + if (payload.iss !== config.jwtIssuer || payload.aud !== config.jwtAudience) { + return { ok: false }; + } + if (expectedType && payload.typ !== expectedType) { + return { ok: false }; + } + return { ok: true, payload }; + } catch { + return { ok: false }; + } +} + +function hasRole(user, roles) { + return roles.includes(user.role); +} + +function denyIfStationOwnedByOther(res, user, actionLabel, options = {}) { + const allowAdmin = Boolean(options.allowAdmin); + if (!runtime.station || !runtime.station.isInUse || !runtime.station.activeByUserId) { + return false; + } + if (allowAdmin && user && user.role === "admin") { + return false; + } + const isOwner = user && String(user.id || "") === String(runtime.station.activeByUserId || ""); + if (isOwner) { + return false; + } + sendError(res, 409, "station.in_use", `${String(actionLabel || "Aktion")} ist gesperrt: Station wird von einem anderen Benutzer verwendet`); + return true; +} + +function hasCapability(user, capability) { + if (!capability) return true; + if (user.role === "admin") return true; + if (capability.startsWith("admin.")) return false; + return true; +} + +function userCapabilities(user) { + const caps = new Set(); + for (const [capability, pluginId] of Object.entries(runtime.pluginState.providers)) { + if (!pluginId) continue; + if (hasCapability(user, capability)) { + caps.add(capability); + } + } + return Array.from(caps).sort(); +} + +function broadcastEvent(type, data) { + const event = { + type, + ts: new Date().toISOString(), + ...data + }; + const id = `evt_${++runtime.eventSeq}`; + const payload = `event: ${type}\nid: ${id}\ndata: ${JSON.stringify(event)}\n\n`; + for (const client of runtime.sseClients) { + try { + client.res.write(payload); + } catch { + runtime.sseClients.delete(client); + } + } +} + +async function runCommand(commandString, options = {}) { + const parsed = splitCommand(commandString); + if (parsed.length === 0) { + return { ok: false, code: -1, stderr: "empty command" }; + } + const [command, ...args] = parsed; + const timeoutMs = Number(options.timeoutMs || 120000); + + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd || rootDir, + env: { + ...process.env, + ...(options.env || {}) + }, + shell: true + }); + + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + if (stdout.length > 20000) stdout = stdout.slice(-20000); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + if (stderr.length > 20000) stderr = stderr.slice(-20000); + }); + child.on("error", (error) => { + clearTimeout(timer); + resolve({ ok: false, code: -1, error: String(error.message || error), stdout: stdout.trim(), stderr: stderr.trim() }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ ok: code === 0, code, stdout: stdout.trim(), stderr: stderr.trim() }); + }); + }); +} + +async function ensureDataFiles() { + await fsp.mkdir(config.dataDir, { recursive: true }); + if (!(await storage.exists(files.users))) { + await writeJson(files.users, []); + } + if (!(await storage.exists(files.station))) { + await writeJson(files.station, buildDefaultStationState()); + } + if (!(await storage.exists(files.audit))) { + await storage.writeText(files.audit, ""); + } + if (!(await storage.exists(files.auth))) { + await writeJson(files.auth, runtime.authState); + } + if (!(await storage.exists(files.plugins))) { + await writeJson(files.plugins, runtime.pluginState); + } + if (!(await storage.exists(files.approvals))) { + await writeJson(files.approvals, []); + } + if (!(await storage.exists(files.system))) { + await writeJson(files.system, runtime.systemState); + } + await ensureMailOutboxInitialized(); +} + +function buildDefaultStationState() { + return { + stationName: config.stationName, + stationOnline: true, + isInUse: false, + activeByUserId: null, + activeByEmail: null, + startedAt: null, + endsAt: null, + reservations: [], + updatedAt: new Date().toISOString(), + lastAction: "init" + }; +} + +async function applyAdminRoles() { + let changed = false; + if (!Array.isArray(runtime.authState.emailTokens)) { + runtime.authState.emailTokens = []; + changed = true; + } + if (!Array.isArray(runtime.authState.otpChallenges)) { + runtime.authState.otpChallenges = []; + changed = true; + } + const availableMethods = listPublicAuthMethods(); + const availableMethodIds = availableMethods.map((entry) => entry.id); + const defaultMethodId = preferredAuthMethodId(availableMethods); + for (const user of runtime.users) { + const role = config.adminEmails.has(user.email) + ? "admin" + : (config.approverEmails.has(user.email) ? "approver" : (user.role || "operator")); + if (user.role !== role) { + user.role = role; + changed = true; + } + if (!user.accountType) { + user.accountType = isPrimaryDomainEmail(user.email) ? "primary-domain" : "external-domain"; + changed = true; + } + if (!Array.isArray(user.enabledAuthMethods) || user.enabledAuthMethods.length === 0) { + user.enabledAuthMethods = [...availableMethodIds]; + changed = true; + } + const filteredMethods = user.enabledAuthMethods.filter((methodId) => availableMethodIds.includes(methodId)); + if (filteredMethods.length !== user.enabledAuthMethods.length) { + user.enabledAuthMethods = filteredMethods; + changed = true; + } + if (!user.primaryAuthMethod || !user.enabledAuthMethods.includes(user.primaryAuthMethod)) { + user.primaryAuthMethod = defaultMethodId && user.enabledAuthMethods.includes(defaultMethodId) + ? defaultMethodId + : (user.enabledAuthMethods[0] || null); + changed = true; + } + if (!user.status) { + user.status = "active"; + changed = true; + } + if (!user.preferredLanguage || !["de", "en"].includes(String(user.preferredLanguage).toLowerCase())) { + user.preferredLanguage = "de"; + changed = true; + } + if (user.role === "admin" && user.status !== "active") { + user.status = "active"; + user.approvedAt = user.approvedAt || new Date().toISOString(); + changed = true; + } + if (user.status === "active" && !user.emailVerifiedAt) { + user.emailVerifiedAt = user.createdAt || new Date().toISOString(); + changed = true; + } + if (!(user.id in runtime.authState.tokenVersionByUser)) { + runtime.authState.tokenVersionByUser[user.id] = 0; + changed = true; + } + } + if (changed) { + await writeJson(files.users, runtime.users); + await saveAuthState(); + } +} + +async function appendAudit(action, user, details) { + const entry = { + at: new Date().toISOString(), + action, + userId: user ? user.id : null, + email: user ? user.email : null, + details: details || null + }; + await storage.appendText(files.audit, `${JSON.stringify(entry)}\n`); +} + +async function listStationActivityLog(limit = 200) { + const raw = await storage.readText(files.audit, ""); + const lines = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const interesting = new Set([ + "auth.request_access", + "station.activate.start", + "station.activate.done", + "station.activate.failed", + "station.deactivate", + "station.deactivate.timeout" + ]); + + const entries = []; + for (const line of lines) { + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (!entry || !interesting.has(entry.action)) { + continue; + } + entries.push({ + at: entry.at, + action: entry.action, + email: entry.email || null, + details: entry.details || null, + message: activityMessageForEntry(entry) + }); + } + + entries.sort((a, b) => new Date(b.at || 0).getTime() - new Date(a.at || 0).getTime()); + return entries.slice(0, limit); +} + +function activityMessageForEntry(entry) { + const email = entry.email || "unbekannt"; + if (entry.action === "auth.request_access") { + return `${email} hat einen Login-Link angefordert`; + } + if (entry.action === "station.activate.start") { + return `${email} hat die RMS-Aktivierung gestartet`; + } + if (entry.action === "station.activate.done") { + return `${email} hat die RMS aktiviert`; + } + if (entry.action === "station.activate.failed") { + return `${email} Aktivierung fehlgeschlagen`; + } + if (entry.action === "station.deactivate") { + return `${email} hat die RMS manuell beendet`; + } + if (entry.action === "station.deactivate.timeout") { + return `${email} RMS wurde automatisch wegen Zeitlimit beendet`; + } + return `${email} ${entry.action}`; +} + +async function saveAuthState() { + await writeJson(files.auth, runtime.authState); +} + +async function savePluginState() { + await writeJson(files.plugins, runtime.pluginState); +} + +async function saveApprovalRequests() { + await writeJson(files.approvals, runtime.approvalRequests); +} + +async function saveSystemState() { + await writeJson(files.system, runtime.systemState); +} + +function pruneRefreshTokens() { + const now = Date.now(); + runtime.authState.refreshTokens = runtime.authState.refreshTokens.filter((token) => { + if (token.expiresAtMs < now - 24 * 60 * 60 * 1000) { + return false; + } + return true; + }); +} + +async function revokeSessionFamily(sid) { + for (const token of runtime.authState.refreshTokens) { + if (token.sid === sid && !token.revokedAt) { + token.revokedAt = new Date().toISOString(); + } + } + await saveAuthState(); +} + +function getPluginSetting(pluginId, key, fallback = null) { + const pluginSettings = runtime.pluginState.settings[pluginId] || {}; + if (key in pluginSettings) { + return pluginSettings[key]; + } + return fallback; +} + +function validateSettings(schema, value) { + if (!schema || schema.type !== "object") { + return { ok: true }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { ok: false, message: "settings muss ein Objekt sein" }; + } + + const properties = schema.properties || {}; + const required = Array.isArray(schema.required) ? schema.required : []; + + for (const key of required) { + if (!(key in value)) { + return { ok: false, message: `Pflichtfeld fehlt: ${key}` }; + } + } + + for (const [key, fieldValue] of Object.entries(value)) { + const fieldSchema = properties[key]; + if (!fieldSchema) { + if (schema.additionalProperties === false) { + return { ok: false, message: `Unbekanntes Feld: ${key}` }; + } + continue; + } + const type = fieldSchema.type; + if (type === "string" && typeof fieldValue !== "string") { + return { ok: false, message: `${key} muss string sein` }; + } + if (type === "number" && typeof fieldValue !== "number") { + return { ok: false, message: `${key} muss number sein` }; + } + if (type === "integer" && (!Number.isInteger(fieldValue))) { + return { ok: false, message: `${key} muss integer sein` }; + } + if (type === "boolean" && typeof fieldValue !== "boolean") { + return { ok: false, message: `${key} muss boolean sein` }; + } + if (Array.isArray(fieldSchema.enum) && !fieldSchema.enum.includes(fieldValue)) { + return { ok: false, message: `${key} hat ungueltigen Wert` }; + } + if (typeof fieldValue === "number") { + if (fieldSchema.minimum !== undefined && fieldValue < fieldSchema.minimum) { + return { ok: false, message: `${key} ist kleiner als minimum` }; + } + if (fieldSchema.maximum !== undefined && fieldValue > fieldSchema.maximum) { + return { ok: false, message: `${key} ist groesser als maximum` }; + } + } + } + return { ok: true }; +} + +function enforceRateLimit(req, res, key, limit, windowMs) { + const now = Date.now(); + const bucket = runtime.rateBuckets.get(key) || { count: 0, resetAt: now + windowMs }; + if (now > bucket.resetAt) { + bucket.count = 0; + bucket.resetAt = now + windowMs; + } + bucket.count += 1; + runtime.rateBuckets.set(key, bucket); + if (bucket.count <= limit) { + return true; + } + const retryAfterSec = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + res.setHeader("Retry-After", String(retryAfterSec)); + sendError(res, 429, "rate_limit.exceeded", "Zu viele Anfragen", { retryAfterSec }); + return false; +} + +function clientIp(req) { + const forwarded = req.headers["x-forwarded-for"]; + if (typeof forwarded === "string" && forwarded.trim()) { + return forwarded.split(",")[0].trim(); + } + return req.socket.remoteAddress || "unknown"; +} + +function sanitizeUser(user) { + return { + id: user.id, + email: user.email, + role: user.role || "operator", + status: user.status || "pending_verification", + accountType: user.accountType || (isPrimaryDomainEmail(user.email) ? "primary-domain" : "external-domain"), + enabledAuthMethods: Array.isArray(user.enabledAuthMethods) ? user.enabledAuthMethods : [], + primaryAuthMethod: user.primaryAuthMethod || null, + preferredLanguage: user.preferredLanguage || "de", + createdAt: user.createdAt, + emailVerifiedAt: user.emailVerifiedAt || null, + approvedAt: user.approvedAt || null, + deniedAt: user.deniedAt || null + }; +} + +function normalizeEmail(value) { + if (typeof value !== "string") { + return ""; + } + return value.trim().toLowerCase(); +} + +function isValidEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +function hashPassword(password) { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = crypto.scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${hash}`; +} + +function verifyPassword(password, packed) { + if (!packed || typeof packed !== "string" || !packed.includes(":")) { + return false; + } + const [salt, expectedHex] = packed.split(":"); + const actualHex = crypto.scryptSync(password, salt, 64).toString("hex"); + const expected = Buffer.from(expectedHex, "hex"); + const actual = Buffer.from(actualHex, "hex"); + if (expected.length !== actual.length) { + return false; + } + return crypto.timingSafeEqual(expected, actual); +} + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function base64UrlEncode(value) { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function base64UrlDecode(value) { + return Buffer.from(value, "base64url").toString("utf8"); +} + +function safeEquals(a, b) { + const left = Buffer.from(String(a)); + const right = Buffer.from(String(b)); + if (left.length !== right.length) { + return false; + } + return crypto.timingSafeEqual(left, right); +} + +function splitCommand(commandString) { + return commandString.match(/(?:[^\s\"]+|\"[^\"]*\")+/g)?.map((part) => part.replace(/^\"|\"$/g, "")) || []; +} + +async function readJson(filePath, fallback) { + return storage.readJson(filePath, fallback); +} + +async function writeJson(filePath, value) { + await storage.writeJson(filePath, value); +} + +async function withLock(action) { + const run = mutex.then(action, action); + mutex = run.then(() => undefined, () => undefined); + return run; +} + +async function readJsonBody(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + if (chunks.length === 0) { + return {}; + } + const text = Buffer.concat(chunks).toString("utf8"); + try { + return JSON.parse(text); + } catch { + const err = new Error("invalid json"); + err.code = "INVALID_JSON"; + throw err; + } +} + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store" + }); + res.end(body); +} + +function sendError(res, statusCode, code, message, details = null) { + return sendJson(res, statusCode, { + error: { + code, + message, + details + } + }); +} + +async function safePluginHealth(plugin) { + if (!plugin.instance.health) { + return "healthy"; + } + try { + const result = await plugin.instance.health(); + if (!result || result.ok === false) { + return "degraded"; + } + return "healthy"; + } catch { + return "failing"; + } +} + +async function refreshPluginHealth() { + for (const plugin of runtime.plugins.values()) { + const next = await safePluginHealth(plugin); + const pluginId = plugin.manifest.id; + const prev = runtime.pluginHealth[pluginId] || "unknown"; + runtime.pluginHealth[pluginId] = next; + if (next !== prev) { + broadcastEvent("plugin.health.changed", { + pluginId, + previous: prev, + health: next + }); + } + } +} + +async function safeStatus(instance) { + try { + return await instance.getStatus(); + } catch { + return {}; + } +} + +async function serveStaticFile(baseDir, req, res, pathname) { + if (pathname.startsWith("/uploads/")) { + const uploadsPath = pathname.slice("/uploads".length) || "/"; + return serveFileFromBaseDir(config.brandingUploadsDir, req, res, uploadsPath, { cachePolicy: "no-store" }); + } + + if (pathname.startsWith("/vswr/images/")) { + const imagesPath = pathname.slice("/vswr/images".length) || "/"; + return serveFileFromBaseDir(config.vswrImagesDir, req, res, imagesPath, { cachePolicy: "no-store" }); + } + + const publicDir = path.join(baseDir, "public"); + return serveFileFromBaseDir(publicDir, req, res, pathname, { spaFallback: true, cachePolicy: "revalidate" }); +} + +async function serveFileFromBaseDir(baseDir, req, res, pathname, options = {}) { + const spaFallback = Boolean(options.spaFallback); + const cachePolicy = String(options.cachePolicy || "revalidate").trim().toLowerCase(); + const resolvedBaseDir = path.resolve(baseDir); + const requestedPath = pathname === "/" ? "/index.html" : pathname; + const effectivePath = spaFallback && isSpaRoute(pathname) ? "/index.html" : requestedPath; + + const safePath = path + .normalize(effectivePath) + .replace(/^[/\\]+/, "") + .replace(/^([.][.][/\\])+/, ""); + const fullPath = path.resolve(resolvedBaseDir, safePath); + + if (fullPath !== resolvedBaseDir && !fullPath.startsWith(resolvedBaseDir + path.sep)) { + return sendError(res, 403, "path.forbidden", "Unerlaubter Pfad"); + } + + try { + const stat = await fsp.stat(fullPath); + if (stat.isDirectory()) { + return sendError(res, 404, "file.not_found", "Nicht gefunden"); + } + const content = await fsp.readFile(fullPath); + const ext = path.extname(fullPath).toLowerCase(); + const contentType = mimeTypeFor(ext); + const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs).toString(16)}"`; + const lastModified = stat.mtime.toUTCString(); + const ifNoneMatch = String(req.headers["if-none-match"] || "").trim(); + if (cachePolicy !== "no-store" && ifNoneMatch && ifNoneMatch === etag) { + res.writeHead(304, { + ETag: etag, + "Last-Modified": lastModified, + "Cache-Control": cacheControlForStatic(cachePolicy, ext) + }); + return res.end(); + } + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": cacheControlForStatic(cachePolicy, ext), + ETag: etag, + "Last-Modified": lastModified + }); + if (req.method === "HEAD") { + return res.end(); + } + return res.end(content); + } catch { + return sendError(res, 404, "file.not_found", "Nicht gefunden"); + } +} + +function cacheControlForStatic(cachePolicy, ext) { + if (cachePolicy === "no-store") { + return "no-store"; + } + if (ext === ".html" || ext === ".js" || ext === ".css" || ext === ".json") { + return "no-cache, must-revalidate"; + } + return "no-cache"; +} + +function isSpaRoute(pathname) { + if (!pathname || pathname === "/") { + return false; + } + if (pathname.startsWith("/v1/") || pathname.startsWith("/api/")) { + return false; + } + const ext = path.posix.extname(pathname); + return ext === ""; +} + +function mimeTypeFor(ext) { + if (ext === ".html") return "text/html; charset=utf-8"; + if (ext === ".css") return "text/css; charset=utf-8"; + if (ext === ".js") return "application/javascript; charset=utf-8"; + if (ext === ".json") return "application/json; charset=utf-8"; + if (ext === ".svg") return "image/svg+xml"; + if (ext === ".png") return "image/png"; + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".webp") return "image/webp"; + return "application/octet-stream"; +} + +function resolveExecMode(explicitValue, lifecycleEvent) { + const lifecycle = String(lifecycleEvent || "").trim().toLowerCase(); + if (lifecycle === "prod") { + return "prod"; + } + if (lifecycle === "dev") { + return "dev"; + } + const explicit = String(explicitValue || "").trim().toLowerCase(); + if (explicit === "prod" || explicit === "production") return "prod"; + if (explicit === "dev" || explicit === "development") return "dev"; + return "dev"; +} + +function loadDotEnv(filePath) { + if (!fs.existsSync(filePath)) { + return; + } + const raw = fs.readFileSync(filePath, "utf8"); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + const idx = trimmed.indexOf("="); + if (idx === -1) { + continue; + } + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!(key in process.env)) { + process.env[key] = value; + } + } +} diff --git a/server/storage/custom-storage-template.js b/server/storage/custom-storage-template.js new file mode 100644 index 0000000..912a214 --- /dev/null +++ b/server/storage/custom-storage-template.js @@ -0,0 +1,47 @@ +function createStorage(config) { + return { + id: "custom-template", + + async init() { + // Initialize connections/resources here. + }, + + async exists(key) { + // Return true/false for existence of key. + return false; + }, + + async readJson(key, fallback = null) { + // Return parsed object or fallback. + return fallback; + }, + + async writeJson(key, value) { + // Persist JSON object. + void key; + void value; + }, + + async appendText(key, text) { + // Append one line/chunk (used for audit log). + void key; + void text; + }, + + async readText(key, fallback = "") { + // Return full text content or fallback. + void key; + return fallback; + }, + + async writeText(key, text) { + // Overwrite full text content. + void key; + void text; + } + }; +} + +module.exports = { + createStorage +}; diff --git a/server/storage/index.js b/server/storage/index.js new file mode 100644 index 0000000..0afb392 --- /dev/null +++ b/server/storage/index.js @@ -0,0 +1,60 @@ +const fs = require("fs"); +const path = require("path"); + +const { createJsonStorage } = require("./providers/json"); +const { createSqliteStorage } = require("./providers/sqlite"); + +async function createStorageProvider(config) { + const providerId = String(config.storageProvider || "json").trim().toLowerCase(); + + if (providerId === "json") { + const provider = createJsonStorage({ dataDir: config.dataDir }); + await provider.init(); + return provider; + } + + if (providerId === "sqlite") { + const sqlitePath = config.storageSqlitePath || path.join(config.dataDir, "rms-storage.db"); + const provider = createSqliteStorage({ + sqlitePath + }); + try { + await provider.init(); + return provider; + } catch (error) { + const sqliteMissing = /node:sqlite/i.test(String(error && error.message ? error.message : error)); + const sqliteDbExists = fs.existsSync(sqlitePath); + if (sqliteMissing && !sqliteDbExists) { + console.warn(`WARN: SQLite not available (${String(error.message || error)}), falling back to JSON storage at ${config.dataDir}`); + const fallback = createJsonStorage({ dataDir: config.dataDir }); + await fallback.init(); + return fallback; + } + throw error; + } + } + + if (providerId === "module") { + const modulePath = config.storageModulePath; + if (!modulePath) { + throw new Error("STORAGE_PROVIDER=module requires STORAGE_MODULE_PATH"); + } + const resolved = path.isAbsolute(modulePath) ? modulePath : path.resolve(config.rootDir, modulePath); + const mod = require(resolved); + const create = typeof mod.createStorage === "function" ? mod.createStorage : mod; + const provider = await create(config); + if (!provider || typeof provider.readJson !== "function" || typeof provider.writeJson !== "function") { + throw new Error("Custom storage module must implement readJson/writeJson"); + } + if (typeof provider.init === "function") { + await provider.init(); + } + return provider; + } + + throw new Error(`Unknown STORAGE_PROVIDER: ${providerId}`); +} + +module.exports = { + createStorageProvider +}; diff --git a/server/storage/providers/json.js b/server/storage/providers/json.js new file mode 100644 index 0000000..caaefc0 --- /dev/null +++ b/server/storage/providers/json.js @@ -0,0 +1,102 @@ +const fs = require("fs"); +const fsp = require("fs/promises"); + +function createJsonStorage(options) { + const dataDir = options.dataDir; + + return { + id: "json", + async init() { + await fsp.mkdir(dataDir, { recursive: true }); + }, + async exists(key) { + return fs.existsSync(key); + }, + async readJson(key, fallback = null) { + try { + const raw = await fsp.readFile(key, "utf8"); + const parsed = parseJsonWithTrailingRecovery(raw); + if (parsed.ok) { + if (parsed.repaired) { + await backupCorruptJsonFile(key, raw, "trailing-garbage"); + await fsp.writeFile(key, `${JSON.stringify(parsed.value, null, 2)}\n`, "utf8"); + console.warn(`[json-storage] repaired trailing JSON data in ${key}`); + } + return parsed.value; + } + + if (fallback !== undefined) { + await backupCorruptJsonFile(key, raw, "invalid-json"); + await fsp.writeFile(key, `${JSON.stringify(fallback, null, 2)}\n`, "utf8"); + console.warn(`[json-storage] invalid JSON in ${key}, restored fallback`); + return fallback; + } + throw parsed.error; + } catch (error) { + if (error.code === "ENOENT") { + return fallback; + } + throw error; + } + }, + async writeJson(key, value) { + await fsp.writeFile(key, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + }, + async appendText(key, text) { + await fsp.appendFile(key, text, "utf8"); + }, + async readText(key, fallback = "") { + try { + return await fsp.readFile(key, "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + return fallback; + } + throw error; + } + }, + async writeText(key, text) { + await fsp.writeFile(key, text, "utf8"); + } + }; +} + +function parseJsonWithTrailingRecovery(raw) { + try { + return { ok: true, repaired: false, value: JSON.parse(raw) }; + } catch (error) { + const message = String(error && error.message ? error.message : ""); + const match = message.match(/position\s+(\d+)/i); + if (!match) { + return { ok: false, error }; + } + const position = Number(match[1]); + if (!Number.isFinite(position) || position <= 0 || position >= raw.length) { + return { ok: false, error }; + } + const prefix = raw.slice(0, position).trimEnd(); + if (!prefix) { + return { ok: false, error }; + } + try { + return { ok: true, repaired: true, value: JSON.parse(prefix) }; + } catch { + return { ok: false, error }; + } + } +} + +async function backupCorruptJsonFile(key, raw, reason) { + try { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const suffix = String(reason || "corrupt").replace(/[^a-z0-9-]/gi, "-").toLowerCase(); + const backupPath = `${key}.${suffix}.${stamp}.bak`; + await fsp.writeFile(backupPath, raw, "utf8"); + } catch { + // best effort backup only + } +} + +module.exports = { + createJsonStorage +}; diff --git a/server/storage/providers/sqlite.js b/server/storage/providers/sqlite.js new file mode 100644 index 0000000..f4becda --- /dev/null +++ b/server/storage/providers/sqlite.js @@ -0,0 +1,66 @@ +const fs = require("fs"); +const path = require("path"); + +function createSqliteStorage(options) { + const sqlitePath = options.sqlitePath; + let db = null; + + function requireDriver() { + try { + return require("node:sqlite").DatabaseSync; + } catch { + throw new Error("SQLite storage requires Node.js with built-in 'node:sqlite' (Node 22+)"); + } + } + + return { + id: "sqlite", + async init() { + const Database = requireDriver(); + fs.mkdirSync(path.dirname(sqlitePath), { recursive: true }); + db = new Database(sqlitePath); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL)"); + db.exec("CREATE TABLE IF NOT EXISTS logs (k TEXT NOT NULL, ts INTEGER NOT NULL, line TEXT NOT NULL)"); + }, + async exists(key) { + const row = db.prepare("SELECT 1 AS ok FROM kv WHERE k = ? LIMIT 1").get(key); + return Boolean(row); + }, + async readJson(key, fallback = null) { + const row = db.prepare("SELECT v FROM kv WHERE k = ? LIMIT 1").get(key); + if (!row) { + return fallback; + } + try { + return JSON.parse(row.v); + } catch { + return fallback; + } + }, + async writeJson(key, value) { + const raw = JSON.stringify(value); + db.prepare("INSERT INTO kv(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v").run(key, raw); + }, + async appendText(key, text) { + db.prepare("INSERT INTO logs(k, ts, line) VALUES(?, ?, ?)").run(key, Date.now(), text); + }, + async readText(key, fallback = "") { + const rows = db.prepare("SELECT line FROM logs WHERE k = ? ORDER BY ts ASC").all(key); + if (!rows.length) { + return fallback; + } + return rows.map((row) => row.line).join(""); + }, + async writeText(key, text) { + db.prepare("DELETE FROM logs WHERE k = ?").run(key); + if (text) { + db.prepare("INSERT INTO logs(k, ts, line) VALUES(?, ?, ?)").run(key, Date.now(), text); + } + } + }; +} + +module.exports = { + createSqliteStorage +}; diff --git a/test/auth-methods.integration.test.js b/test/auth-methods.integration.test.js new file mode 100644 index 0000000..49fd695 --- /dev/null +++ b/test/auth-methods.integration.test.js @@ -0,0 +1,1943 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); + +function randomPort() { + return 18080 + Math.floor(Math.random() * 1000); +} + +async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForServer(baseUrl, timeoutMs = 15000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const res = await fetch(`${baseUrl}/api/health`); + if (res.ok) { + return; + } + } catch { + // retry + } + await sleep(200); + } + throw new Error("server did not become ready in time"); +} + +async function waitForCondition(checkFn, timeoutMs = 15000, intervalMs = 150) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const value = await checkFn(); + if (value) { + return value; + } + await sleep(intervalMs); + } + throw new Error("condition not met in time"); +} + +async function requestJson(baseUrl, route, options = {}) { + const headers = { "Content-Type": "application/json", ...(options.headers || {}) }; + const response = await fetch(`${baseUrl}${route}`, { + method: options.method || "GET", + headers, + body: options.body ? JSON.stringify(options.body) : undefined + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const message = payload && payload.error && payload.error.message ? payload.error.message : `HTTP ${response.status}`; + const err = new Error(message); + err.status = response.status; + err.payload = payload; + throw err; + } + return payload; +} + +async function readOutbox(dataDir) { + const outboxPath = path.join(dataDir, "mail-outbox.log"); + const raw = await fs.readFile(outboxPath, "utf8"); + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function extractTokenFromText(text) { + const loginMatch = /loginToken=([^\s]+)/.exec(text); + if (loginMatch) return decodeURIComponent(loginMatch[1]); + const verifyMatch = /verifyToken=([^\s]+)/.exec(text); + if (verifyMatch) return decodeURIComponent(verifyMatch[1]); + return ""; +} + +function extractOtpFromText(text) { + const match = /Code lautet:\s*(\d{6})/.exec(text); + return match ? match[1] : ""; +} + +async function requestAccessAndVerify(baseUrl, dataDir, email, method) { + const start = await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: method ? { email, method } : { email } + }); + const outbox = await readOutbox(dataDir); + const mail = [...outbox].reverse().find((entry) => entry.to === email); + assert.ok(mail, `challenge mail for ${email} must exist`); + + if (start.challengeType === "otp") { + const code = extractOtpFromText(mail.text); + assert.ok(code, `otp code for ${email} must be parsable`); + return requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { email, code } + }); + } + + const token = extractTokenFromText(mail.text); + assert.ok(token, `token for ${email} must be parsable`); + return requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { token } + }); +} + +test("plugin-based auth methods support link and OTP flows", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-auth-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const methods = await requestJson(baseUrl, "/v1/public/auth-methods"); + const methodIds = new Set((methods.methods || []).map((entry) => entry.id)); + assert.ok(methodIds.has("smtp-link"), "smtp-link method must be available"); + assert.ok(methodIds.has("otp-email"), "otp-email method must be available"); + + const adminStart = await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "admin@arcg.at" } + }); + const outbox1 = await readOutbox(dataDir); + const adminLastMail = [...outbox1].reverse().find((entry) => entry.to === "admin@arcg.at"); + assert.ok(adminLastMail, "admin challenge must be written"); + + let adminVerify; + if (adminStart.challengeType === "otp") { + const code = extractOtpFromText(adminLastMail.text); + assert.ok(code, "admin otp code must be parsable"); + adminVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { email: "admin@arcg.at", code } + }); + } else { + const adminToken = extractTokenFromText(adminLastMail.text); + assert.ok(adminToken, "admin verify token must be parsable"); + adminVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { token: adminToken } + }); + } + assert.ok(adminVerify.accessToken, "admin access token expected"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const guestStart = await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "guest@example.com" } + }); + const outbox2 = await readOutbox(dataDir); + const guestVerifyMail = [...outbox2].reverse().find((entry) => entry.to === "guest@example.com"); + assert.ok(guestVerifyMail, "guest verify challenge must be written"); + + let guestVerify; + if (guestStart.challengeType === "otp") { + const guestCode = extractOtpFromText(guestVerifyMail.text); + assert.ok(guestCode, "guest otp code must be parsable"); + guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { email: "guest@example.com", code: guestCode } + }); + } else { + const guestVerifyToken = extractTokenFromText(guestVerifyMail.text); + assert.ok(guestVerifyToken, "guest verify token must be parsable"); + guestVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { token: guestVerifyToken } + }); + } + assert.equal(guestVerify.approved, false, "external domain should not auto-approve"); + + const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); + const pending = (approvals.approvals || []).find((entry) => entry.email === "guest@example.com" && entry.status === "pending"); + assert.ok(pending, "approval request for external user expected"); + + await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { + method: "POST", + headers: adminHeaders, + body: {} + }); + + const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); + const guest = (users.users || []).find((entry) => entry.email === "guest@example.com"); + assert.ok(guest, "guest user must exist"); + + await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(guest.id)}/auth-methods`, { + method: "PUT", + headers: adminHeaders, + body: { + enabledMethods: ["otp-email"], + primaryMethod: "otp-email" + } + }); + + const otpStart = await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "guest@example.com", method: "otp-email" } + }); + assert.equal(otpStart.challengeType, "otp", "OTP challenge should be selected"); + + const outbox3 = await readOutbox(dataDir); + const guestOtpMail = [...outbox3] + .reverse() + .find((entry) => entry.to === "guest@example.com" && /Code lautet:\s*\d{6}/.test(entry.text)); + assert.ok(guestOtpMail, "guest OTP mail must be written"); + const otpMatch = /Code lautet:\s*(\d{6})/.exec(guestOtpMail.text); + assert.ok(otpMatch, "otp code must be parsable"); + + const otpVerify = await requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { + email: "guest@example.com", + code: otpMatch[1] + } + }); + assert.ok(otpVerify.accessToken, "guest OTP login should return token"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("maintenance mode keeps admin active and blocks non-admin login", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-maintenance-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + assert.ok(adminVerify.accessToken, "admin token expected"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at"); + assert.ok(operatorVerify.accessToken, "operator token expected"); + + const enabled = await requestJson(baseUrl, "/v1/admin/maintenance", { + method: "PUT", + headers: adminHeaders, + body: { enabled: true, message: "Geplante Wartung" } + }); + assert.equal(enabled.maintenanceMode, true); + + const publicSystem = await requestJson(baseUrl, "/v1/public/system"); + assert.equal(publicSystem.maintenanceMode, true); + + const adminStillLoggedIn = await requestJson(baseUrl, "/v1/me", { headers: adminHeaders }); + assert.equal(adminStillLoggedIn.user.email, "admin@arcg.at"); + + await assert.rejects( + requestJson(baseUrl, "/v1/me", { + headers: { Authorization: `Bearer ${operatorVerify.accessToken}` } + }), + (error) => error && (error.status === 401 || error.status === 503) + ); + + await assert.rejects( + requestJson(baseUrl, "/v1/auth/refresh", { + method: "POST", + body: { refreshToken: operatorVerify.refreshToken } + }), + (error) => error && (error.status === 401 || error.status === 503) + ); + + await assert.rejects( + requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "newuser@arcg.at" } + }), + (error) => error && error.status === 503 + ); + + const adminRequestDuringMaintenance = await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "admin@arcg.at" } + }); + assert.ok(adminRequestDuringMaintenance.ok); + + const adminAgain = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminAgainHeaders = { Authorization: `Bearer ${adminAgain.accessToken}` }; + const disabled = await requestJson(baseUrl, "/v1/admin/maintenance", { + method: "PUT", + headers: adminAgainHeaders, + body: { enabled: false } + }); + assert.equal(disabled.maintenanceMode, false); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("approver role can process external approvals without admin role", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const approverVerify = await requestAccessAndVerify(baseUrl, dataDir, "approver@arcg.at"); + assert.ok(approverVerify.accessToken, "approver user token expected"); + + const approverUserList = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); + const approverUser = (approverUserList.users || []).find((entry) => entry.email === "approver@arcg.at"); + assert.ok(approverUser, "approver user record expected"); + + await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { + method: "PUT", + headers: adminHeaders, + body: { role: "approver" } + }); + + const approverLogin = await requestAccessAndVerify(baseUrl, dataDir, "approver@arcg.at"); + const approverHeaders = { Authorization: `Bearer ${approverLogin.accessToken}` }; + + const controls = await requestJson(baseUrl, "/v1/ui/controls", { headers: approverHeaders }); + const stationMain = (controls.controls || []).find((entry) => entry.controlId === "station-main"); + assert.ok(stationMain, "approver should see station control"); + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers: approverHeaders, + body: {} + }); + await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers: approverHeaders }); + return status.isInUse ? status : null; + }, 20000, 250); + await requestJson(baseUrl, "/v1/station/release", { + method: "POST", + headers: approverHeaders, + body: {} + }); + + const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example"); + assert.equal(externalVerify.approved, false, "external user should require approval"); + + const approvalsForApprover = await requestJson(baseUrl, "/v1/approvals", { headers: approverHeaders }); + const pending = (approvalsForApprover.approvals || []).find((entry) => entry.email === "remote@outside.example" && entry.status === "pending"); + assert.ok(pending, "approver should see pending external request"); + + await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { + method: "POST", + headers: approverHeaders, + body: {} + }); + + const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, "remote@outside.example"); + assert.ok(externalLogin.accessToken, "external user should be able to login after approver decision"); + + const operatorVerify = await requestAccessAndVerify(baseUrl, dataDir, "operator@arcg.at"); + await assert.rejects( + requestJson(baseUrl, "/v1/approvals", { + headers: { Authorization: `Bearer ${operatorVerify.accessToken}` } + }), + (error) => error && error.status === 403 + ); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("rejected external user can request approval again via request-approval flow", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-reject-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const externalEmail = "blocked@external.example"; + const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); + assert.equal(externalVerify.approved, false, "external user should require approval"); + + const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); + const pending = (approvals.approvals || []).find((entry) => entry.email === externalEmail && entry.status === "pending"); + assert.ok(pending, "pending approval for external user expected"); + + await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/reject`, { + method: "POST", + headers: adminHeaders, + body: {} + }); + + let deniedError = null; + try { + await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: externalEmail } + }); + } catch (error) { + deniedError = error; + } + assert.ok(deniedError, "denied user request-access should fail"); + assert.equal(deniedError.status, 403); + assert.equal(deniedError.payload.error.code, "auth.access_denied"); + assert.ok( + deniedError.payload.error.details && deniedError.payload.error.details.requestApprovalUrl, + "requestApprovalUrl should be included for denied users" + ); + + const requestApproval = await requestJson(baseUrl, "/v1/auth/request-approval", { + method: "POST", + body: { email: externalEmail } + }); + assert.equal(requestApproval.ok, true); + + const approvalsAfter = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); + const reopened = (approvalsAfter.approvals || []).find((entry) => entry.email === externalEmail && entry.status === "pending"); + assert.ok(reopened, "approval should be reopened after request-approval"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("auth error codes remain stable for denied/not-approved/maintenance", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-errors-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const externalEmail = "codes@external.example"; + const externalVerify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); + assert.equal(externalVerify.approved, false); + + const approvals = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); + const pending = (approvals.approvals || []).find((entry) => entry.email === externalEmail && entry.status === "pending"); + assert.ok(pending); + + await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { + method: "POST", + headers: adminHeaders, + body: {} + }); + + const externalLogin = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); + assert.ok(externalLogin.refreshToken); + + await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/reject`, { + method: "POST", + headers: adminHeaders, + body: {} + }); + + let notApprovedError = null; + try { + await requestJson(baseUrl, "/v1/auth/refresh", { + method: "POST", + body: { refreshToken: externalLogin.refreshToken } + }); + } catch (error) { + notApprovedError = error; + } + assert.ok(notApprovedError); + assert.equal(notApprovedError.status, 403); + assert.equal(notApprovedError.payload.error.code, "auth.not_approved"); + + let deniedError = null; + try { + await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: externalEmail } + }); + } catch (error) { + deniedError = error; + } + assert.ok(deniedError); + assert.equal(deniedError.status, 403); + assert.equal(deniedError.payload.error.code, "auth.access_denied"); + assert.ok(deniedError.payload.error.details.requestApprovalUrl); + + await requestJson(baseUrl, "/v1/admin/maintenance", { + method: "PUT", + headers: adminHeaders, + body: { enabled: true, message: "Wartungstest" } + }); + + let maintenanceError = null; + try { + await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "newperson@arcg.at" } + }); + } catch (error) { + maintenanceError = error; + } + assert.ok(maintenanceError); + assert.equal(maintenanceError.status, 503); + assert.equal(maintenanceError.payload.error.code, "auth.maintenance"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("user can update own preferred auth method via /v1/me/auth-method", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-self-auth-method-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const methods = await requestJson(baseUrl, "/v1/public/auth-methods"); + const methodIds = new Set((methods.methods || []).map((entry) => entry.id)); + assert.ok(methodIds.has("smtp-link")); + assert.ok(methodIds.has("otp-email")); + + const userVerify = await requestAccessAndVerify(baseUrl, dataDir, "self@arcg.at"); + assert.ok(userVerify.accessToken); + const userHeaders = { Authorization: `Bearer ${userVerify.accessToken}` }; + + const setOtp = await requestJson(baseUrl, "/v1/me/auth-method", { + method: "PUT", + headers: userHeaders, + body: { primaryMethod: "otp-email" } + }); + assert.equal(setOtp.user.primaryAuthMethod, "otp-email"); + + const meAfterOtp = await requestJson(baseUrl, "/v1/me", { headers: userHeaders }); + assert.equal(meAfterOtp.user.primaryAuthMethod, "otp-email"); + + let invalidError = null; + try { + await requestJson(baseUrl, "/v1/me/auth-method", { + method: "PUT", + headers: userHeaders, + body: { primaryMethod: "not-a-method" } + }); + } catch (error) { + invalidError = error; + } + assert.ok(invalidError); + assert.equal(invalidError.status, 400); + assert.equal(invalidError.payload.error.code, "user.auth_methods.invalid"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("approver can view users but cannot change roles or auth methods", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approver-readonly-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const approverVerify = await requestAccessAndVerify(baseUrl, dataDir, "readonly@arcg.at"); + const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); + const approverUser = (users.users || []).find((entry) => entry.email === "readonly@arcg.at"); + assert.ok(approverUser); + + await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { + method: "PUT", + headers: adminHeaders, + body: { role: "approver" } + }); + + const approverLogin = await requestAccessAndVerify(baseUrl, dataDir, "readonly@arcg.at"); + const approverHeaders = { Authorization: `Bearer ${approverLogin.accessToken}` }; + + const visibleUsers = await requestJson(baseUrl, "/v1/admin/users", { headers: approverHeaders }); + assert.ok(Array.isArray(visibleUsers.users)); + assert.ok(visibleUsers.users.length >= 2); + + let roleMutationError = null; + try { + await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { + method: "PUT", + headers: approverHeaders, + body: { role: "admin" } + }); + } catch (error) { + roleMutationError = error; + } + assert.ok(roleMutationError); + assert.equal(roleMutationError.status, 403); + + let authMethodMutationError = null; + try { + await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/auth-methods`, { + method: "PUT", + headers: approverHeaders, + body: { enabledMethods: ["smtp-link"], primaryMethod: "smtp-link" } + }); + } catch (error) { + authMethodMutationError = error; + } + assert.ok(authMethodMutationError); + assert.equal(authMethodMutationError.status, 403); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("self preferred auth method must be enabled for that user", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-self-auth-restrict-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const userVerify = await requestAccessAndVerify(baseUrl, dataDir, "limit@arcg.at"); + const userHeaders = { Authorization: `Bearer ${userVerify.accessToken}` }; + + const users = await requestJson(baseUrl, "/v1/admin/users", { headers: adminHeaders }); + const limitedUser = (users.users || []).find((entry) => entry.email === "limit@arcg.at"); + assert.ok(limitedUser); + + await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(limitedUser.id)}/auth-methods`, { + method: "PUT", + headers: adminHeaders, + body: { + enabledMethods: ["smtp-link"], + primaryMethod: "smtp-link" + } + }); + + let disallowedError = null; + try { + await requestJson(baseUrl, "/v1/me/auth-method", { + method: "PUT", + headers: userHeaders, + body: { primaryMethod: "otp-email" } + }); + } catch (error) { + disallowedError = error; + } + assert.ok(disallowedError); + assert.equal(disallowedError.status, 400); + assert.equal(disallowedError.payload.error.code, "user.auth_methods.invalid"); + + const allowed = await requestJson(baseUrl, "/v1/me/auth-method", { + method: "PUT", + headers: userHeaders, + body: { primaryMethod: "smtp-link" } + }); + assert.equal(allowed.user.primaryAuthMethod, "smtp-link"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("new users default to smtp-link and outbox uses smtp relay plugin", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-smtp-default-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const start = await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "default@arcg.at" } + }); + assert.equal(start.challengeType, "link", "default challenge should be link"); + assert.equal(start.method, "smtp-link", "default method should prefer smtp-link"); + + const outbox = await readOutbox(dataDir); + const mail = [...outbox].reverse().find((entry) => entry.to === "default@arcg.at"); + assert.ok(mail, "mail entry expected for default user"); + assert.equal(mail.via, "rms.auth.smtp_relay", "smtp relay plugin should dispatch messages"); + assert.match(String(mail.text || ""), /verifyToken=/, "verify token link should be present"); + + const token = extractTokenFromText(mail.text); + const verify = await requestJson(baseUrl, "/v1/auth/verify-email", { + method: "POST", + body: { token } + }); + assert.equal(verify.user.primaryAuthMethod, "smtp-link", "new user primary method should be smtp-link"); + assert.ok(Array.isArray(verify.user.enabledAuthMethods)); + assert.ok(verify.user.enabledAuthMethods.includes("smtp-link")); + assert.ok(verify.user.enabledAuthMethods.includes("otp-email")); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("public auth methods expose smtp-link first with 'per Mail' label", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-public-methods-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const methods = await requestJson(baseUrl, "/v1/public/auth-methods"); + assert.ok(Array.isArray(methods.methods)); + assert.ok(methods.methods.length >= 2); + assert.equal(methods.methods[0].id, "smtp-link"); + assert.equal(methods.methods[0].label, "per Mail"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("approvals list exposes account status and supports repeated decisions", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-approvals-view-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const adminHeaders = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const externalEmail = "statuscheck@outside.example"; + const verify = await requestAccessAndVerify(baseUrl, dataDir, externalEmail); + assert.equal(verify.approved, false); + + const list1 = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); + const pending = (list1.approvals || []).find((entry) => entry.email === externalEmail); + assert.ok(pending); + assert.equal(pending.userStatus, "pending_approval"); + assert.equal(typeof pending.userRole, "string"); + + await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/approve`, { + method: "POST", + headers: adminHeaders, + body: {} + }); + const list2 = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); + const approved = (list2.approvals || []).find((entry) => entry.id === pending.id); + assert.ok(approved); + assert.equal(approved.status, "approved"); + assert.equal(approved.userStatus, "active"); + + await requestJson(baseUrl, `/v1/approvals/${encodeURIComponent(pending.id)}/reject`, { + method: "POST", + headers: adminHeaders, + body: {} + }); + const list3 = await requestJson(baseUrl, "/v1/approvals", { headers: adminHeaders }); + const rejected = (list3.approvals || []).find((entry) => entry.id === pending.id); + assert.ok(rejected); + assert.equal(rejected.status, "rejected"); + assert.equal(rejected.userStatus, "denied"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("station usage is auto-ended after configured max usage time", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-station-timeout-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl, + STATION_MAX_USAGE_SEC: "2" + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + + const activeStatus = await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? status : null; + }, 20000, 250); + + assert.ok(activeStatus.endsAt, "activation should set end time"); + + const releasedStatus = await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? null : status; + }, 15000, 300); + + assert.equal(releasedStatus.isInUse, false); + assert.equal(releasedStatus.activeByEmail, null); + assert.equal(releasedStatus.endsAt, null); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("activity log endpoint lists link requests and station operations", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-activity-log-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const approverVerify = await requestAccessAndVerify(baseUrl, dataDir, "logviewer@arcg.at"); + const users = await requestJson(baseUrl, "/v1/admin/users", { headers }); + const approverUser = (users.users || []).find((entry) => entry.email === "logviewer@arcg.at"); + assert.ok(approverUser); + await requestJson(baseUrl, `/v1/admin/users/${encodeURIComponent(approverUser.id)}/role`, { + method: "PUT", + headers, + body: { role: "approver" } + }); + const approverLogin = await requestAccessAndVerify(baseUrl, dataDir, "logviewer@arcg.at"); + const approverHeaders = { Authorization: `Bearer ${approverLogin.accessToken}` }; + + await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "viewer@arcg.at" } + }); + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + + await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? true : null; + }, 20000, 250); + + await requestJson(baseUrl, "/v1/station/release", { + method: "POST", + headers, + body: {} + }); + + const log = await requestJson(baseUrl, "/v1/activity-log?limit=200", { headers }); + assert.ok(Array.isArray(log.entries)); + const actions = new Set(log.entries.map((entry) => entry.action)); + assert.ok(actions.has("auth.request_access")); + assert.ok(actions.has("station.activate.start")); + assert.ok(actions.has("station.activate.done")); + assert.ok(actions.has("station.deactivate")); + + const approverLog = await requestJson(baseUrl, "/v1/activity-log?limit=50", { headers: approverHeaders }); + assert.ok(Array.isArray(approverLog.entries)); + assert.ok(approverLog.entries.length > 0); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("admin can upload theme logos and public system exposes branding URLs", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-branding-upload-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const tinyPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6X3mQAAAAASUVORK5CYII="; + + await requestJson(baseUrl, "/v1/admin/branding/logo", { + method: "PUT", + headers, + body: { theme: "light", dataUrl: tinyPng, fileName: "light.png" } + }); + await requestJson(baseUrl, "/v1/admin/branding/logo", { + method: "PUT", + headers, + body: { theme: "dark", dataUrl: tinyPng, fileName: "dark.png" } + }); + + const system = await requestJson(baseUrl, "/v1/public/system"); + assert.ok(system.branding); + assert.equal(system.branding.logoLightUrl, "/uploads/logo-light.png"); + assert.equal(system.branding.logoDarkUrl, "/uploads/logo-dark.png"); + + const lightRes = await fetch(`${baseUrl}${system.branding.logoLightUrl}`); + assert.equal(lightRes.status, 200); + const darkRes = await fetch(`${baseUrl}${system.branding.logoDarkUrl}`); + assert.equal(darkRes.status, 200); + + await requestJson(baseUrl, "/v1/admin/branding/logo?theme=light", { + method: "DELETE", + headers + }); + const systemAfterDelete = await requestJson(baseUrl, "/v1/public/system"); + assert.equal(systemAfterDelete.branding.logoLightUrl, null); + assert.equal(systemAfterDelete.branding.logoDarkUrl, "/uploads/logo-dark.png"); + + const lightResAfterDelete = await fetch(`${baseUrl}/uploads/logo-light.png`); + assert.equal(lightResAfterDelete.status, 404); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("swr report endpoint returns band data and supports manual run trigger", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-swr-report-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + const report = await requestJson(baseUrl, "/v1/swr/report", { headers }); + assert.ok(Array.isArray(report.bands)); + assert.ok(report.bands.length >= 8); + assert.ok(report.bands.some((entry) => entry.band === "20m")); + + const run = await requestJson(baseUrl, "/v1/swr/run-check", { + method: "POST", + headers, + body: {} + }); + assert.equal(run.ok, true); + assert.ok(run.report); + + const staleLegacyPath = path.join(dataDir, "vswr", "legacy-report.json"); + await fs.mkdir(path.dirname(staleLegacyPath), { recursive: true }); + await fs.writeFile(staleLegacyPath, JSON.stringify({ + generatedAt: "2000-01-01T00:00:00.000Z", + overallStatus: "FAILED", + bands: [ + { band: "20m", status: "FAILED", error: "stale legacy" } + ] + }, null, 2)); + + const refreshedReport = await requestJson(baseUrl, "/v1/swr/report", { headers }); + assert.equal(refreshedReport.generatedAt, run.report.generatedAt); + const refreshed20m = Array.isArray(refreshedReport.bands) + ? refreshedReport.bands.find((entry) => entry && entry.band === "20m") + : null; + assert.ok(refreshed20m); + assert.notEqual(String(refreshed20m.status || "").toUpperCase(), "FAILED"); + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? status : null; + }, 20000, 250); + + await assert.rejects( + requestJson(baseUrl, "/v1/swr/run-check", { + method: "POST", + headers, + body: {} + }), + (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "vswr.unsafe_station_active" + ); + + await requestJson(baseUrl, "/v1/station/release", { + method: "POST", + headers, + body: {} + }); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("tx-active lock blocks any switching and swr run", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-tx-lock-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const txStatePath = path.join(dataDir, "tx-state.json"); + await fs.writeFile(txStatePath, JSON.stringify({ txActive: true, updatedAt: new Date().toISOString(), source: "test" })); + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + TX_STATE_PATH: txStatePath, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + await assert.rejects( + requestJson(baseUrl, "/v1/swr/run-check", { + method: "POST", + headers, + body: {} + }), + (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked" + ); + + await assert.rejects( + requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }), + (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked" + ); + + await assert.rejects( + requestJson(baseUrl, "/v1/ui/controls/rfroute-main/actions/setRoute", { + method: "POST", + headers, + body: { input: { route: "beam" } } + }), + (error) => error && error.status === 409 && error.payload && error.payload.error && error.payload.error.code === "tx.switch_locked" + ); + + await fs.writeFile(txStatePath, JSON.stringify({ txActive: false, updatedAt: new Date().toISOString(), source: "test" })); + + const unlockRun = await requestJson(baseUrl, "/v1/swr/run-check", { + method: "POST", + headers, + body: {} + }); + assert.equal(unlockRun.ok, true); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("openwebrx session is owner-bound and release disables tx first", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-openwebrx-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const txStatePath = path.join(dataDir, "tx-state.json"); + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + TX_STATE_PATH: txStatePath, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl, + OPENWEBRX_PATH: "/openwebrx/" + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? status : null; + }, 20000, 250); + + const session = await requestJson(baseUrl, "/v1/openwebrx/session", { + method: "POST", + headers, + body: {} + }); + assert.equal(session.ok, true); + assert.ok(session.session.ticket); + + const authOk = await fetch(`${baseUrl}/v1/openwebrx/authorize?ticket=${encodeURIComponent(session.session.ticket)}`); + assert.equal(authOk.status, 200); + + await requestJson(baseUrl, "/v1/openwebrx/tx/enable", { + method: "POST", + headers, + body: {} + }); + + await requestJson(baseUrl, "/v1/station/release", { + method: "POST", + headers, + body: {} + }); + + const authAfterRelease = await fetch(`${baseUrl}/v1/openwebrx/authorize?ticket=${encodeURIComponent(session.session.ticket)}`); + assert.equal(authAfterRelease.status, 403); + + const txRaw = await fs.readFile(txStatePath, "utf8"); + const txState = JSON.parse(txRaw); + assert.equal(Boolean(txState.txActive), false); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("help content endpoint is auth-only and returns structured content", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-help-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + const unauth = await fetch(`${baseUrl}/v1/help/content`); + assert.equal(unauth.status, 401); + + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + const help = await requestJson(baseUrl, "/v1/help/content", { headers }); + + assert.ok(help.content); + assert.equal(help.content.title, "RMS Hilfe"); + assert.ok(help.content.quickStart); + assert.ok(Array.isArray(help.content.quickStart.steps)); + assert.ok(help.content.quickStart.steps.length >= 3); + assert.ok(Array.isArray(help.content.sections)); + assert.ok(help.content.sections.length >= 3); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("openwebrx bandmap endpoints work for active owner and access policy syncs owner", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-openwebrx-band-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const policyPath = path.join(dataDir, "openwebrx-access-policy.txt"); + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl, + OPENWEBRX_ACCESS_POLICY_FILE: policyPath, + OPENWEBRX_BANDMAP: "80;3650000;80m;draht,40;7150000;40m;draht,20;14300000;20m;beam" + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? status : null; + }, 20000, 250); + + const bands = await requestJson(baseUrl, "/v1/openwebrx/bands", { headers }); + assert.equal(bands.ok, true); + assert.ok(Array.isArray(bands.bands)); + assert.ok(bands.bands.length > 0); + + const firstBand = String(bands.bands[0].band); + const selected = await requestJson(baseUrl, "/v1/openwebrx/bands/select", { + method: "POST", + headers, + body: { band: firstBand } + }); + assert.equal(selected.ok, true); + assert.equal(String(selected.result.selectedBand), firstBand); + + const policyRawActive = await fs.readFile(policyPath, "utf8"); + assert.ok(policyRawActive.includes("admin@arcg.at")); + + await requestJson(baseUrl, "/v1/station/release", { + method: "POST", + headers, + body: {} + }); + + await assert.rejects( + requestJson(baseUrl, "/v1/openwebrx/bands", { headers }), + (error) => error && error.status === 403 && error.payload && error.payload.error && error.payload.error.code === "openwebrx.not_owner" + ); + + const policyRawReleased = await fs.readFile(policyPath, "utf8"); + assert.equal(policyRawReleased.trim(), ""); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("openwebrx tx status is owner-only and reflects tx enable", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-openwebrx-tx-status-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const owner = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const other = await requestAccessAndVerify(baseUrl, dataDir, "operator2@arcg.at"); + const ownerHeaders = { Authorization: `Bearer ${owner.accessToken}` }; + const otherHeaders = { Authorization: `Bearer ${other.accessToken}` }; + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers: ownerHeaders, + body: {} + }); + await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers: ownerHeaders }); + return status.isInUse ? status : null; + }, 20000, 250); + + const initialStatus = await requestJson(baseUrl, "/v1/openwebrx/tx/status", { headers: ownerHeaders }); + assert.equal(initialStatus.ok, true); + assert.equal(Boolean(initialStatus.txActive), false); + + await requestJson(baseUrl, "/v1/openwebrx/tx/enable", { + method: "POST", + headers: ownerHeaders, + body: {} + }); + + const afterEnableStatus = await requestJson(baseUrl, "/v1/openwebrx/tx/status", { headers: ownerHeaders }); + assert.equal(afterEnableStatus.ok, true); + assert.equal(Boolean(afterEnableStatus.txActive), true); + + await assert.rejects( + requestJson(baseUrl, "/v1/openwebrx/tx/status", { headers: otherHeaders }), + (error) => error && error.status === 403 && error.payload && error.payload.error && error.payload.error.code === "openwebrx.not_owner" + ); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("failed activation clears running state and exposes error in status", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-activation-fail-status-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + RMS_EXEC_MODE: "prod", + SCRIPT_ACTIVATE: "definitely-not-a-command", + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + + const failedStatus = await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + if (status.activation && status.activation.running) { + return null; + } + if (status.activation && status.activation.lastStatus === "failed") { + return status; + } + return null; + }, 20000, 250); + + assert.equal(Boolean(failedStatus.isInUse), false); + assert.equal(failedStatus.activation.lastStatus, "failed"); + assert.ok(typeof failedStatus.activation.lastError === "string" && failedStatus.activation.lastError.length > 0); + + const secondStart = await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + assert.equal(Boolean(secondStart.ok), true); + assert.equal(Boolean(secondStart.pending), true); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("prod activation tolerates missing default station scripts", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-missing-station-script-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + RMS_EXEC_MODE: "prod", + SCRIPT_ROOT: "/opt/remotestation", + SCRIPT_ACTIVATE: "/opt/remotestation/bin/activate.sh", + SCRIPT_DEACTIVATE: "/opt/remotestation/bin/deactivate.sh", + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + + await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? status : null; + }, 20000, 250); + + await requestJson(baseUrl, "/v1/station/release", { + method: "POST", + headers, + body: {} + }); + + const released = await waitForCondition(async () => { + const status = await requestJson(baseUrl, "/v1/station/status", { headers }); + return status.isInUse ? null : status; + }, 10000, 250); + assert.equal(Boolean(released.isInUse), false); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + +test("prod mode auto-disables TX before station activation", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-prod-auto-txoff-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const txStatePath = path.join(dataDir, "tx-state.json"); + await fs.writeFile(txStatePath, JSON.stringify({ txActive: true, updatedAt: new Date().toISOString(), source: "test" })); + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + RMS_EXEC_MODE: "prod", + AUTO_DISABLE_TX_BEFORE_ACTIVATION: "true", + TX_STATE_PATH: txStatePath, + TX_DISABLE_CMD: "true", + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + const adminVerify = await requestAccessAndVerify(baseUrl, dataDir, "admin@arcg.at"); + const headers = { Authorization: `Bearer ${adminVerify.accessToken}` }; + + await requestJson(baseUrl, "/v1/station/activation-jobs", { + method: "POST", + headers, + body: {} + }); + + const status = await waitForCondition(async () => { + const current = await requestJson(baseUrl, "/v1/station/status", { headers }); + return current.isInUse ? current : null; + }, 20000, 250); + assert.equal(Boolean(status.isInUse), true); + + const txRaw = await fs.readFile(txStatePath, "utf8"); + const txState = JSON.parse(txRaw); + assert.equal(Boolean(txState.txActive), false); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); diff --git a/tools/check-windows-paths.ps1 b/tools/check-windows-paths.ps1 new file mode 100644 index 0000000..fbf5346 --- /dev/null +++ b/tools/check-windows-paths.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = "Stop" + +param( + [Parameter(Mandatory = $false)] + [string]$RepoPath = "." +) + +$invalidCharPattern = '[<>:""/|?*]' +$reserved = '^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$' + +$repoAbs = Resolve-Path $RepoPath +Push-Location $repoAbs + +try { + $paths = git ls-tree -r --name-only HEAD + if (-not $paths) { + Write-Output "Keine Dateien im Git-Tree gefunden." + exit 0 + } + + $issues = @() + foreach ($p in $paths) { + $parts = $p -split '/' + foreach ($part in $parts) { + if ($part -match $invalidCharPattern) { + $issues += "INVALID_CHAR: $p" + break + } + if ($part -match $reserved) { + $issues += "RESERVED_NAME: $p" + break + } + if ($part.EndsWith(".") -or $part.EndsWith(" ")) { + $issues += "TRAILING_DOT_SPACE: $p" + break + } + } + if ($p.Length -gt 245) { + $issues += "POTENTIAL_LONG_PATH($($p.Length)): $p" + } + } + + if ($issues.Count -eq 0) { + Write-Output "Keine offensichtlichen Windows-Pfadprobleme im aktuellen Commit gefunden." + exit 0 + } + + $issues | Sort-Object -Unique | ForEach-Object { Write-Output $_ } + exit 2 +} +finally { + Pop-Location +} diff --git a/tools/migrate-json-to-sqlite.js b/tools/migrate-json-to-sqlite.js new file mode 100644 index 0000000..877be2d --- /dev/null +++ b/tools/migrate-json-to-sqlite.js @@ -0,0 +1,53 @@ +const path = require("path"); +const fs = require("fs"); +const fsp = require("fs/promises"); + +const { createJsonStorage } = require("../server/storage/providers/json"); +const { createSqliteStorage } = require("../server/storage/providers/sqlite"); + +async function main() { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = path.resolve(rootDir, process.env.DATA_DIR || "./data"); + const sqlitePath = path.resolve(rootDir, process.env.STORAGE_SQLITE_PATH || "./data/rms-storage.db"); + + await fsp.mkdir(dataDir, { recursive: true }); + + const json = createJsonStorage({ dataDir }); + await json.init(); + const sqlite = createSqliteStorage({ sqlitePath }); + await sqlite.init(); + + const files = [ + "users.json", + "station-state.json", + "auth-state.json", + "plugin-state.json" + ].map((name) => path.join(dataDir, name)); + + for (const filePath of files) { + if (!fs.existsSync(filePath)) { + continue; + } + const value = await json.readJson(filePath, null); + if (value !== null) { + await sqlite.writeJson(filePath, value); + console.log(`migrated json: ${filePath}`); + } + } + + const auditPath = path.join(dataDir, "audit.log"); + if (fs.existsSync(auditPath)) { + const content = await json.readText(auditPath, ""); + if (content) { + await sqlite.writeText(auditPath, content); + console.log(`migrated text: ${auditPath}`); + } + } + + console.log(`done -> ${sqlitePath}`); +} + +main().catch((error) => { + console.error("migration failed", error); + process.exit(1); +});