diff --git a/docs/_javascript/preset_test.js b/docs/_javascript/preset_test.js new file mode 100644 index 00000000..783c5c39 --- /dev/null +++ b/docs/_javascript/preset_test.js @@ -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); diff --git a/docs/_stylesheets/preset_test.css b/docs/_stylesheets/preset_test.css new file mode 100644 index 00000000..0923acff --- /dev/null +++ b/docs/_stylesheets/preset_test.css @@ -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; + } +} diff --git a/docs/index.md b/docs/index.md index 0703d07f..2b5d465f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) diff --git a/docs/preset_test.md b/docs/preset_test.md new file mode 100644 index 00000000..f7538bba --- /dev/null +++ b/docs/preset_test.md @@ -0,0 +1,233 @@ +# Temporary radio test command generator + +Use this page to join the shared temporary radio test without overwriting the +node's saved primary radio settings. The default window is **Monday, +September 21, 2026 at 5:00 PM through Wednesday, September 23 at 5:00 PM +Pacific time** (48 hours), using **910.1 MHz, 500 kHz, SF8, CR7**. + +The page reads the complete test definition from its URL. Change the query +parameters to reuse it for a different window or radio tuple; no source edit is +required. + +
+ + +
+
+

MeshCore · temporary preset test

+
+
+

+ 910.100 MHz / + 500 kHz / + SF8 / + CR7 +

+

+
+ Scheduled +
+ +
+ Starts in + + +
+ +
+
Start
+
End
+
Firmware epochs
+
+
+ +
+

Before changing a radio

+

+ Run get radio 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 + get radio2 and get radio2.cross so you can restore + any non-default secondary-profile setup. +

+
+ +
+

Check the node clock before scheduling

+

+ tempradioat and tempradioat2 use UTC Unix time. + Run clock 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. +

+ +
+
+ Browser UTC now + +
+
+ Browser Unix time + +
+
+ +
+
+

Remote admin session

+

+ In a MeshCore client that supplies the sender timestamp, sync and + then verify: +

+
clock sync
+clock
+
+ +
+

Local USB or browser console

+

Copy this fresh, run it immediately, and then verify with clock:

+
time 0
+clock
+ +
+
+ +

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

+
+ +
+

Option 1: switch when the test begins

+

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

+ +
+
+
+

Stock repeater, room server, or sensor

+ Primary radio +
+
+ +

+
+ +
+
+

Companion using both frequencies

+ Dual profile +
+
+ +

+ rxtx permits transmission on the second profile; + radio2.cross on copies ordinary Companion traffic across + the primary and temporary profiles. +

+
+
+
+ +
+

Option 2: schedule it in advance

+

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

+ +
+
+
+

Stock repeater, room server, or sensor

+ Primary schedule +
+
+ +

+
+ +
+
+

Companion using both frequencies

+ Dual-profile schedule +
+
+ +

+ Crossing is saved independently and remains on after the + temporary second profile ends. Restore its previous value after the + test; auto is the normal default. +

+
+
+
+ +
+

Cancel or leave the test

+

+ Use the command for the node type and timing. The all forms + remove every temporary schedule in that family. To preserve another + schedule, first run the corresponding get command and replace + all with its displayed entry number. +

+ +
+
+

Stock · before it starts

+
+ +
+ +
+

Stock · while it is running

+
+ +

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

+
+ +
+

Companion · before it starts

+
+ +
+ +
+

Companion · while it is running

+
+ +

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

+
+
+
+ +
+

Reuse this page 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. +

+
?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
+ +
+
+
+ +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. diff --git a/mkdocs.yml b/mkdocs.yml index 2449f960..3b1a271b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/test/test_preset_test.js b/test/test_preset_test.js new file mode 100644 index 00000000..7cf3dc7a --- /dev/null +++ b/test/test_preset_test.js @@ -0,0 +1,90 @@ +"use strict"; + +const assert = require("assert"); +const tool = require("../docs/_javascript/preset_test.js"); + +const query = + "?start=2026-09-21T17:00:00-07:00" + + "&end=2026-09-23T17:00:00-07:00" + + "&freq=910.1&bw=500&sf=8&cr=7"; +const config = tool.configFromSearch(query); +const defaults = tool.configFromSearch(""); + +assert.strictEqual(config.startEpoch, 1790035200); +assert.strictEqual(config.endEpoch, 1790208000); +assert.strictEqual(config.endEpoch - config.startEpoch, 48 * 60 * 60); +assert.strictEqual(config.freq, 910.1); +assert.strictEqual(config.sf, 8); +assert.strictEqual(config.cr, 7); +assert.strictEqual(defaults.startEpoch, config.startEpoch); +assert.strictEqual(defaults.endEpoch, config.endEpoch); +assert.strictEqual(defaults.freq, config.freq); + +const before = Date.parse("2026-09-21T16:59:00-07:00"); +const active = Date.parse("2026-09-21T17:00:30-07:00"); +const ended = Date.parse("2026-09-23T17:00:00-07:00"); +assert.strictEqual(tool.phaseAt(config, before), "before"); +assert.strictEqual(tool.phaseAt(config, active), "active"); +assert.strictEqual(tool.phaseAt(config, ended), "ended"); +assert.strictEqual(tool.remainingMinutes(config, active), 2880); + +const commands = tool.commandsFor(config, active); +assert.strictEqual(commands.stockNow, "tempradio 910.1,500,8,7,2880"); +assert.strictEqual( + commands.stockScheduled, + "set tempradioat 910.1,500,8,7,1790035200,1790208000\nget tempradioat" +); +assert.strictEqual( + commands.companionNow, + "set radio2.cross on\nset tempradio2 910.1,500,8,7,rxtx,2880" +); +assert.strictEqual( + commands.companionScheduled, + "set radio2.cross on\n" + + "set tempradioat2 910.1,500,8,7,rxtx,1790035200,1790208000\n" + + "get tempradioat2" +); + +assert.strictEqual(tool.scheduleAvailability(config, before).available, true); +assert.strictEqual(tool.scheduleAvailability(config, active).available, false); +assert.strictEqual( + tool.scheduleAvailability(config, config.endMs - tool.SCHEDULE_HORIZON_MS - 1).available, + false +); + +assert.throws( + () => tool.configFromSearch("?start=bad"), + /start must include an explicit UTC offset/ +); +assert.throws( + () => tool.configFromSearch("?start=2026-09-21T17:00:00"), + /start must include an explicit UTC offset/ +); +assert.throws( + () => tool.configFromSearch("?bw=123"), + /bw must be one of/ +); +assert.throws( + () => tool.configFromSearch("?start=1790208000&end=1790035200"), + /end must be later/ +); + +const changed = tool.configFromSearch( + "?start=1790035200&end=1790208000&freq=915.25&bw=125&sf=10&cr=5" +); +assert.strictEqual( + tool.commandsFor(changed, changed.startMs).stockNow, + "tempradio 915.25,125,10,5,2880" +); + +const shared = new URL(tool.configuredUrl( + changed, + "https://example.test/preset-test/?stale=yes#commands" +)); +assert.strictEqual(shared.searchParams.get("start"), "2026-09-22T00:00:00.000Z"); +assert.strictEqual(shared.searchParams.get("end"), "2026-09-24T00:00:00.000Z"); +assert.strictEqual(shared.searchParams.get("freq"), "915.25"); +assert.strictEqual(shared.searchParams.has("stale"), false); +assert.strictEqual(shared.hash, "#commands"); + +process.stdout.write("preset test command generator checks passed\n");