From d61b287ddc60b13fcaaac286a9259a613d7c4ebd Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 16 Sep 2026 14:52:34 -0700 Subject: [PATCH] Add reusable radio test link builder --- docs/_javascript/preset_test.js | 282 +++++++++++++++++++++++++++++- docs/_stylesheets/preset_test.css | 131 +++++++++++++- docs/preset_test.md | 138 +++++++++++++-- test/test_preset_test.js | 60 +++++++ 4 files changed, 590 insertions(+), 21 deletions(-) diff --git a/docs/_javascript/preset_test.js b/docs/_javascript/preset_test.js index e059e1b82..5de9023c7 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 30df5b775..c383a6c9e 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 b1cadb1b0..dd886275c 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

normalradio cancels pending and active primary TempRadio windows and restores the saved primary tuple after its reply drains.

+

Leave in 30 minutes

+
+ +

+ 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

If get radio2.cross was not auto before the test, restore that recorded value instead.

+

Leave in 30 minutes

+
+ +

+ 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. +

+ +
+
+
+ + + + + + + + +
+ +
+ + +
+ + + + + + + + + + + + + + + + +
+
+ + Open generated page +
+ +

+ 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
-
diff --git a/test/test_preset_test.js b/test/test_preset_test.js index 11e81c859..dfcba65db 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");