Add temporary radio test command generator
This commit is contained in:
1 parent
439dc7da3c
commit
e3b9ed165d
6 files changed
+966
No files matched your search
@@ -0,0 +1,383 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
start: "2026-09-21T17:00:00-07:00",
|
||||
end: "2026-09-23T17:00:00-07:00",
|
||||
freq: "910.1",
|
||||
bw: "500",
|
||||
sf: "8",
|
||||
cr: "7",
|
||||
});
|
||||
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;
|
||||
|
||||
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 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 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 freq = strictNumber(raw.freq, "freq");
|
||||
const bw = strictNumber(raw.bw, "bw");
|
||||
const sf = strictInteger(raw.sf, "sf");
|
||||
const cr = strictInteger(raw.cr, "cr");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
startMs: startMs,
|
||||
endMs: endMs,
|
||||
startEpoch: Math.floor(startMs / 1000),
|
||||
endEpoch: Math.floor(endMs / 1000),
|
||||
freq: freq,
|
||||
bw: bw,
|
||||
sf: sf,
|
||||
cr: cr,
|
||||
freqText: numberText(freq),
|
||||
bwText: numberText(bw),
|
||||
});
|
||||
}
|
||||
|
||||
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 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 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: "normalradio",
|
||||
companionCancelBefore:
|
||||
"get tempradioat2\ndel tempradioat2 all\nset radio2.cross auto",
|
||||
companionCancelDuring: "set tempradio2 off\nset radio2.cross auto",
|
||||
});
|
||||
}
|
||||
|
||||
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 formatPacific(milliseconds) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/Los_Angeles",
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}).format(new Date(milliseconds));
|
||||
}
|
||||
|
||||
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("freq", config.freqText);
|
||||
url.searchParams.set("bw", config.bwText);
|
||||
url.searchParams.set("sf", String(config.sf));
|
||||
url.searchParams.set("cr", String(config.cr));
|
||||
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 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-role="start-pacific"]', formatPacific(config.startMs));
|
||||
setText(root, '[data-role="end-pacific"]', formatPacific(config.endMs));
|
||||
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, "companion-cancel-before", staticCommands.companionCancelBefore);
|
||||
setCommand(root, "companion-cancel-during", staticCommands.companionCancelDuring);
|
||||
setCommand(
|
||||
root,
|
||||
"share-url",
|
||||
configuredUrl(config, global.location ? global.location.href : "https://example.invalid/")
|
||||
);
|
||||
|
||||
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 status = root.querySelector('[data-role="status"]');
|
||||
const schedule = scheduleAvailability(config, nowMs);
|
||||
const commands = commandsFor(config, nowMs);
|
||||
|
||||
status.dataset.state = phase;
|
||||
if (phase === "before") {
|
||||
status.textContent = "Scheduled";
|
||||
setText(root, '[data-role="countdown-label"]', "Starts in");
|
||||
setText(root, '[data-role="countdown"]', formatCountdown(config.startMs - nowMs));
|
||||
setText(root, '[data-role="countdown-detail"]', "Do not use immediate TempRadio yet.");
|
||||
} 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.");
|
||||
}
|
||||
|
||||
setCommand(root, "stock-now", commands.stockNow);
|
||||
setCommand(root, "companion-now", commands.companionNow);
|
||||
setCommandEnabled(root, "stock-now", phase === "active");
|
||||
setCommandEnabled(root, "companion-now", phase === "active");
|
||||
setText(
|
||||
root,
|
||||
'[data-role="stock-now-note"]',
|
||||
phase === "before"
|
||||
? "Available when the test starts; schedule it now with Option 2."
|
||||
: 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();
|
||||
global.setInterval(render, 1000);
|
||||
}
|
||||
|
||||
const api = Object.freeze({
|
||||
DEFAULTS: DEFAULTS,
|
||||
VALID_BANDWIDTHS: VALID_BANDWIDTHS,
|
||||
SCHEDULE_HORIZON_MS: SCHEDULE_HORIZON_MS,
|
||||
PresetTestError: PresetTestError,
|
||||
configFromSearch: configFromSearch,
|
||||
phaseAt: phaseAt,
|
||||
remainingMinutes: remainingMinutes,
|
||||
scheduleAvailability: scheduleAvailability,
|
||||
commandsFor: commandsFor,
|
||||
formatCountdown: formatCountdown,
|
||||
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);
|
||||
@@ -0,0 +1,257 @@
|
||||
.preset-test {
|
||||
--preset-accent: #b83280;
|
||||
--preset-accent-dark: #8f245f;
|
||||
--preset-cyan: #087f8c;
|
||||
--preset-border: color-mix(in srgb, currentColor 18%, transparent);
|
||||
margin: 1.25rem 0 2rem;
|
||||
}
|
||||
|
||||
.preset-test section {
|
||||
margin: 1.4rem 0;
|
||||
}
|
||||
|
||||
.preset-test-hero {
|
||||
border: 1px solid var(--preset-border);
|
||||
border-top: 0.35rem solid var(--preset-accent);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.1rem 1.2rem 1.2rem;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--preset-accent) 9%, transparent), transparent 55%),
|
||||
var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.preset-test-eyebrow {
|
||||
margin: 0 0 0.45rem;
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preset-test-hero-row,
|
||||
.preset-test-card-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preset-test-hero h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 4vw, 2.15rem);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.preset-test-hero-row p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preset-test-status,
|
||||
.preset-test-card-heading > span {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.55rem;
|
||||
background: color-mix(in srgb, var(--preset-cyan) 12%, transparent);
|
||||
color: var(--preset-cyan);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preset-test-status[data-state="active"] {
|
||||
background: color-mix(in srgb, #1b8f4b 14%, transparent);
|
||||
color: #168143;
|
||||
}
|
||||
|
||||
.preset-test-status[data-state="ended"] {
|
||||
background: color-mix(in srgb, currentColor 9%, transparent);
|
||||
color: var(--md-default-fg-color--light);
|
||||
}
|
||||
|
||||
.preset-test-clock {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.1rem 0.8rem;
|
||||
align-items: baseline;
|
||||
margin: 1.2rem 0;
|
||||
padding: 0.8rem 0;
|
||||
border-block: 1px solid var(--preset-border);
|
||||
}
|
||||
|
||||
.preset-test-clock-label {
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preset-test-clock strong {
|
||||
color: var(--preset-accent);
|
||||
font-family: var(--md-code-font-family);
|
||||
font-size: clamp(1.35rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.preset-test-clock > span:last-child {
|
||||
grid-column: 2;
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.preset-test-window {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preset-test-window div,
|
||||
.preset-test-clock-check > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preset-test-window dt,
|
||||
.preset-test-clock-check span {
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preset-test-window dd {
|
||||
margin: 0.18rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preset-test-warning,
|
||||
.preset-test-error {
|
||||
border-left: 0.32rem solid #d97706;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: color-mix(in srgb, #d97706 9%, transparent);
|
||||
}
|
||||
|
||||
.preset-test-warning h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.preset-test-warning p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preset-test-error {
|
||||
color: var(--md-code-hl-number-color);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.preset-test-grid,
|
||||
.preset-test-cancel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preset-test-cancel-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
}
|
||||
|
||||
.preset-test-card {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
background: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.preset-test-card h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.preset-test-card-heading h3 {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.preset-test-card pre,
|
||||
.preset-test > [data-role="content"] > section > pre {
|
||||
margin: 0.8rem 0;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.preset-test-card pre code,
|
||||
.preset-test > [data-role="content"] > section > pre code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preset-test button {
|
||||
border: 0;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--preset-accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.preset-test button:hover:not(:disabled),
|
||||
.preset-test button:focus-visible:not(:disabled) {
|
||||
background: var(--preset-accent-dark);
|
||||
}
|
||||
|
||||
.preset-test button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.preset-test-note {
|
||||
margin-bottom: 0;
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.preset-test-clock-check {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.preset-test-clock-check > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.preset-test-clock-check strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--md-code-font-family);
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.preset-test-grid,
|
||||
.preset-test-grid--clock,
|
||||
.preset-test-clock-check {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preset-test-hero-row {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.preset-test-status {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.preset-test-clock {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preset-test-clock > span:last-child {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ Below are a few quick start guides.
|
||||
- [Full Companion feature switches](./full_companion_features.md)
|
||||
- [Frequently Asked Questions](./faq.md)
|
||||
- [Firmware Picker](./firmware_picker.md)
|
||||
- [Temporary Radio Test Command Generator](./preset_test.md)
|
||||
- [CLI Commands](./cli_commands.md)
|
||||
- [LoRa CLI Host Service](./host_cli_service.md)
|
||||
- [Filter Policy Playground](./filter_tool.md)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# Temporary radio test command generator
|
||||
|
||||
Use this page to join the shared temporary radio test without overwriting the
|
||||
node's saved primary radio settings. The default window is **Monday,
|
||||
September 21, 2026 at 5:00 PM through Wednesday, September 23 at 5:00 PM
|
||||
Pacific time** (48 hours), using **910.1 MHz, 500 kHz, SF8, CR7**.
|
||||
|
||||
The page reads the complete test definition from its URL. Change the query
|
||||
parameters to reuse it for a different window or radio tuple; no source edit is
|
||||
required.
|
||||
|
||||
<div class="preset-test" data-preset-test>
|
||||
<div class="preset-test-error" data-role="config-error" role="alert" hidden></div>
|
||||
|
||||
<div data-role="content">
|
||||
<section class="preset-test-hero" aria-labelledby="preset-test-title">
|
||||
<p class="preset-test-eyebrow">MeshCore · temporary preset test</p>
|
||||
<div class="preset-test-hero-row">
|
||||
<div>
|
||||
<h2 id="preset-test-title">
|
||||
<span data-field="freq-display">910.100</span> MHz /
|
||||
<span data-field="bw-display">500</span> kHz /
|
||||
SF<span data-field="sf">8</span> /
|
||||
CR<span data-field="cr">7</span>
|
||||
</h2>
|
||||
<p data-role="window-summary"></p>
|
||||
</div>
|
||||
<span class="preset-test-status" data-role="status" data-state="before">Scheduled</span>
|
||||
</div>
|
||||
|
||||
<div class="preset-test-clock" aria-live="polite">
|
||||
<span class="preset-test-clock-label" data-role="countdown-label">Starts in</span>
|
||||
<strong data-role="countdown">—</strong>
|
||||
<span data-role="countdown-detail">—</span>
|
||||
</div>
|
||||
|
||||
<dl class="preset-test-window">
|
||||
<div><dt>Start</dt><dd data-role="start-pacific"></dd></div>
|
||||
<div><dt>End</dt><dd data-role="end-pacific"></dd></div>
|
||||
<div><dt>Firmware epochs</dt><dd><code data-role="epoch-range"></code></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="preset-test-warning" aria-labelledby="save-settings-title">
|
||||
<h2 id="save-settings-title">Before changing a radio</h2>
|
||||
<p>
|
||||
Run <code>get radio</code> and record the saved primary tuple. Both
|
||||
primary TempRadio methods return to that saved tuple; they do not store
|
||||
a return tuple inside the temporary command. On a Companion, also run
|
||||
<code>get radio2</code> and <code>get radio2.cross</code> so you can restore
|
||||
any non-default secondary-profile setup.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="clock-check-title">
|
||||
<h2 id="clock-check-title">Check the node clock before scheduling</h2>
|
||||
<p>
|
||||
<code>tempradioat</code> and <code>tempradioat2</code> use UTC Unix time.
|
||||
Run <code>clock</code> on every node and compare it with the browser UTC
|
||||
time below. It should agree to within about a minute. A wrong clock can
|
||||
start late, start immediately, or cause the schedule to be rejected.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-clock-check">
|
||||
<div>
|
||||
<span>Browser UTC now</span>
|
||||
<strong data-role="browser-utc">—</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Browser Unix time</span>
|
||||
<strong data-role="browser-epoch">—</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preset-test-grid preset-test-grid--clock">
|
||||
<article class="preset-test-card">
|
||||
<h3>Remote admin session</h3>
|
||||
<p>
|
||||
In a MeshCore client that supplies the sender timestamp, sync and
|
||||
then verify:
|
||||
</p>
|
||||
<pre><code>clock sync
|
||||
clock</code></pre>
|
||||
</article>
|
||||
|
||||
<article class="preset-test-card">
|
||||
<h3>Local USB or browser console</h3>
|
||||
<p>Copy this fresh, run it immediately, and then verify with <code>clock</code>:</p>
|
||||
<pre><code data-command="set-clock">time 0
|
||||
clock</code></pre>
|
||||
<button type="button" data-copy-command="set-clock">Copy current clock command</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p class="preset-test-note">
|
||||
The schedule must be queued while both its start and end are in the
|
||||
future and within the firmware's roughly 24-day scheduling horizon.
|
||||
Scheduled entries are held in RAM and disappear if the node reboots.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="join-now-title">
|
||||
<h2 id="join-now-title">Option 1: switch when the test begins</h2>
|
||||
<p>
|
||||
Open this page after the start time. Its timeout shrinks so every node
|
||||
returns at the same end time. These copy buttons remain disabled before
|
||||
the window to prevent an early switch.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-grid">
|
||||
<article class="preset-test-card">
|
||||
<div class="preset-test-card-heading">
|
||||
<h3>Stock repeater, room server, or sensor</h3>
|
||||
<span>Primary radio</span>
|
||||
</div>
|
||||
<pre><code data-command="stock-now"></code></pre>
|
||||
<button type="button" data-copy-command="stock-now">Copy command</button>
|
||||
<p class="preset-test-note" data-role="stock-now-note"></p>
|
||||
</article>
|
||||
|
||||
<article class="preset-test-card">
|
||||
<div class="preset-test-card-heading">
|
||||
<h3>Companion using both frequencies</h3>
|
||||
<span>Dual profile</span>
|
||||
</div>
|
||||
<pre><code data-command="companion-now"></code></pre>
|
||||
<button type="button" data-copy-command="companion-now">Copy commands</button>
|
||||
<p class="preset-test-note">
|
||||
<code>rxtx</code> permits transmission on the second profile;
|
||||
<code>radio2.cross on</code> copies ordinary Companion traffic across
|
||||
the primary and temporary profiles.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="schedule-title">
|
||||
<h2 id="schedule-title">Option 2: schedule it in advance</h2>
|
||||
<p>
|
||||
Use this only after checking the clock above. The exact UTC epochs are
|
||||
built into the commands, so the node switches at the common start and
|
||||
restores its saved configuration at the common end.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-grid">
|
||||
<article class="preset-test-card">
|
||||
<div class="preset-test-card-heading">
|
||||
<h3>Stock repeater, room server, or sensor</h3>
|
||||
<span>Primary schedule</span>
|
||||
</div>
|
||||
<pre><code data-command="stock-scheduled"></code></pre>
|
||||
<button type="button" data-copy-command="stock-scheduled">Copy schedule commands</button>
|
||||
<p class="preset-test-note" data-role="stock-schedule-note"></p>
|
||||
</article>
|
||||
|
||||
<article class="preset-test-card">
|
||||
<div class="preset-test-card-heading">
|
||||
<h3>Companion using both frequencies</h3>
|
||||
<span>Dual-profile schedule</span>
|
||||
</div>
|
||||
<pre><code data-command="companion-scheduled"></code></pre>
|
||||
<button type="button" data-copy-command="companion-scheduled">Copy schedule commands</button>
|
||||
<p class="preset-test-note">
|
||||
Crossing is saved independently and remains <code>on</code> after the
|
||||
temporary second profile ends. Restore its previous value after the
|
||||
test; <code>auto</code> is the normal default.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="cancel-title">
|
||||
<h2 id="cancel-title">Cancel or leave the test</h2>
|
||||
<p>
|
||||
Use the command for the node type and timing. The <code>all</code> forms
|
||||
remove every temporary schedule in that family. To preserve another
|
||||
schedule, first run the corresponding <code>get</code> command and replace
|
||||
<code>all</code> with its displayed entry number.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-cancel-grid">
|
||||
<article class="preset-test-card">
|
||||
<h3>Stock · before it starts</h3>
|
||||
<pre><code data-command="stock-cancel-before"></code></pre>
|
||||
<button type="button" data-copy-command="stock-cancel-before">Copy cancel commands</button>
|
||||
</article>
|
||||
|
||||
<article class="preset-test-card">
|
||||
<h3>Stock · while it is running</h3>
|
||||
<pre><code data-command="stock-cancel-during"></code></pre>
|
||||
<button type="button" data-copy-command="stock-cancel-during">Copy restore command</button>
|
||||
<p class="preset-test-note">
|
||||
<code>normalradio</code> cancels pending and active primary TempRadio
|
||||
windows and restores the saved primary tuple after its reply drains.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="preset-test-card">
|
||||
<h3>Companion · before it starts</h3>
|
||||
<pre><code data-command="companion-cancel-before"></code></pre>
|
||||
<button type="button" data-copy-command="companion-cancel-before">Copy cancel commands</button>
|
||||
</article>
|
||||
|
||||
<article class="preset-test-card">
|
||||
<h3>Companion · while it is running</h3>
|
||||
<pre><code data-command="companion-cancel-during"></code></pre>
|
||||
<button type="button" data-copy-command="companion-cancel-during">Copy restore commands</button>
|
||||
<p class="preset-test-note">
|
||||
If <code>get radio2.cross</code> was not <code>auto</code> before the
|
||||
test, restore that recorded value instead.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="share-title">
|
||||
<h2 id="share-title">Reuse this page for another test</h2>
|
||||
<p>
|
||||
Set <code>start</code> and <code>end</code> to ISO-8601 timestamps with an
|
||||
explicit UTC offset, or to Unix epoch seconds. Set <code>freq</code>,
|
||||
<code>bw</code>, <code>sf</code>, and <code>cr</code> to the desired radio
|
||||
tuple. The current configuration link is normalized to UTC.
|
||||
</p>
|
||||
<pre><code>?start=2026-09-21T17:00:00-07:00&end=2026-09-23T17:00:00-07:00&freq=910.1&bw=500&sf=8&cr=7</code></pre>
|
||||
<button type="button" data-copy-command="share-url">Copy configured page URL</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
The scheduled commands require current full-parser firmware. Companion
|
||||
`tempradio2` and `tempradioat2` require a build with dual-radio-profile support.
|
||||
If a node reports an unknown command, update it or use only a method that its
|
||||
installed firmware documents.
|
||||
@@ -20,8 +20,10 @@ extra_css:
|
||||
- _stylesheets/firmware_picker.css
|
||||
- _stylesheets/telemetry_decoder.css
|
||||
- _stylesheets/filter_tool.css
|
||||
- _stylesheets/preset_test.css
|
||||
|
||||
extra_javascript:
|
||||
- _javascript/firmware_picker.js
|
||||
- _javascript/telemetry_decoder.js
|
||||
- _javascript/filter_tool.js
|
||||
- _javascript/preset_test.js
|
||||
@@ -0,0 +1,90 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const tool = require("../docs/_javascript/preset_test.js");
|
||||
|
||||
const query =
|
||||
"?start=2026-09-21T17:00:00-07:00" +
|
||||
"&end=2026-09-23T17:00:00-07:00" +
|
||||
"&freq=910.1&bw=500&sf=8&cr=7";
|
||||
const config = tool.configFromSearch(query);
|
||||
const defaults = tool.configFromSearch("");
|
||||
|
||||
assert.strictEqual(config.startEpoch, 1790035200);
|
||||
assert.strictEqual(config.endEpoch, 1790208000);
|
||||
assert.strictEqual(config.endEpoch - config.startEpoch, 48 * 60 * 60);
|
||||
assert.strictEqual(config.freq, 910.1);
|
||||
assert.strictEqual(config.sf, 8);
|
||||
assert.strictEqual(config.cr, 7);
|
||||
assert.strictEqual(defaults.startEpoch, config.startEpoch);
|
||||
assert.strictEqual(defaults.endEpoch, config.endEpoch);
|
||||
assert.strictEqual(defaults.freq, config.freq);
|
||||
|
||||
const before = Date.parse("2026-09-21T16:59:00-07:00");
|
||||
const active = Date.parse("2026-09-21T17:00:30-07:00");
|
||||
const ended = Date.parse("2026-09-23T17:00:00-07:00");
|
||||
assert.strictEqual(tool.phaseAt(config, before), "before");
|
||||
assert.strictEqual(tool.phaseAt(config, active), "active");
|
||||
assert.strictEqual(tool.phaseAt(config, ended), "ended");
|
||||
assert.strictEqual(tool.remainingMinutes(config, active), 2880);
|
||||
|
||||
const commands = tool.commandsFor(config, active);
|
||||
assert.strictEqual(commands.stockNow, "tempradio 910.1,500,8,7,2880");
|
||||
assert.strictEqual(
|
||||
commands.stockScheduled,
|
||||
"set tempradioat 910.1,500,8,7,1790035200,1790208000\nget tempradioat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
commands.companionNow,
|
||||
"set radio2.cross on\nset tempradio2 910.1,500,8,7,rxtx,2880"
|
||||
);
|
||||
assert.strictEqual(
|
||||
commands.companionScheduled,
|
||||
"set radio2.cross on\n" +
|
||||
"set tempradioat2 910.1,500,8,7,rxtx,1790035200,1790208000\n" +
|
||||
"get tempradioat2"
|
||||
);
|
||||
|
||||
assert.strictEqual(tool.scheduleAvailability(config, before).available, true);
|
||||
assert.strictEqual(tool.scheduleAvailability(config, active).available, false);
|
||||
assert.strictEqual(
|
||||
tool.scheduleAvailability(config, config.endMs - tool.SCHEDULE_HORIZON_MS - 1).available,
|
||||
false
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => tool.configFromSearch("?start=bad"),
|
||||
/start must include an explicit UTC offset/
|
||||
);
|
||||
assert.throws(
|
||||
() => tool.configFromSearch("?start=2026-09-21T17:00:00"),
|
||||
/start must include an explicit UTC offset/
|
||||
);
|
||||
assert.throws(
|
||||
() => tool.configFromSearch("?bw=123"),
|
||||
/bw must be one of/
|
||||
);
|
||||
assert.throws(
|
||||
() => tool.configFromSearch("?start=1790208000&end=1790035200"),
|
||||
/end must be later/
|
||||
);
|
||||
|
||||
const changed = tool.configFromSearch(
|
||||
"?start=1790035200&end=1790208000&freq=915.25&bw=125&sf=10&cr=5"
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.commandsFor(changed, changed.startMs).stockNow,
|
||||
"tempradio 915.25,125,10,5,2880"
|
||||
);
|
||||
|
||||
const shared = new URL(tool.configuredUrl(
|
||||
changed,
|
||||
"https://example.test/preset-test/?stale=yes#commands"
|
||||
));
|
||||
assert.strictEqual(shared.searchParams.get("start"), "2026-09-22T00:00:00.000Z");
|
||||
assert.strictEqual(shared.searchParams.get("end"), "2026-09-24T00:00:00.000Z");
|
||||
assert.strictEqual(shared.searchParams.get("freq"), "915.25");
|
||||
assert.strictEqual(shared.searchParams.has("stale"), false);
|
||||
assert.strictEqual(shared.hash, "#commands");
|
||||
|
||||
process.stdout.write("preset test command generator checks passed\n");
|
||||
Reference in new issue
Block a user