mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-23 18:02:29 +00:00
Add reusable radio test link builder
This commit is contained in:
1 parent
1818e60387
commit
d61b287ddc
4 files changed
+590
-21
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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&end=2026-09-23T17:00:00-07:00&freq=910.1&bw=500&sf=8&cr=7</code></pre>
|
||||
<button type="button" data-copy-command="share-url">Copy configured page URL</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user