Add temporary radio test command generator

This commit is contained in:
mikecarper committed 2026-09-16 14:15:34 -07:00
1 parent 439dc7da3c
commit e3b9ed165d
6 files changed
+966

No files matched your search

+383
View File
@@ -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);
+257
View File
@@ -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;
}
}
+1
View File
@@ -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)
+233
View File
@@ -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&amp;end=2026-09-23T17:00:00-07:00&amp;freq=910.1&amp;bw=500&amp;sf=8&amp;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.
+2
View File
@@ -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
+90
View File
@@ -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");