(function (global) { "use strict"; const DEFAULTS = Object.freeze({ start: "2026-09-21T17:00:00-07:00", end: "2026-09-23T17:00:00-07:00", tz: "America/Los_Angeles", freq: "910.1", bw: "500", sf: "8", cr: "7", normalfreq: "910.525", normalbw: "62.5", normalsf: "7", normalcr: "5", tx: "22", }); const VALID_BANDWIDTHS = Object.freeze([ 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500, ]); const SCHEDULE_HORIZON_MS = 0x7fffffff; const EARLY_JOIN_MS = 60 * 60 * 1000; const CLOCK_RESET_COMMAND = "clkreboot"; class PresetTestError extends Error {} function strictNumber(value, name) { const text = String(value).trim(); if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) { throw new PresetTestError(name + " must be a decimal number"); } const parsed = Number(text); if (!Number.isFinite(parsed)) { throw new PresetTestError(name + " is outside the supported range"); } return parsed; } function strictSignedNumber(value, name) { const text = String(value).trim(); if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) { throw new PresetTestError(name + " must be a decimal number"); } const parsed = Number(text); if (!Number.isFinite(parsed)) { throw new PresetTestError(name + " is outside the supported range"); } return parsed; } function strictInteger(value, name) { const text = String(value).trim(); if (!/^\d+$/.test(text)) { throw new PresetTestError(name + " must be an integer"); } return Number(text); } function parseTimestamp(value, name) { const text = String(value).trim(); let milliseconds; if (/^\d{10}$/.test(text)) { milliseconds = Number(text) * 1000; } else if (/^\d{13}$/.test(text)) { milliseconds = Number(text); } else { if (!/(?:Z|[+-]\d{2}:\d{2})$/i.test(text)) { throw new PresetTestError( name + " must include an explicit UTC offset such as Z or -07:00" ); } milliseconds = Date.parse(text); } if (!Number.isFinite(milliseconds)) { throw new PresetTestError( name + " must be ISO-8601 with an explicit offset, or a Unix timestamp" ); } return milliseconds; } function numberText(value) { return String(Number(value)); } function validateTimeZone(value) { const text = String(value || "").trim(); if (!text) { throw new PresetTestError("tz must be an IANA time zone such as America/Los_Angeles"); } try { return new Intl.DateTimeFormat("en-US", { timeZone: text }) .resolvedOptions().timeZone; } catch (error) { throw new PresetTestError( "tz must be a valid IANA time zone such as America/Los_Angeles or UTC" ); } } function configFromSearch(search) { const params = new URLSearchParams(search || ""); const raw = {}; Object.keys(DEFAULTS).forEach(function (key) { raw[key] = params.has(key) ? params.get(key) : DEFAULTS[key]; }); const startMs = parseTimestamp(raw.start, "start"); const endMs = parseTimestamp(raw.end, "end"); const tz = validateTimeZone(raw.tz); const freq = strictNumber(raw.freq, "freq"); const bw = strictNumber(raw.bw, "bw"); const sf = strictInteger(raw.sf, "sf"); const cr = strictInteger(raw.cr, "cr"); const normalFreq = strictNumber(raw.normalfreq, "normalfreq"); const normalBw = strictNumber(raw.normalbw, "normalbw"); const normalSf = strictInteger(raw.normalsf, "normalsf"); const normalCr = strictInteger(raw.normalcr, "normalcr"); const tx = strictSignedNumber(raw.tx, "tx"); if (endMs <= startMs) { throw new PresetTestError("end must be later than start"); } if (freq < 150 || freq > 2500) { throw new PresetTestError("freq must be between 150 and 2500 MHz"); } if (!VALID_BANDWIDTHS.some(function (allowed) { return Math.abs(allowed - bw) < 0.01; })) { throw new PresetTestError( "bw must be one of " + VALID_BANDWIDTHS.join(", ") + " kHz" ); } if (sf < 5 || sf > 12) { throw new PresetTestError("sf must be between 5 and 12"); } if (cr < 5 || cr > 8) { throw new PresetTestError("cr must be between 5 and 8"); } if (normalFreq < 150 || normalFreq > 2500) { throw new PresetTestError("normalfreq must be between 150 and 2500 MHz"); } if (!VALID_BANDWIDTHS.some(function (allowed) { return Math.abs(allowed - normalBw) < 0.01; })) { throw new PresetTestError( "normalbw must be one of " + VALID_BANDWIDTHS.join(", ") + " kHz" ); } if (normalSf < 5 || normalSf > 12) { throw new PresetTestError("normalsf must be between 5 and 12"); } if (normalCr < 5 || normalCr > 8) { throw new PresetTestError("normalcr must be between 5 and 8"); } if (tx < -30 || tx > 60) { throw new PresetTestError("tx must be between -30 and 60 dBm"); } return Object.freeze({ startMs: startMs, endMs: endMs, startEpoch: Math.floor(startMs / 1000), endEpoch: Math.floor(endMs / 1000), tz: tz, freq: freq, bw: bw, sf: sf, cr: cr, normalFreq: normalFreq, normalBw: normalBw, normalSf: normalSf, normalCr: normalCr, tx: tx, freqText: numberText(freq), bwText: numberText(bw), normalFreqText: numberText(normalFreq), normalBwText: numberText(normalBw), txText: numberText(tx), }); } function phaseAt(config, nowMs) { if (nowMs < config.startMs) return "before"; if (nowMs < config.endMs) return "active"; return "ended"; } function remainingMinutes(config, nowMs) { return Math.max(0, Math.ceil((config.endMs - nowMs) / 60000)); } function immediateAvailable(config, nowMs) { return nowMs >= config.startMs - EARLY_JOIN_MS && nowMs < config.endMs; } function scheduleAvailability(config, nowMs) { if (nowMs >= config.startMs) { return { available: false, reason: "The start time has passed; use the immediate option." }; } if (config.endMs - nowMs > SCHEDULE_HORIZON_MS) { return { available: false, reason: "The end is outside the firmware's roughly 24-day horizon; return closer to the test.", }; } return { available: true, reason: "Ready to queue after the node clock is verified." }; } function commandsFor(config, nowMs) { const tuple = [config.freqText, config.bwText, config.sf, config.cr].join(","); const normalTuple = [ config.normalFreqText, config.normalBwText, config.normalSf, config.normalCr, ].join(","); const minutes = remainingMinutes(config, nowMs); return Object.freeze({ stockNow: "tempradio " + tuple + "," + minutes, companionNow: "set radio2.cross on\nset tempradio2 " + tuple + ",rxtx," + minutes, stockScheduled: "set tempradioat " + tuple + "," + config.startEpoch + "," + config.endEpoch + "\nget tempradioat", companionScheduled: "set radio2.cross on\nset tempradioat2 " + tuple + ",rxtx," + config.startEpoch + "," + config.endEpoch + "\nget tempradioat2", stockCancelBefore: "get tempradioat\ndel tempradioat all", stockCancelDuring: "tempradio " + normalTuple + ",1", stockLeaveIn30: "tempradio " + tuple + ",30", companionCancelBefore: "get tempradioat2\ndel tempradioat2 all\nset radio2.cross auto", companionCancelDuring: "set tempradio2 off\nset radio2.cross auto", companionLeaveIn30: "set radio2.cross on\ndel tempradioat2 all\nset tempradio2 " + tuple + ",rxtx,30", }); } function radioEstimates(config) { const requiredSnr = { 5: -2.5, 6: -5, 7: -7.5, 8: -10, 9: -12.5, 10: -15, 11: -17.5, 12: -20, }[config.sf]; const bandwidthHz = config.bw * 1000; const bitrateKbps = ( config.sf * (4 / config.cr) * bandwidthHz / Math.pow(2, config.sf) ) / 1000; const sensitivityDbm = -174 + 10 * Math.log10(bandwidthHz) + 6 + requiredSnr; return Object.freeze({ bitrateKbps: bitrateKbps, sensitivityDbm: sensitivityDbm, linkBudgetDb: config.tx - sensitivityDbm, }); } function formatCountdown(milliseconds) { let seconds = Math.max(0, Math.ceil(milliseconds / 1000)); const days = Math.floor(seconds / 86400); seconds %= 86400; const hours = Math.floor(seconds / 3600); seconds %= 3600; const minutes = Math.floor(seconds / 60); seconds %= 60; const parts = []; if (days) parts.push(days + "d"); if (days || hours) parts.push(hours + "h"); if (days || hours || minutes) parts.push(minutes + "m"); parts.push(seconds + "s"); return parts.join(" "); } function formatZoned(milliseconds, timeZone) { return new Intl.DateTimeFormat("en-US", { timeZone: timeZone, weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short", }).format(new Date(milliseconds)); } function zonedParts(milliseconds, timeZone) { const result = {}; new Intl.DateTimeFormat("en-US", { timeZone: timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", }).formatToParts(new Date(milliseconds)).forEach(function (part) { if (part.type !== "literal") result[part.type] = Number(part.value); }); return result; } function twoDigits(value) { return String(value).padStart(2, "0"); } function zonedInputValue(milliseconds, timeZone) { const parts = zonedParts(milliseconds, timeZone); return String(parts.year).padStart(4, "0") + "-" + twoDigits(parts.month) + "-" + twoDigits(parts.day) + "T" + twoDigits(parts.hour) + ":" + twoDigits(parts.minute); } function parseLocalDateTime(value, name) { const text = String(value || "").trim(); const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(text); if (!match) { throw new PresetTestError(name + " must include a date and time"); } const parts = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]), hour: Number(match[4]), minute: Number(match[5]), second: Number(match[6] || 0), }; const checked = new Date(Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second )); if (checked.getUTCFullYear() !== parts.year || checked.getUTCMonth() + 1 !== parts.month || checked.getUTCDate() !== parts.day || checked.getUTCHours() !== parts.hour || checked.getUTCMinutes() !== parts.minute || checked.getUTCSeconds() !== parts.second) { throw new PresetTestError(name + " is not a valid calendar date and time"); } return parts; } function sameDateTime(left, right) { return left.year === right.year && left.month === right.month && left.day === right.day && left.hour === right.hour && left.minute === right.minute && left.second === right.second; } function offsetAt(milliseconds, timeZone) { const parts = zonedParts(milliseconds, timeZone); const rounded = Math.floor(milliseconds / 1000) * 1000; return Date.UTC( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second ) - rounded; } function localDateTimeToMs(value, timeZone, name) { const label = name || "date and time"; const zone = validateTimeZone(timeZone); const desired = parseLocalDateTime(value, label); const wallMilliseconds = Date.UTC( desired.year, desired.month - 1, desired.day, desired.hour, desired.minute, desired.second ); const offsets = new Set(); for (let hours = -48; hours <= 48; hours += 6) { offsets.add(offsetAt(wallMilliseconds + hours * 60 * 60 * 1000, zone)); } const matches = Array.from(offsets).map(function (offset) { return wallMilliseconds - offset; }).filter(function (candidate) { return sameDateTime(zonedParts(candidate, zone), desired); }).sort(function (left, right) { return left - right; }); if (matches.length === 0) { throw new PresetTestError( label + " does not exist in " + zone + " because of a clock change" ); } if (matches.length > 1) { throw new PresetTestError( label + " is ambiguous in " + zone + " because of a clock change" ); } return matches[0]; } function configFromGenerator(values) { const tz = validateTimeZone(values.tz); const startMs = localDateTimeToMs(values.start, tz, "start"); const endMs = localDateTimeToMs(values.end, tz, "end"); const params = new URLSearchParams(); params.set("start", new Date(startMs).toISOString()); params.set("end", new Date(endMs).toISOString()); params.set("tz", tz); params.set("freq", values.freq); params.set("bw", values.bw); params.set("sf", values.sf); params.set("cr", values.cr); params.set("normalfreq", values.normalfreq); params.set("normalbw", values.normalbw); params.set("normalsf", values.normalsf); params.set("normalcr", values.normalcr); params.set("tx", values.tx); return configFromSearch("?" + params.toString()); } function formatUtc(milliseconds) { return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, timeZoneName: "short", }).format(new Date(milliseconds)); } function configuredUrl(config, baseUrl) { const url = new URL(baseUrl); url.search = ""; url.searchParams.set("start", new Date(config.startMs).toISOString()); url.searchParams.set("end", new Date(config.endMs).toISOString()); url.searchParams.set("tz", config.tz); url.searchParams.set("freq", config.freqText); url.searchParams.set("bw", config.bwText); url.searchParams.set("sf", String(config.sf)); url.searchParams.set("cr", String(config.cr)); url.searchParams.set("normalfreq", config.normalFreqText); url.searchParams.set("normalbw", config.normalBwText); url.searchParams.set("normalsf", String(config.normalSf)); url.searchParams.set("normalcr", String(config.normalCr)); url.searchParams.set("tx", config.txText); return url.toString(); } function setText(root, selector, value) { root.querySelectorAll(selector).forEach(function (element) { element.textContent = value; }); } function setCommand(root, name, value) { setText(root, '[data-command="' + name + '"]', value); root.querySelectorAll('[data-copy-command="' + name + '"]').forEach(function (button) { button.dataset.copyValue = value; }); } function setCommandEnabled(root, name, enabled) { root.querySelectorAll('[data-copy-command="' + name + '"]').forEach(function (button) { button.disabled = !enabled; }); } function setMaterialCommandCopyEnabled(root, name, enabled) { root.querySelectorAll('[data-command="' + name + '"]').forEach(function (code) { const container = code.closest(".highlight") || code.closest("pre") || code.parentElement; if (!container) return; container.querySelectorAll( 'button.md-code__button[data-md-type="copy"], button.md-clipboard' ).forEach(function (button) { button.disabled = !enabled; if (enabled) button.removeAttribute("aria-disabled"); else button.setAttribute("aria-disabled", "true"); }); }); } function fallbackCopy(text) { const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0"; document.body.appendChild(area); area.select(); document.execCommand("copy"); area.remove(); } function copyText(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); }); } fallbackCopy(text); return Promise.resolve(); } function init(root) { let config; try { config = configFromSearch(global.location ? global.location.search : ""); } catch (error) { const box = root.querySelector('[data-role="config-error"]'); box.hidden = false; box.textContent = "Invalid test URL: " + error.message; root.querySelector('[data-role="content"]').hidden = true; return; } setText(root, '[data-field="freq-display"]', config.freq.toFixed(3)); setText(root, '[data-field="bw-display"]', config.bwText); setText(root, '[data-field="sf"]', String(config.sf)); setText(root, '[data-field="cr"]', String(config.cr)); setText( root, '[data-field="normal-profile"]', [config.normalFreqText, config.normalBwText, config.normalSf, config.normalCr].join(",") ); setText(root, '[data-role="start-zoned"]', formatZoned(config.startMs, config.tz)); setText(root, '[data-role="end-zoned"]', formatZoned(config.endMs, config.tz)); setText(root, '[data-role="display-zone"]', config.tz); setText( root, '[data-role="epoch-range"]', config.startEpoch + " → " + config.endEpoch ); setText( root, '[data-role="window-summary"]', "A " + formatCountdown(config.endMs - config.startMs) + " window. Saved primary settings return automatically at the end." ); const staticCommands = commandsFor(config, config.startMs); setCommand(root, "stock-scheduled", staticCommands.stockScheduled); setCommand(root, "companion-scheduled", staticCommands.companionScheduled); setCommand(root, "stock-cancel-before", staticCommands.stockCancelBefore); setCommand(root, "stock-cancel-during", staticCommands.stockCancelDuring); setCommand(root, "stock-leave-30", staticCommands.stockLeaveIn30); setCommand(root, "companion-cancel-before", staticCommands.companionCancelBefore); setCommand(root, "companion-cancel-during", staticCommands.companionCancelDuring); setCommand(root, "companion-leave-30", staticCommands.companionLeaveIn30); setCommand(root, "reset-clock", CLOCK_RESET_COMMAND); const generator = root.querySelector('[data-role="url-generator"]'); if (generator) { generator.elements.start.value = zonedInputValue(config.startMs, config.tz); generator.elements.end.value = zonedInputValue(config.endMs, config.tz); generator.elements.tz.value = config.tz; generator.elements.freq.value = config.freqText; generator.elements.bw.value = config.bwText; generator.elements.sf.value = String(config.sf); generator.elements.cr.value = String(config.cr); generator.elements.normalfreq.value = config.normalFreqText; generator.elements.normalbw.value = config.normalBwText; generator.elements.normalsf.value = String(config.normalSf); generator.elements.normalcr.value = String(config.normalCr); generator.elements.tx.value = config.txText; const zoneList = root.querySelector("#preset-test-time-zones"); if (zoneList && typeof Intl.supportedValuesOf === "function") { Intl.supportedValuesOf("timeZone").forEach(function (zone) { if (!zoneList.querySelector('option[value="' + zone + '"]')) { const option = document.createElement("option"); option.value = zone; zoneList.appendChild(option); } }); } function generateUrl() { const errorBox = root.querySelector('[data-role="generator-error"]'); try { const generated = configFromGenerator({ start: generator.elements.start.value, end: generator.elements.end.value, tz: generator.elements.tz.value, freq: generator.elements.freq.value, bw: generator.elements.bw.value, sf: generator.elements.sf.value, cr: generator.elements.cr.value, normalfreq: generator.elements.normalfreq.value, normalbw: generator.elements.normalbw.value, normalsf: generator.elements.normalsf.value, normalcr: generator.elements.normalcr.value, tx: generator.elements.tx.value, }); const url = configuredUrl( generated, global.location ? global.location.href : "https://example.invalid/" ); setCommand(root, "generated-url", url); setCommandEnabled(root, "generated-url", true); const estimates = radioEstimates(generated); setText(root, '[data-role="estimate-rate"]', estimates.bitrateKbps.toFixed(2) + " kbps"); setText(root, '[data-role="estimate-sensitivity"]', estimates.sensitivityDbm.toFixed(1) + " dBm"); setText(root, '[data-role="estimate-budget"]', estimates.linkBudgetDb.toFixed(1) + " dB"); setText(root, '[data-role="estimate-tx"]', generated.txText + " dBm"); const openLink = root.querySelector('[data-role="open-generated-url"]'); if (openLink) { openLink.href = url; openLink.hidden = false; } if (errorBox) { errorBox.textContent = ""; errorBox.hidden = true; } } catch (error) { setCommand(root, "generated-url", "Fix the highlighted configuration error first."); setCommandEnabled(root, "generated-url", false); const openLink = root.querySelector('[data-role="open-generated-url"]'); if (openLink) openLink.hidden = true; if (errorBox) { errorBox.textContent = error.message; errorBox.hidden = false; } } } generator.addEventListener("submit", function (event) { event.preventDefault(); generateUrl(); }); generator.addEventListener("change", generateUrl); generateUrl(); } root.querySelectorAll("[data-copy-command]").forEach(function (button) { button.addEventListener("click", function () { const original = button.textContent; copyText(button.dataset.copyValue || "").then(function () { button.textContent = "Copied"; global.setTimeout(function () { button.textContent = original; }, 1600); }); }); }); function render() { const nowMs = Date.now(); const phase = phaseAt(config, nowMs); const immediateOpen = immediateAvailable(config, nowMs); const status = root.querySelector('[data-role="status"]'); const schedule = scheduleAvailability(config, nowMs); const commands = commandsFor(config, nowMs); status.dataset.state = phase; if (phase === "before") { if (immediateOpen) { status.textContent = "Setup window open"; setText(root, '[data-role="countdown-label"]', "Test starts in"); setText(root, '[data-role="countdown"]', formatCountdown(config.startMs - nowMs)); setText(root, '[data-role="countdown-detail"]', "Immediate TempRadio commands are enabled."); } else { status.textContent = "Scheduled"; setText(root, '[data-role="countdown-label"]', "Commands open in"); setText( root, '[data-role="countdown"]', formatCountdown(config.startMs - EARLY_JOIN_MS - nowMs) ); setText(root, '[data-role="countdown-detail"]', "The setup window opens one hour before the test."); } } else if (phase === "active") { status.textContent = "Test live"; setText(root, '[data-role="countdown-label"]', "Ends in"); setText(root, '[data-role="countdown"]', formatCountdown(config.endMs - nowMs)); setText(root, '[data-role="countdown-detail"]', "Temporary radios revert at zero."); } else { status.textContent = "Test finished"; setText(root, '[data-role="countdown-label"]', "Window closed"); setText(root, '[data-role="countdown"]', "0s"); setText(root, '[data-role="countdown-detail"]', "Temporary radios should be back on saved settings."); } if (immediateOpen) { setCommand(root, "stock-now", commands.stockNow); setCommand(root, "companion-now", commands.companionNow); } else { const unavailable = phase === "before" ? "Available one hour before the test — use Option 2 to schedule now." : "Test window ended — do not start TempRadio."; setCommand(root, "stock-now", unavailable); setCommand(root, "companion-now", unavailable); } setCommandEnabled(root, "stock-now", immediateOpen); setCommandEnabled(root, "companion-now", immediateOpen); setMaterialCommandCopyEnabled(root, "stock-now", immediateOpen); setMaterialCommandCopyEnabled(root, "companion-now", immediateOpen); setText( root, '[data-role="stock-now-note"]', phase === "before" && !immediateOpen ? "Available one hour before the test; schedule it now with Option 2." : phase === "before" ? "The setup window is open; the timeout includes the hour before the official start." : phase === "active" ? "The final argument is the live minutes remaining until the common end." : "The test window has ended." ); setCommandEnabled(root, "stock-scheduled", schedule.available); setCommandEnabled(root, "companion-scheduled", schedule.available); setText(root, '[data-role="stock-schedule-note"]', schedule.reason); const nowEpoch = Math.floor(nowMs / 1000); setText(root, '[data-role="browser-utc"]', formatUtc(nowMs)); setText(root, '[data-role="browser-epoch"]', String(nowEpoch)); setCommand(root, "set-clock", "time " + nowEpoch + "\nclock"); } render(); if (typeof global.MutationObserver === "function") { const materialCopyObserver = new global.MutationObserver(function () { const enabled = immediateAvailable(config, Date.now()); setMaterialCommandCopyEnabled(root, "stock-now", enabled); setMaterialCommandCopyEnabled(root, "companion-now", enabled); }); materialCopyObserver.observe(root, { childList: true, subtree: true }); } global.setInterval(render, 1000); } const api = Object.freeze({ DEFAULTS: DEFAULTS, VALID_BANDWIDTHS: VALID_BANDWIDTHS, SCHEDULE_HORIZON_MS: SCHEDULE_HORIZON_MS, EARLY_JOIN_MS: EARLY_JOIN_MS, CLOCK_RESET_COMMAND: CLOCK_RESET_COMMAND, PresetTestError: PresetTestError, configFromSearch: configFromSearch, configFromGenerator: configFromGenerator, validateTimeZone: validateTimeZone, phaseAt: phaseAt, remainingMinutes: remainingMinutes, immediateAvailable: immediateAvailable, scheduleAvailability: scheduleAvailability, commandsFor: commandsFor, radioEstimates: radioEstimates, formatCountdown: formatCountdown, formatZoned: formatZoned, zonedInputValue: zonedInputValue, localDateTimeToMs: localDateTimeToMs, configuredUrl: configuredUrl, init: init, }); if (typeof module !== "undefined" && module.exports) module.exports = api; global.MeshCorePresetTest = api; if (typeof document !== "undefined") { document.addEventListener("DOMContentLoaded", function () { document.querySelectorAll("[data-preset-test]").forEach(init); }); } })(typeof globalThis !== "undefined" ? globalThis : this);