const fs = require("fs"); const path = require("path"); const DEFAULT_BANDS = [ { band: "160m", startHz: 1810000, endHz: 2000000 }, { band: "80m", startHz: 3500000, endHz: 3800000 }, { band: "40m", startHz: 7000000, endHz: 7200000 }, { band: "20m", startHz: 14000000, endHz: 14350000 }, { band: "17m", startHz: 18068000, endHz: 18168000 }, { band: "15m", startHz: 21000000, endHz: 21450000 }, { band: "12m", startHz: 24890000, endHz: 24990000 }, { band: "10m", startHz: 28200000, endHz: 29000000 } ]; async function createPlugin(ctx) { return { async execute(action) { if (action === "runCheck") { return runCheck(ctx); } if (action === "getReport") { return readReport(ctx); } throw new Error(`Unknown action: ${action}`); }, async getStatus() { const report = readReport(ctx); return { source: report.source, generatedAt: report.generatedAt, overallStatus: report.overallStatus, bands: report.bands.length }; }, async health() { return { ok: true }; } }; } async function runCheck(ctx) { const dataDir = String(ctx.env.DATA_DIR || "/opt/remotestation-arcg/shared/data").trim() || "/opt/remotestation-arcg/shared/data"; const tracePath = resolvePath(String(ctx.env.VSWR_NATIVE_TRACE_PATH || `${dataDir}/vswr/native-run.log`)); const reportPath = resolvePath(String(ctx.getSetting("reportJsonPath", ctx.env.VSWR_REPORT_JSON_PATH || `${dataDir}/vswr/swr-report.json`))); const outputBaseDir = resolvePath(String(ctx.getSetting("outputBaseDir", ctx.env.VSWR_OUTPUT_BASE_DIR || `${dataDir}/vswr/output`))); const timeoutMs = Number(ctx.getSetting("timeoutMsPerBand", ctx.env.VSWR_TIMEOUT_MS_PER_BAND || 45000)); const bands = readBands(ctx); const cmdTemplate = String( ctx.getSetting( "nanovnaCommandTemplate", ctx.env.NANOVNA_COMMAND_TEMPLATE || ctx.env.VSWR_CHECK_CMD || "" ) ).trim(); const multiBandInSingleRun = cmdTemplate && !/[{](band|startHz|endHz|bandDir)[}]/.test(cmdTemplate); let batchCommandError = ""; const report = { source: "native-controller", generatedAt: new Date().toISOString(), overallStatus: "OK", overviewUrl: String(ctx.env.SWR_OVERVIEW_URL || "") || null, bands: [] }; await fs.promises.mkdir(outputBaseDir, { recursive: true }); appendTrace(tracePath, `run-start cmdTemplate=${cmdTemplate || ""} multiBand=${multiBandInSingleRun ? "yes" : "no"}`); const simulate = typeof ctx.simulateHardware === "boolean" ? ctx.simulateHardware : !(process.platform === "linux" || String(ctx.env.ALLOW_NON_LINUX_CMDS || "false") === "true"); const canRunHardware = !simulate; if (multiBandInSingleRun && canRunHardware) { appendTrace(tracePath, `batch-run command=${cmdTemplate}`); let result = await ctx.commandRunner(cmdTemplate, { timeoutMs: timeoutMs * Math.max(1, bands.length) }); if (!result.ok && Number(result.code) === 127) { const trimmed = String(cmdTemplate || "").trim(); const firstToken = trimmed.split(/\s+/, 1)[0] || ""; if (firstToken && firstToken.endsWith(".sh") && fs.existsSync(firstToken)) { const fallbackCommand = `/bin/bash ${quotePathForCmd(firstToken)}`; appendTrace(tracePath, `batch-retry fallback=${fallbackCommand}`); result = await ctx.commandRunner(fallbackCommand, { timeoutMs: timeoutMs * Math.max(1, bands.length) }); } } appendTrace(tracePath, `batch-result ok=${result.ok ? "yes" : "no"} code=${result.code == null ? "-" : String(result.code)}`); if (!result.ok) { const errorText = String(result.stderr || result.error || "").trim(); if (errorText) { appendTrace(tracePath, `batch-stderr ${errorText.slice(0, 400)}`); } } if (!result.ok) { batchCommandError = String(result.stderr || result.error || `command exited with code ${result.code}` || "VSWR batch check failed"); report.overallStatus = "FAILED"; } } for (const bandCfg of bands) { const bandDir = path.join(outputBaseDir, bandCfg.band); await fs.promises.mkdir(bandDir, { recursive: true }); const entry = { band: bandCfg.band, startHz: bandCfg.startHz, endHz: bandCfg.endHz, status: "UNKNOWN", imageUrl: defaultBandImageUrl(ctx, bandCfg.band), updatedAt: new Date().toISOString(), error: null }; if (!cmdTemplate || !canRunHardware) { entry.status = "OK"; report.bands.push(entry); continue; } if (multiBandInSingleRun) { entry.status = "UNKNOWN"; report.bands.push(entry); continue; } const command = cmdTemplate .replaceAll("{band}", bandCfg.band) .replaceAll("{startHz}", String(bandCfg.startHz)) .replaceAll("{endHz}", String(bandCfg.endHz)) .replaceAll("{bandDir}", quotePathForCmd(bandDir)); const result = await ctx.commandRunner(command, { timeoutMs }); appendTrace(tracePath, `band=${bandCfg.band} ok=${result.ok ? "yes" : "no"} code=${result.code == null ? "-" : String(result.code)}`); if (result.ok) { entry.status = "OK"; } else { entry.status = "FAILED"; entry.error = String(result.stderr || result.error || "check failed"); report.overallStatus = "FAILED"; } report.bands.push(entry); } if (multiBandInSingleRun) { const legacyReportPath = resolvePath(String(ctx.env.VSWR_LEGACY_REPORT_JSON_PATH || `${dataDir}/vswr/legacy-report.json`)); const overviewHtmlPath = resolvePath(String(ctx.env.VSWR_OVERVIEW_HTML_PATH || `${dataDir}/vswr/index.html`)); let legacyBands = null; let overviewBands = null; if (legacyReportPath && fs.existsSync(legacyReportPath)) { try { const parsed = JSON.parse(String(fs.readFileSync(legacyReportPath, "utf8") || "{}")); if (parsed && Array.isArray(parsed.bands)) { legacyBands = new Map(parsed.bands.map((entry) => [String(entry.band || "").toLowerCase(), entry])); if (parsed.overallStatus) { report.overallStatus = String(parsed.overallStatus).toUpperCase(); } } } catch { // ignore malformed legacy report } } if (!legacyBands && overviewHtmlPath && fs.existsSync(overviewHtmlPath)) { overviewBands = parseOverviewStatuses(String(fs.readFileSync(overviewHtmlPath, "utf8") || "")); } const metadataPath = String(ctx.env.VSWR_METADATA_PATH || "").trim(); if (metadataPath && fs.existsSync(metadataPath)) { const raw = String(fs.readFileSync(metadataPath, "utf8") || "").trim().toUpperCase(); if (raw === "FAILED") { report.overallStatus = "FAILED"; } else if (raw === "OK") { report.overallStatus = "OK"; } else { report.overallStatus = "UNKNOWN"; } } for (const entry of report.bands) { const legacy = legacyBands ? legacyBands.get(String(entry.band || "").toLowerCase()) : null; if (legacy && legacy.status) { entry.status = String(legacy.status).toUpperCase(); entry.error = legacy.error ? String(legacy.error) : null; } else if (overviewBands && overviewBands.has(String(entry.band || "").toLowerCase())) { entry.status = overviewBands.get(String(entry.band || "").toLowerCase()) || "UNKNOWN"; } else { const expected = resolveImagePath(outputBaseDir, entry.band); entry.status = fs.existsSync(expected) ? "OK" : "UNKNOWN"; } if (entry.status !== "OK" && report.overallStatus === "OK") { report.overallStatus = "UNKNOWN"; } if (batchCommandError && entry.status !== "OK") { entry.error = batchCommandError; } } if (batchCommandError && report.overallStatus === "OK") { report.overallStatus = "FAILED"; } } await fs.promises.mkdir(path.dirname(reportPath), { recursive: true }); await fs.promises.writeFile(reportPath, JSON.stringify(report, null, 2)); appendTrace(tracePath, `run-finish overall=${report.overallStatus}`); return { ok: true, report }; } function readReport(ctx) { const dataDir = String(ctx.env.DATA_DIR || "/opt/remotestation-arcg/shared/data").trim() || "/opt/remotestation-arcg/shared/data"; const reportPath = resolvePath(String(ctx.getSetting("reportJsonPath", ctx.env.VSWR_REPORT_JSON_PATH || `${dataDir}/vswr/swr-report.json`))); if (!fs.existsSync(reportPath)) { const base = { source: "native-controller", generatedAt: null, overallStatus: "UNKNOWN", overviewUrl: String(ctx.env.SWR_OVERVIEW_URL || "") || null, bands: DEFAULT_BANDS.map((band) => ({ band: band.band, startHz: band.startHz, endHz: band.endHz, status: "UNKNOWN", imageUrl: defaultBandImageUrl(ctx, band.band), updatedAt: null, error: null })) }; return applyLegacyStatusFallback(base, dataDir, ctx); } try { const raw = fs.readFileSync(reportPath, "utf8"); const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.bands)) { return applyLegacyStatusFallback(parsed, dataDir, ctx); } } catch { // ignore parse issues } const fallback = { source: "native-controller", generatedAt: null, overallStatus: "UNKNOWN", overviewUrl: String(ctx.env.SWR_OVERVIEW_URL || "") || null, bands: [] }; return applyLegacyStatusFallback(fallback, dataDir, ctx); } function readBands(ctx) { const raw = String(ctx.getSetting("bandsJson", ctx.env.VSWR_BANDS_JSON || "")).trim(); if (!raw) return DEFAULT_BANDS; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed) || parsed.length === 0) { return DEFAULT_BANDS; } const bands = []; for (const entry of parsed) { if (!entry || !entry.band || !entry.startHz || !entry.endHz) continue; bands.push({ band: String(entry.band), startHz: Number(entry.startHz), endHz: Number(entry.endHz) }); } return bands.length ? bands : DEFAULT_BANDS; } catch { return DEFAULT_BANDS; } } function defaultBandImageUrl(ctx, band) { const base = String(ctx.getSetting("publicImagesBaseUrl", ctx.env.VSWR_IMAGES_BASE_URL || "")).trim(); if (!base) return null; return `${base.replace(/\/$/, "")}/${band}.png`; } function resolvePath(value) { const v = String(value || "").trim(); if (!v) return ""; if (path.isAbsolute(v)) return v; return path.resolve(process.cwd(), v); } function quotePathForCmd(value) { const text = String(value || ""); return text.includes(" ") ? `"${text}"` : text; } function resolveImagePath(outputBaseDir, band) { const base = path.resolve(outputBaseDir, ".."); return path.join(base, "images", `${band}.png`); } function appendTrace(tracePath, line) { if (!tracePath) return; try { fs.mkdirSync(path.dirname(tracePath), { recursive: true }); fs.appendFileSync(tracePath, `[${new Date().toISOString()}] ${line}\n`); } catch { // ignore trace write failures } } function applyLegacyStatusFallback(report, dataDir, ctx) { const next = { source: report && report.source ? report.source : "native-controller", generatedAt: report && report.generatedAt ? report.generatedAt : null, overallStatus: report && report.overallStatus ? String(report.overallStatus).toUpperCase() : "UNKNOWN", overviewUrl: report && Object.prototype.hasOwnProperty.call(report, "overviewUrl") ? report.overviewUrl : (String(ctx.env.SWR_OVERVIEW_URL || "") || null), bands: Array.isArray(report && report.bands) ? report.bands.map((entry) => ({ ...entry })) : [] }; const legacyReportPath = resolvePath(String(ctx.env.VSWR_LEGACY_REPORT_JSON_PATH || `${dataDir}/vswr/legacy-report.json`)); if (legacyReportPath && fs.existsSync(legacyReportPath)) { try { const parsed = JSON.parse(String(fs.readFileSync(legacyReportPath, "utf8") || "{}")); if (parsed && Array.isArray(parsed.bands) && shouldApplyLegacyFallback(parsed, next)) { const legacyMap = new Map(parsed.bands.map((entry) => [String(entry.band || "").toLowerCase(), entry])); if (parsed.generatedAt) { next.generatedAt = String(parsed.generatedAt); } if (parsed.overallStatus) { next.overallStatus = String(parsed.overallStatus).toUpperCase(); } for (const bandEntry of next.bands) { const legacy = legacyMap.get(String(bandEntry.band || "").toLowerCase()); if (!legacy) continue; if (legacy.status) { bandEntry.status = String(legacy.status).toUpperCase(); } if (Object.prototype.hasOwnProperty.call(legacy, "error")) { bandEntry.error = legacy.error ? String(legacy.error) : null; } } } } catch { // ignore malformed legacy report } } return next; } function shouldApplyLegacyFallback(legacy, current) { const legacyAt = parseTimestamp(legacy && legacy.generatedAt); const currentAt = parseTimestamp(current && current.generatedAt); if (legacyAt == null) { return currentAt == null; } if (currentAt == null) { return true; } return legacyAt >= currentAt; } function parseTimestamp(value) { if (!value) return null; const ts = Date.parse(String(value)); return Number.isFinite(ts) ? ts : null; } function parseOverviewStatuses(html) { const map = new Map(); if (!html) return map; const pattern = /
  • \s*([0-9]{1,3}m)\s*-\s*]*>\s*(OK|FAILED|UNKNOWN)\s*<\/span>/gi; for (const match of html.matchAll(pattern)) { const band = String(match[1] || "").toLowerCase(); const status = String(match[2] || "").toUpperCase(); if (band) { map.set(band, status); } } return map; } module.exports = { createPlugin };