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.
This commit is contained in:
32
plugins/rms.auth.otp_email/index.js
Normal file
32
plugins/rms.auth.otp_email/index.js
Normal file
@@ -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
|
||||
};
|
||||
20
plugins/rms.auth.otp_email/manifest.json
Normal file
20
plugins/rms.auth.otp_email/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
101
plugins/rms.auth.smtp_relay/index.js
Normal file
101
plugins/rms.auth.smtp_relay/index.js
Normal file
@@ -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
|
||||
};
|
||||
27
plugins/rms.auth.smtp_relay/manifest.json
Normal file
27
plugins/rms.auth.smtp_relay/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
335
plugins/rms.debug.remote/index.js
Normal file
335
plugins/rms.debug.remote/index.js
Normal file
@@ -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
|
||||
};
|
||||
94
plugins/rms.debug.remote/manifest.json
Normal file
94
plugins/rms.debug.remote/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
89
plugins/rms.help.basic/index.js
Normal file
89
plugins/rms.help.basic/index.js
Normal file
@@ -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
|
||||
};
|
||||
15
plugins/rms.help.basic/manifest.json
Normal file
15
plugins/rms.help.basic/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
601
plugins/rms.microham/index.js
Normal file
601
plugins/rms.microham/index.js
Normal file
@@ -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
|
||||
};
|
||||
34
plugins/rms.microham/manifest.json
Normal file
34
plugins/rms.microham/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
311
plugins/rms.openwebrx.bandmap/index.js
Normal file
311
plugins/rms.openwebrx.bandmap/index.js
Normal file
@@ -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
|
||||
};
|
||||
42
plugins/rms.openwebrx.bandmap/manifest.json
Normal file
42
plugins/rms.openwebrx.bandmap/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
160
plugins/rms.openwebrx.guard/index.js
Normal file
160
plugins/rms.openwebrx.guard/index.js
Normal file
@@ -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
|
||||
};
|
||||
24
plugins/rms.openwebrx.guard/manifest.json
Normal file
24
plugins/rms.openwebrx.guard/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
66
plugins/rms.rfroute.shell/index.js
Normal file
66
plugins/rms.rfroute.shell/index.js
Normal file
@@ -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
|
||||
};
|
||||
56
plugins/rms.rfroute.shell/manifest.json
Normal file
56
plugins/rms.rfroute.shell/manifest.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
93
plugins/rms.rotor.hamlib/index.js
Normal file
93
plugins/rms.rotor.hamlib/index.js
Normal file
@@ -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
|
||||
};
|
||||
51
plugins/rms.rotor.hamlib/manifest.json
Normal file
51
plugins/rms.rotor.hamlib/manifest.json
Normal file
@@ -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]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
143
plugins/rms.station.access.policy/index.js
Normal file
143
plugins/rms.station.access.policy/index.js
Normal file
@@ -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
|
||||
};
|
||||
69
plugins/rms.station.access.policy/manifest.json
Normal file
69
plugins/rms.station.access.policy/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
122
plugins/rms.station.shell/index.js
Normal file
122
plugins/rms.station.shell/index.js
Normal file
@@ -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
|
||||
};
|
||||
21
plugins/rms.station.shell/manifest.json
Normal file
21
plugins/rms.station.shell/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
70
plugins/rms.tx.audio.core/index.js
Normal file
70
plugins/rms.tx.audio.core/index.js
Normal file
@@ -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
|
||||
};
|
||||
16
plugins/rms.tx.audio.core/manifest.json
Normal file
16
plugins/rms.tx.audio.core/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
158
plugins/rms.tx.control.native/index.js
Normal file
158
plugins/rms.tx.control.native/index.js
Normal file
@@ -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
|
||||
};
|
||||
59
plugins/rms.tx.control.native/manifest.json
Normal file
59
plugins/rms.tx.control.native/manifest.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
56
plugins/rms.tx.state.file/index.js
Normal file
56
plugins/rms.tx.state.file/index.js
Normal file
@@ -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
|
||||
};
|
||||
17
plugins/rms.tx.state.file/manifest.json
Normal file
17
plugins/rms.tx.state.file/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
70
plugins/rms.vswr.nanovna/index.js
Normal file
70
plugins/rms.vswr.nanovna/index.js
Normal file
@@ -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
|
||||
};
|
||||
46
plugins/rms.vswr.nanovna/manifest.json
Normal file
46
plugins/rms.vswr.nanovna/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
385
plugins/rms.vswr.native/index.js
Normal file
385
plugins/rms.vswr.native/index.js
Normal file
@@ -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 || "<empty>"} 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 = /<li>\s*([0-9]{1,3}m)\s*-\s*<span[^>]*>\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
|
||||
};
|
||||
23
plugins/rms.vswr.native/manifest.json
Normal file
23
plugins/rms.vswr.native/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
165
plugins/rms.vswr.report_reader/index.js
Normal file
165
plugins/rms.vswr.report_reader/index.js
Normal file
@@ -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*<span\s+class="([^"]+)"[^>]*>\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 = /<li>\s*([0-9]{2,3}m)\s*-\s*<span\s+class="([^"]+)"[^>]*>\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 = /<img\s+[^>]*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
|
||||
};
|
||||
20
plugins/rms.vswr.report_reader/manifest.json
Normal file
20
plugins/rms.vswr.report_reader/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
Reference in New Issue
Block a user