Add reusable radio test link builder

This commit is contained in:
mikecarper committed 2026-09-16 14:52:34 -07:00
1 parent 1818e60387
commit d61b287ddc
4 files changed
+590 -21

No files matched your search

+273 -9
View File
@@ -4,10 +4,12 @@
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",
tx: "22",
});
const VALID_BANDWIDTHS = Object.freeze([
7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500,
@@ -30,6 +32,18 @@
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)) {
@@ -65,6 +79,21 @@
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 = {};
@@ -74,10 +103,12 @@
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 tx = strictSignedNumber(raw.tx, "tx");
if (endMs <= startMs) {
throw new PresetTestError("end must be later than start");
@@ -98,18 +129,24 @@
if (cr < 5 || cr > 8) {
throw new PresetTestError("cr 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,
tx: tx,
freqText: numberText(freq),
bwText: numberText(bw),
txText: numberText(tx),
});
}
@@ -155,9 +192,36 @@
config.startEpoch + "," + config.endEpoch + "\nget tempradioat2",
stockCancelBefore: "get tempradioat\ndel tempradioat all",
stockCancelDuring: "normalradio",
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,
});
}
@@ -177,9 +241,9 @@
return parts.join(" ");
}
function formatPacific(milliseconds) {
function formatZoned(milliseconds, timeZone) {
return new Intl.DateTimeFormat("en-US", {
timeZone: "America/Los_Angeles",
timeZone: timeZone,
weekday: "long",
year: "numeric",
month: "long",
@@ -190,6 +254,125 @@
}).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("tx", values.tx);
return configFromSearch("?" + params.toString());
}
function formatUtc(milliseconds) {
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
@@ -209,10 +392,12 @@
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("tx", config.txText);
return url.toString();
}
@@ -287,8 +472,9 @@
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="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"]',
@@ -306,14 +492,86 @@
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);
setCommand(
root,
"share-url",
configuredUrl(config, global.location ? global.location.href : "https://example.invalid/")
);
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.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,
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 () {
@@ -418,12 +676,18 @@
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,
});
+130 -1
View File
@@ -244,10 +244,139 @@
font-family: var(--md-code-font-family);
}
.preset-test-generator-layout {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(15rem, 2fr);
gap: 1rem;
align-items: start;
}
.preset-test-generator,
.preset-test-estimates {
min-width: 0;
border: 1px solid var(--preset-border);
border-radius: 0.65rem;
padding: 1rem;
background: var(--md-default-bg-color);
}
.preset-test-generator-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.preset-test-generator label {
display: grid;
align-content: start;
gap: 0.25rem;
min-width: 0;
}
.preset-test-generator label > span {
font-size: 0.78rem;
font-weight: 700;
}
.preset-test-generator input,
.preset-test-generator select {
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 1px solid var(--preset-border);
border-radius: 0.3rem;
padding: 0.52rem 0.58rem;
background: var(--md-default-bg-color);
color: var(--md-default-fg-color);
font: inherit;
}
.preset-test-generator input:focus,
.preset-test-generator select:focus {
border-color: var(--preset-cyan);
outline: 2px solid color-mix(in srgb, var(--preset-cyan) 24%, transparent);
outline-offset: 1px;
}
.preset-test-generator small {
color: var(--md-default-fg-color--light);
font-size: 0.68rem;
line-height: 1.35;
}
.preset-test-generator > button {
margin-top: 0.9rem;
}
.preset-test-estimates h3 {
margin-top: 0;
}
.preset-test-estimates dl {
display: grid;
gap: 0.7rem;
margin: 0 0 1rem;
}
.preset-test-estimates dl > div {
display: flex;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--preset-border);
padding-bottom: 0.55rem;
}
.preset-test-estimates dt {
color: var(--md-default-fg-color--light);
font-size: 0.74rem;
font-weight: 700;
}
.preset-test-estimates dd {
margin: 0;
color: var(--preset-accent);
font-family: var(--md-code-font-family);
font-weight: 750;
text-align: right;
}
.preset-test-generator-error {
margin-top: 1rem;
}
.preset-test-generated-url code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.preset-test-generator-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
.preset-test-generator-actions a {
border: 1px solid var(--preset-accent);
border-radius: 0.25rem;
padding: 0.48rem 0.8rem;
color: var(--preset-accent);
font-size: 0.78rem;
font-weight: 750;
text-decoration: none;
}
.preset-test-generator-actions a:hover,
.preset-test-generator-actions a:focus-visible {
background: color-mix(in srgb, var(--preset-accent) 10%, transparent);
}
@media (max-width: 44rem) {
.preset-test-grid,
.preset-test-grid--clock,
.preset-test-clock-check {
.preset-test-clock-check,
.preset-test-generator-layout,
.preset-test-generator-fields {
grid-template-columns: 1fr;
}
+127 -11
View File
@@ -6,8 +6,8 @@ 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.
parameters to reuse it for a different window, display time zone, 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>
@@ -35,8 +35,9 @@ required.
</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>Start</dt><dd data-role="start-zoned"></dd></div>
<div><dt>End</dt><dd data-role="end-zoned"></dd></div>
<div><dt>Display time zone</dt><dd data-role="display-zone"></dd></div>
<div><dt>Firmware epochs</dt><dd><code data-role="epoch-range"></code></dd></div>
</dl>
</section>
@@ -211,12 +212,20 @@ clock</code></pre>
<article class="preset-test-card">
<h3>Stock · while it is running</h3>
<h4>Leave now</h4>
<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>
<h4>Leave in 30 minutes</h4>
<pre><code data-command="stock-leave-30"></code></pre>
<button type="button" data-copy-command="stock-leave-30">Copy 30-minute command</button>
<p class="preset-test-note">
This replaces the current temporary timeout with 30 minutes, then
restores the saved primary tuple automatically.
</p>
</article>
<article class="preset-test-card">
@@ -227,26 +236,133 @@ clock</code></pre>
<article class="preset-test-card">
<h3>Companion · while it is running</h3>
<h4>Leave now</h4>
<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>
<h4>Leave in 30 minutes</h4>
<pre><code data-command="companion-leave-30"></code></pre>
<button type="button" data-copy-command="companion-leave-30">Copy 30-minute commands</button>
<p class="preset-test-note">
These commands clear any absolute second-profile schedule and give
the test profile a fresh 30-minute lease. The saved second profile
returns at expiry, but crossing remains <code>on</code>; afterward,
restore the value you recorded before the test (<code>auto</code> is
the normal default).
</p>
</article>
</div>
</section>
<section aria-labelledby="share-title">
<h2 id="share-title">Reuse this page for another test</h2>
<h2 id="share-title">Build a link 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.
Enter the test times in the selected time zone and choose the radio
tuple. The generated URL converts the times to exact UTC instants and
keeps <code>tz</code> so the page displays them in the organizer's local
time zone.
</p>
<div class="preset-test-generator-layout">
<form class="preset-test-generator" data-role="url-generator">
<div class="preset-test-generator-fields">
<label>
<span>Start date and time</span>
<input type="datetime-local" name="start" step="60" required>
</label>
<label>
<span>End date and time</span>
<input type="datetime-local" name="end" step="60" required>
</label>
<label>
<span>Time zone</span>
<input type="text" name="tz" list="preset-test-time-zones" required
autocomplete="off" spellcheck="false">
<small>Use an IANA name such as America/Los_Angeles or UTC.</small>
</label>
<label>
<span>Frequency (MHz)</span>
<input type="number" name="freq" min="150" max="2500" step="0.001" required>
</label>
<label>
<span>Bandwidth (kHz)</span>
<select name="bw" required>
<option>7.8</option><option>10.4</option><option>15.6</option>
<option>20.8</option><option>31.25</option><option>41.7</option>
<option>62.5</option><option>125</option><option>250</option><option>500</option>
</select>
</label>
<label>
<span>Spreading factor</span>
<select name="sf" required>
<option>5</option><option>6</option><option>7</option><option>8</option>
<option>9</option><option>10</option><option>11</option><option>12</option>
</select>
</label>
<label>
<span>Coding-rate denominator</span>
<select name="cr" required>
<option>5</option><option>6</option><option>7</option><option>8</option>
</select>
</label>
<label>
<span>TX output for estimate (dBm)</span>
<input type="number" name="tx" min="-30" max="60" step="0.1" required>
<small>This estimate-only value does not change the TempRadio commands.</small>
</label>
</div>
<button type="submit">Generate test URL</button>
</form>
<aside class="preset-test-estimates" aria-labelledby="radio-estimates-title">
<h3 id="radio-estimates-title">Radio estimates</h3>
<dl>
<div><dt>Nominal LoRa bitrate</dt><dd data-role="estimate-rate">—</dd></div>
<div><dt>Estimated sensitivity</dt><dd data-role="estimate-sensitivity">—</dd></div>
<div><dt>TX output used</dt><dd data-role="estimate-tx">—</dd></div>
<div><dt>Estimated link budget</dt><dd data-role="estimate-budget">—</dd></div>
</dl>
<p class="preset-test-note">
The bitrate is the nominal LoRa physical-layer rate; usable payload
throughput is lower. Sensitivity assumes a 6 dB receiver noise figure
and the standard LoRa SNR threshold for the selected spreading factor.
Link budget is TX output minus that sensitivity, before antenna gain,
cable loss, path loss, interference, and implementation differences.
</p>
</aside>
</div>
<datalist id="preset-test-time-zones">
<option value="America/Los_Angeles"></option>
<option value="America/Denver"></option>
<option value="America/Chicago"></option>
<option value="America/New_York"></option>
<option value="America/Phoenix"></option>
<option value="Pacific/Honolulu"></option>
<option value="UTC"></option>
<option value="Europe/London"></option>
<option value="Europe/Berlin"></option>
<option value="Asia/Tokyo"></option>
<option value="Australia/Sydney"></option>
</datalist>
<p class="preset-test-error preset-test-generator-error"
data-role="generator-error" role="alert" hidden></p>
<pre class="preset-test-generated-url"><code data-command="generated-url"></code></pre>
<div class="preset-test-generator-actions">
<button type="button" data-copy-command="generated-url">Copy generated URL</button>
<a data-role="open-generated-url" target="_blank" rel="noopener">Open generated page</a>
</div>
<p class="preset-test-note">
Advanced use: <code>start</code> and <code>end</code> also accept ISO-8601
timestamps with explicit UTC offsets or Unix epoch seconds. The other URL
parameters are <code>tz</code>, <code>freq</code>, <code>bw</code>,
<code>sf</code>, <code>cr</code>, and estimate-only <code>tx</code>.
</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>
+60
View File
@@ -16,9 +16,12 @@ 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(config.tz, "America/Los_Angeles");
assert.strictEqual(config.tx, 22);
assert.strictEqual(defaults.startEpoch, config.startEpoch);
assert.strictEqual(defaults.endEpoch, config.endEpoch);
assert.strictEqual(defaults.freq, config.freq);
assert.strictEqual(defaults.tz, config.tz);
const before = Date.parse("2026-09-21T16:59:00-07:00");
const beforeSetup = Date.parse("2026-09-21T15:59:59-07:00");
@@ -54,6 +57,13 @@ assert.strictEqual(
"set tempradioat2 910.1,500,8,7,rxtx,1790035200,1790208000\n" +
"get tempradioat2"
);
assert.strictEqual(commands.stockLeaveIn30, "tempradio 910.1,500,8,7,30");
assert.strictEqual(
commands.companionLeaveIn30,
"set radio2.cross on\n" +
"del tempradioat2 all\n" +
"set tempradio2 910.1,500,8,7,rxtx,30"
);
assert.strictEqual(tool.scheduleAvailability(config, before).available, true);
assert.strictEqual(tool.scheduleAvailability(config, active).available, false);
@@ -62,6 +72,46 @@ assert.strictEqual(
false
);
assert.strictEqual(
tool.zonedInputValue(config.startMs, "America/Los_Angeles"),
"2026-09-21T17:00"
);
assert.strictEqual(
tool.zonedInputValue(config.startMs, "America/New_York"),
"2026-09-21T20:00"
);
assert.strictEqual(
tool.localDateTimeToMs("2026-09-21T17:00", "America/Los_Angeles"),
config.startMs
);
assert.throws(
() => tool.localDateTimeToMs("2026-03-08T02:30", "America/Los_Angeles"),
/does not exist/
);
assert.throws(
() => tool.localDateTimeToMs("2026-11-01T01:30", "America/Los_Angeles"),
/ambiguous/
);
const generated = tool.configFromGenerator({
start: "2026-09-21T17:00",
end: "2026-09-23T17:00",
tz: "America/Los_Angeles",
freq: "910.1",
bw: "500",
sf: "8",
cr: "7",
tx: "22",
});
assert.strictEqual(generated.startEpoch, config.startEpoch);
assert.strictEqual(generated.endEpoch, config.endEpoch);
assert.strictEqual(generated.tz, "America/Los_Angeles");
const estimates = tool.radioEstimates(config);
assert.ok(Math.abs(estimates.bitrateKbps - 8.9285714286) < 0.000001);
assert.ok(Math.abs(estimates.sensitivityDbm - (-121.0102999566)) < 0.000001);
assert.ok(Math.abs(estimates.linkBudgetDb - 143.0102999566) < 0.000001);
assert.throws(
() => tool.configFromSearch("?start=bad"),
/start must include an explicit UTC offset/
@@ -78,6 +128,14 @@ assert.throws(
() => tool.configFromSearch("?start=1790208000&end=1790035200"),
/end must be later/
);
assert.throws(
() => tool.configFromSearch("?tz=Moon%2FTranquility"),
/tz must be a valid IANA time zone/
);
assert.throws(
() => tool.configFromSearch("?tx=61"),
/tx must be between/
);
const changed = tool.configFromSearch(
"?start=1790035200&end=1790208000&freq=915.25&bw=125&sf=10&cr=5"
@@ -94,6 +152,8 @@ const shared = new URL(tool.configuredUrl(
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.get("tz"), "America/Los_Angeles");
assert.strictEqual(shared.searchParams.get("tx"), "22");
assert.strictEqual(shared.searchParams.has("stale"), false);
assert.strictEqual(shared.hash, "#commands");