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.
386 lines
14 KiB
JavaScript
386 lines
14 KiB
JavaScript
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
|
|
};
|