diff --git a/docs/_javascript/preset_test.js b/docs/_javascript/preset_test.js
index e059e1b8..5de9023c 100644
--- a/docs/_javascript/preset_test.js
+++ b/docs/_javascript/preset_test.js
@@ -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,
});
diff --git a/docs/_stylesheets/preset_test.css b/docs/_stylesheets/preset_test.css
index 30df5b77..c383a6c9 100644
--- a/docs/_stylesheets/preset_test.css
+++ b/docs/_stylesheets/preset_test.css
@@ -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;
}
diff --git a/docs/preset_test.md b/docs/preset_test.md
index b1cadb1b..dd886275 100644
--- a/docs/preset_test.md
+++ b/docs/preset_test.md
@@ -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.
@@ -35,8 +35,9 @@ required.
-
Start
-
End
+
Start
+
End
+
Display time zone
Firmware epochs
@@ -211,12 +212,20 @@ clock
Stock · while it is running
+ Leave now
Copy restore command
normalradio cancels pending and active primary TempRadio
windows and restores the saved primary tuple after its reply drains.
+ Leave in 30 minutes
+
+ Copy 30-minute command
+
+ This replaces the current temporary timeout with 30 minutes, then
+ restores the saved primary tuple automatically.
+
@@ -227,26 +236,133 @@ clock
Companion · while it is running
+ Leave now
Copy restore commands
If get radio2.cross was not auto before the
test, restore that recorded value instead.
+ Leave in 30 minutes
+
+ Copy 30-minute commands
+
+ 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 on; afterward,
+ restore the value you recorded before the test (auto is
+ the normal default).
+
- Reuse this page for another test
+ Build a link for another test
- Set start and end to ISO-8601 timestamps with an
- explicit UTC offset, or to Unix epoch seconds. Set freq,
- bw, sf, and cr 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 tz so the page displays them in the organizer's local
+ time zone.
+
+
+
+
+
+
+ Radio estimates
+
+
Nominal LoRa bitrate —
+
Estimated sensitivity —
+
TX output used —
+
Estimated link budget —
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Advanced use: start and end also accept ISO-8601
+ timestamps with explicit UTC offsets or Unix epoch seconds. The other URL
+ parameters are tz, freq, bw,
+ sf, cr, and estimate-only tx.
- ?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
- Copy configured page URL
diff --git a/test/test_preset_test.js b/test/test_preset_test.js
index 11e81c85..dfcba65d 100644
--- a/test/test_preset_test.js
+++ b/test/test_preset_test.js
@@ -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");