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:
2026-03-16 03:31:08 +01:00
commit e1a4ce0b8b
58 changed files with 20611 additions and 0 deletions

View 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
};

View 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": []
}