mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 14:07:58 +00:00
Update preset test scheduling UI
This commit is contained in:
+115
-24
@@ -2,8 +2,6 @@
|
||||
"use strict";
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
start: "2026-09-21T17:00:00-07:00",
|
||||
end: "2026-09-23T17:00:00-07:00",
|
||||
tz: "",
|
||||
freq: "910.1",
|
||||
bw: "500",
|
||||
@@ -17,6 +15,10 @@
|
||||
const SCHEDULE_HORIZON_MS = 0x7fffffff;
|
||||
const SCHEDULER_EPOCH_MAX = 0xffffffff;
|
||||
const EARLY_JOIN_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_START_HOUR = 17;
|
||||
const DEFAULT_WINDOW_DAYS = 1;
|
||||
const SCHEDULE_MODE_RELATIVE = "relative";
|
||||
const SCHEDULE_MODE_ABSOLUTE = "absolute";
|
||||
const CLOCK_RESET_COMMAND = "clkreboot";
|
||||
const TIMEZONE_BOUNDARY_PATH = "../_data/timezones-2025b-simplified.json";
|
||||
const TIMEZONE_MAP_STYLE = Object.freeze({
|
||||
@@ -202,7 +204,7 @@
|
||||
|
||||
function hasPresetParameters(search) {
|
||||
const params = new URLSearchParams(search || "");
|
||||
return Object.keys(DEFAULTS).some(function (key) {
|
||||
return ["start", "end"].concat(Object.keys(DEFAULTS)).some(function (key) {
|
||||
return params.has(key);
|
||||
});
|
||||
}
|
||||
@@ -229,7 +231,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
function configFromSearch(search, fallbackTimeZone) {
|
||||
function calendarDayValue(parts, daysLater) {
|
||||
const value = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + daysLater));
|
||||
return String(value.getUTCFullYear()).padStart(4, "0") + "-" +
|
||||
twoDigits(value.getUTCMonth() + 1) + "-" + twoDigits(value.getUTCDate());
|
||||
}
|
||||
|
||||
function defaultWindow(timeZone, nowMs) {
|
||||
const zone = validateTimeZone(timeZone);
|
||||
const now = nowMs == null ? Date.now() : Number(nowMs);
|
||||
if (!Number.isFinite(now)) throw new PresetTestError("browser clock is unavailable");
|
||||
const today = zonedParts(now, zone);
|
||||
const startText = calendarDayValue(today, 1) + "T" +
|
||||
twoDigits(DEFAULT_START_HOUR) + ":00";
|
||||
const endText = calendarDayValue(today, 1 + DEFAULT_WINDOW_DAYS) + "T" +
|
||||
twoDigits(DEFAULT_START_HOUR) + ":00";
|
||||
return Object.freeze({
|
||||
startMs: localDateTimeToMs(startText, zone, "default start"),
|
||||
endMs: localDateTimeToMs(endText, zone, "default end"),
|
||||
});
|
||||
}
|
||||
|
||||
function configFromSearch(search, fallbackTimeZone, nowMs) {
|
||||
const params = new URLSearchParams(search || "");
|
||||
const raw = {};
|
||||
Object.keys(DEFAULTS).forEach(function (key) {
|
||||
@@ -239,9 +262,14 @@
|
||||
raw.tz = fallbackTimeZone || browserTimeZone();
|
||||
}
|
||||
|
||||
const startMs = parseTimestamp(raw.start, "start");
|
||||
const endMs = parseTimestamp(raw.end, "end");
|
||||
const tz = validateTimeZone(raw.tz);
|
||||
const generatedWindow = defaultWindow(tz, nowMs);
|
||||
let startMs = params.has("start")
|
||||
? parseTimestamp(params.get("start"), "start") : generatedWindow.startMs;
|
||||
let endMs = params.has("end")
|
||||
? parseTimestamp(params.get("end"), "end") : generatedWindow.endMs;
|
||||
if (params.has("start") && !params.has("end")) endMs = startMs + 86400000;
|
||||
if (!params.has("start") && params.has("end")) startMs = endMs - 86400000;
|
||||
const freq = strictNumber(raw.freq, "freq");
|
||||
const bw = strictNumber(raw.bw, "bw");
|
||||
const sf = strictInteger(raw.sf, "sf");
|
||||
@@ -289,14 +317,17 @@
|
||||
}
|
||||
|
||||
function isDefaultPreset(config) {
|
||||
const startLocal = zonedInputValue(config.startMs, config.tz);
|
||||
const endLocal = zonedInputValue(config.endMs, config.tz);
|
||||
const startParts = zonedParts(config.startMs, config.tz);
|
||||
return (
|
||||
config.startMs === parseTimestamp(DEFAULTS.start, "start") &&
|
||||
config.endMs === parseTimestamp(DEFAULTS.end, "end") &&
|
||||
config.freq === Number(DEFAULTS.freq) &&
|
||||
config.bw === Number(DEFAULTS.bw) &&
|
||||
config.sf === Number(DEFAULTS.sf) &&
|
||||
config.cr === Number(DEFAULTS.cr) &&
|
||||
config.tx === Number(DEFAULTS.tx)
|
||||
config.tx === Number(DEFAULTS.tx) &&
|
||||
startLocal.endsWith("T17:00") &&
|
||||
endLocal === calendarDayValue(startParts, DEFAULT_WINDOW_DAYS) + "T17:00"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,30 +385,47 @@
|
||||
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." };
|
||||
return { available: true, reason: "Ready to queue." };
|
||||
}
|
||||
|
||||
function primaryScheduleAvailability(config, nowMs) {
|
||||
if (nowMs >= config.startMs) {
|
||||
return { available: false, reason: "The start time has passed; use the immediate option." };
|
||||
}
|
||||
return { available: true, reason: "Ready to queue after the node clock is verified." };
|
||||
return { available: true, reason: "Ready to queue." };
|
||||
}
|
||||
|
||||
function commandsFor(config, nowMs, clockOffsetSeconds) {
|
||||
function relativeMinutes(targetMs, nowMs) {
|
||||
return Math.max(1, Math.ceil((targetMs - nowMs) / 60000));
|
||||
}
|
||||
|
||||
function commandsFor(config, nowMs, clockOffsetSeconds, scheduleMode) {
|
||||
const tuple = [config.freqText, config.bwText, config.sf, config.cr].join(",");
|
||||
const minutes = remainingMinutes(config, nowMs);
|
||||
const scheduled = schedulerEpochs(config, clockOffsetSeconds);
|
||||
const mode = scheduleMode || SCHEDULE_MODE_RELATIVE;
|
||||
if (mode !== SCHEDULE_MODE_RELATIVE && mode !== SCHEDULE_MODE_ABSOLUTE) {
|
||||
throw new PresetTestError("schedule mode must be relative or absolute");
|
||||
}
|
||||
let startArgument;
|
||||
let endArgument;
|
||||
if (mode === SCHEDULE_MODE_RELATIVE) {
|
||||
startArgument = "+" + relativeMinutes(config.startMs, nowMs);
|
||||
endArgument = "+" + relativeMinutes(config.endMs, nowMs);
|
||||
} else {
|
||||
const scheduled = schedulerEpochs(config, clockOffsetSeconds);
|
||||
startArgument = String(scheduled.startEpoch);
|
||||
endArgument = String(scheduled.endEpoch);
|
||||
}
|
||||
return Object.freeze({
|
||||
stockNow: "tempradio " + tuple + "," + minutes,
|
||||
companionNow:
|
||||
"set radio2.cross on\nset tempradio2 " + tuple + ",rxtx," + minutes,
|
||||
primaryScheduled:
|
||||
"set tempradioat " + tuple + "," + scheduled.startEpoch + "," +
|
||||
scheduled.endEpoch + "\nget tempradioat",
|
||||
"set tempradioat " + tuple + "," + startArgument + "," +
|
||||
endArgument + "\nget tempradioat",
|
||||
companionScheduled:
|
||||
"set radio2.cross on\nset tempradioat2 " + tuple + ",rxtx," +
|
||||
scheduled.startEpoch + "," + scheduled.endEpoch + "\nget tempradioat2",
|
||||
startArgument + "," + endArgument + "\nget tempradioat2",
|
||||
stockCancelDuring: "tempradio " + tuple + ",1",
|
||||
stockLeaveIn30: "tempradio " + tuple + ",30",
|
||||
primaryCancel:
|
||||
@@ -824,18 +872,32 @@
|
||||
if (builderDisclosure) builderDisclosure.open = !showTest;
|
||||
|
||||
let activeClockOffsetSeconds = 0;
|
||||
let scheduleMode = SCHEDULE_MODE_RELATIVE;
|
||||
|
||||
function setScheduledCommands() {
|
||||
function setScheduledCommands(nowMs) {
|
||||
const staticCommands = commandsFor(
|
||||
config,
|
||||
config.startMs,
|
||||
activeClockOffsetSeconds
|
||||
nowMs == null ? Date.now() : nowMs,
|
||||
activeClockOffsetSeconds,
|
||||
scheduleMode
|
||||
);
|
||||
setCommand(root, "primary-scheduled", staticCommands.primaryScheduled);
|
||||
setCommand(root, "companion-scheduled", staticCommands.companionScheduled);
|
||||
return staticCommands;
|
||||
}
|
||||
|
||||
function setScheduleMode(nextMode) {
|
||||
scheduleMode = nextMode === SCHEDULE_MODE_ABSOLUTE
|
||||
? SCHEDULE_MODE_ABSOLUTE : SCHEDULE_MODE_RELATIVE;
|
||||
const absoluteControls = root.querySelector('[data-role="absolute-clock-controls"]');
|
||||
if (absoluteControls) absoluteControls.hidden = scheduleMode !== SCHEDULE_MODE_ABSOLUTE;
|
||||
const relativeNote = root.querySelector('[data-role="relative-schedule-note"]');
|
||||
if (relativeNote) relativeNote.hidden = scheduleMode !== SCHEDULE_MODE_RELATIVE;
|
||||
const absoluteNote = root.querySelector('[data-role="absolute-schedule-note"]');
|
||||
if (absoluteNote) absoluteNote.hidden = scheduleMode !== SCHEDULE_MODE_ABSOLUTE;
|
||||
setScheduledCommands(Date.now());
|
||||
}
|
||||
|
||||
function setNodeClockStatus(message, state) {
|
||||
const status = root.querySelector('[data-role="node-clock-status"]');
|
||||
if (!status) return;
|
||||
@@ -848,7 +910,7 @@
|
||||
const text = input ? input.value : "";
|
||||
if (!String(text).trim()) {
|
||||
activeClockOffsetSeconds = 0;
|
||||
setScheduledCommands();
|
||||
setScheduledCommands(Date.now());
|
||||
setNodeClockStatus(
|
||||
"No node-clock correction is applied. Scheduled commands use the normal UTC epochs.",
|
||||
"normal"
|
||||
@@ -860,7 +922,7 @@
|
||||
const offset = nodeClockOffsetSeconds(nodeClockEpoch, Date.now());
|
||||
schedulerEpochs(config, offset);
|
||||
activeClockOffsetSeconds = offset;
|
||||
setScheduledCommands();
|
||||
setScheduledCommands(Date.now());
|
||||
const direction = offset === 0
|
||||
? "matches browser UTC to the minute"
|
||||
: "is " + formatClockOffset(offset) + (offset > 0 ? " ahead of" : " behind") +
|
||||
@@ -901,7 +963,7 @@
|
||||
" window. Saved primary settings return automatically at the end."
|
||||
);
|
||||
|
||||
const staticCommands = setScheduledCommands();
|
||||
const staticCommands = setScheduledCommands(Date.now());
|
||||
setCommand(root, "stock-leave-30", staticCommands.stockLeaveIn30);
|
||||
setCommand(root, "primary-cancel", staticCommands.primaryCancel);
|
||||
setCommand(root, "companion-cancel-before", staticCommands.companionCancelBefore);
|
||||
@@ -911,6 +973,13 @@
|
||||
}
|
||||
|
||||
if (showTest) {
|
||||
root.querySelectorAll('input[name="schedule-command-mode"]').forEach(function (input) {
|
||||
input.addEventListener("change", function () {
|
||||
if (input.checked) setScheduleMode(input.value);
|
||||
});
|
||||
});
|
||||
setScheduleMode(SCHEDULE_MODE_RELATIVE);
|
||||
|
||||
const nodeClockInput = root.querySelector('[data-role="node-clock-input"]');
|
||||
const applyNodeClockButton = root.querySelector('[data-action="apply-node-clock"]');
|
||||
if (nodeClockInput) {
|
||||
@@ -928,6 +997,9 @@
|
||||
|
||||
const generator = root.querySelector('[data-role="url-generator"]');
|
||||
if (generator) {
|
||||
const sourceParams = new URLSearchParams(search || "");
|
||||
const followsDefaultWindow = !sourceParams.has("start") && !sourceParams.has("end");
|
||||
let timeFieldsEdited = false;
|
||||
generator.elements.start.value = zonedInputValue(config.startMs, config.tz);
|
||||
generator.elements.end.value = zonedInputValue(config.endMs, config.tz);
|
||||
generator.elements.tz.value = config.tz;
|
||||
@@ -987,9 +1059,19 @@
|
||||
event.preventDefault();
|
||||
generateUrl();
|
||||
});
|
||||
[generator.elements.start, generator.elements.end].forEach(function (input) {
|
||||
input.addEventListener("input", function () { timeFieldsEdited = true; });
|
||||
});
|
||||
generator.addEventListener("change", generateUrl);
|
||||
generateUrl();
|
||||
initTimeZoneMap(root, config.tz, generateUrl);
|
||||
initTimeZoneMap(root, config.tz, function (zone) {
|
||||
if (followsDefaultWindow && !timeFieldsEdited) {
|
||||
const nextWindow = defaultWindow(zone, Date.now());
|
||||
generator.elements.start.value = zonedInputValue(nextWindow.startMs, zone);
|
||||
generator.elements.end.value = zonedInputValue(nextWindow.endMs, zone);
|
||||
}
|
||||
generateUrl();
|
||||
});
|
||||
}
|
||||
|
||||
root.querySelectorAll("[data-copy-command]").forEach(function (button) {
|
||||
@@ -1011,7 +1093,11 @@
|
||||
const status = root.querySelector('[data-role="status"]');
|
||||
const primarySchedule = primaryScheduleAvailability(config, nowMs);
|
||||
const schedule = scheduleAvailability(config, nowMs);
|
||||
const commands = commandsFor(config, nowMs, activeClockOffsetSeconds);
|
||||
const commands = commandsFor(
|
||||
config, nowMs, activeClockOffsetSeconds, scheduleMode
|
||||
);
|
||||
setCommand(root, "primary-scheduled", commands.primaryScheduled);
|
||||
setCommand(root, "companion-scheduled", commands.companionScheduled);
|
||||
|
||||
status.dataset.state = phase;
|
||||
if (phase === "before") {
|
||||
@@ -1111,6 +1197,10 @@
|
||||
SCHEDULE_HORIZON_MS: SCHEDULE_HORIZON_MS,
|
||||
SCHEDULER_EPOCH_MAX: SCHEDULER_EPOCH_MAX,
|
||||
EARLY_JOIN_MS: EARLY_JOIN_MS,
|
||||
DEFAULT_START_HOUR: DEFAULT_START_HOUR,
|
||||
DEFAULT_WINDOW_DAYS: DEFAULT_WINDOW_DAYS,
|
||||
SCHEDULE_MODE_RELATIVE: SCHEDULE_MODE_RELATIVE,
|
||||
SCHEDULE_MODE_ABSOLUTE: SCHEDULE_MODE_ABSOLUTE,
|
||||
CLOCK_RESET_COMMAND: CLOCK_RESET_COMMAND,
|
||||
PresetTestError: PresetTestError,
|
||||
parseNodeClock: parseNodeClock,
|
||||
@@ -1127,6 +1217,7 @@
|
||||
presetEyebrow: presetEyebrow,
|
||||
validateTimeZone: validateTimeZone,
|
||||
browserTimeZone: browserTimeZone,
|
||||
defaultWindow: defaultWindow,
|
||||
hasPresetParameters: hasPresetParameters,
|
||||
supportedTimeZones: supportedTimeZones,
|
||||
phaseAt: phaseAt,
|
||||
|
||||
@@ -393,19 +393,53 @@
|
||||
content: "Hide advanced";
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode legend {
|
||||
grid-column: 1 / -1;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--md-default-fg-color--light);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode label {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode input {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.2rem;
|
||||
accent-color: var(--preset-accent);
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode label:has(input:checked) {
|
||||
border-color: var(--preset-accent);
|
||||
background: color-mix(in srgb, var(--preset-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.preset-test-generator-disclosure-body {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.preset-test-generator-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(15rem, 2fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preset-test-generator,
|
||||
.preset-test-estimates {
|
||||
.preset-test-generator {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--preset-border);
|
||||
border-radius: 0.65rem;
|
||||
@@ -413,12 +447,34 @@
|
||||
background: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.preset-test-generator-fields {
|
||||
.preset-test-time-fields,
|
||||
.preset-test-radio-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.preset-test-time-fields {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.preset-test-radio-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(15rem, 2fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.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 label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -443,7 +499,6 @@
|
||||
}
|
||||
|
||||
.preset-test-timezone-picker {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -614,8 +669,13 @@
|
||||
.preset-test-grid--clock,
|
||||
.preset-test-clock-check,
|
||||
.preset-test-clock-override,
|
||||
.preset-test-generator-layout,
|
||||
.preset-test-generator-fields {
|
||||
.preset-test-time-fields,
|
||||
.preset-test-radio-layout,
|
||||
.preset-test-radio-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preset-test-schedule-mode fieldset {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
+89
-54
@@ -9,7 +9,7 @@ create a shareable temporary-radio test URL.</span>
|
||||
<div data-role="content">
|
||||
<div data-role="test-content" hidden>
|
||||
<section class="preset-test-hero" aria-labelledby="preset-test-title">
|
||||
<p class="preset-test-eyebrow" data-role="preset-test-eyebrow">MeshCore · default 48-hour temporary preset test</p>
|
||||
<p class="preset-test-eyebrow" data-role="preset-test-eyebrow">MeshCore · one-day temporary preset test</p>
|
||||
<div class="preset-test-hero-row">
|
||||
<div>
|
||||
<h2 id="preset-test-title">
|
||||
@@ -61,13 +61,36 @@ create a shareable temporary-radio test URL.</span>
|
||||
<div data-role="test-content-footer" hidden>
|
||||
<details class="preset-test-generator-disclosure preset-test-keymind-disclosure"
|
||||
data-role="keymind-disclosure">
|
||||
<summary><h2>KeyMind firmware and advanced commands</h2></summary>
|
||||
<summary><h2>KeyMind Cascade firmware and advanced commands</h2></summary>
|
||||
<div class="preset-test-generator-disclosure-body">
|
||||
<p>
|
||||
Use these only if your firmware supports the listed commands or you need
|
||||
dual-radio operation, scheduling, or clock correction.
|
||||
</p>
|
||||
<p>Firmware schedule epochs: <code data-role="epoch-range"></code></p>
|
||||
|
||||
<section class="preset-test-schedule-mode" aria-labelledby="schedule-mode-title">
|
||||
<h2 id="schedule-mode-title">Scheduled command timing</h2>
|
||||
<fieldset>
|
||||
<legend>Choose the syntax supported by the node</legend>
|
||||
<label>
|
||||
<input type="radio" name="schedule-command-mode" value="relative" checked>
|
||||
<span><strong>KeyMind Cascade · <code>+minutes</code></strong> (default)</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="schedule-command-mode" value="absolute">
|
||||
<span><strong>Absolute Unix time</strong></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<p data-role="relative-schedule-note">
|
||||
Relative commands calculate whole minutes from now to the shared start
|
||||
and end. Both offsets use the node's same command-time snapshot, so no
|
||||
clock correction is needed. Copy the command shortly after it is shown.
|
||||
</p>
|
||||
<p data-role="absolute-schedule-note" hidden>
|
||||
Absolute commands use fixed UTC epochs. Verify or compensate for the
|
||||
node clock with the controls below before queuing them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="preset-test-warning" aria-labelledby="continuity-plan-title">
|
||||
<h2 id="continuity-plan-title">Bridges and early-revert plan</h2>
|
||||
@@ -90,6 +113,8 @@ create a shareable temporary-radio test URL.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div data-role="absolute-clock-controls" hidden>
|
||||
<p>Firmware schedule epochs: <code data-role="epoch-range"></code></p>
|
||||
<section aria-labelledby="clock-check-title">
|
||||
<h2 id="clock-check-title">Check the node clock before scheduling</h2>
|
||||
<p>
|
||||
@@ -188,6 +213,7 @@ clock</code></pre>
|
||||
reboots.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="join-now-title">
|
||||
<h2 id="join-now-title">Companion: use both frequencies</h2>
|
||||
@@ -218,11 +244,11 @@ clock</code></pre>
|
||||
<p>
|
||||
A Simple Repeater build can schedule its primary radio with
|
||||
<code>tempradioat</code>. A Companion can instead schedule its second
|
||||
profile with <code>tempradioat2</code>. Both commands use the exact UTC
|
||||
epochs below, switch at the common start, and restore saved settings at
|
||||
the common end. A node-clock conversion above replaces only those command
|
||||
epochs with its per-node translated values. Room-server and sensor roles
|
||||
use the stock join command above instead.
|
||||
profile with <code>tempradioat2</code>. The default KeyMind Cascade form
|
||||
uses <code>+minutes</code> for both endpoints. Switch to Absolute Unix time
|
||||
above for fixed UTC epochs and the node-clock correction tools. Both forms
|
||||
switch at the common start and restore saved settings at the common end.
|
||||
Room-server and sensor roles use the stock join command above instead.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-grid">
|
||||
@@ -348,12 +374,14 @@ clock</code></pre>
|
||||
Enter the test times in the selected time zone and choose the radio
|
||||
tuple. The generated URL converts the times to exact UTC instants, keeps
|
||||
the date punctuation readable, and keeps <code>tz</code> so the page
|
||||
displays them in the organizer's local time zone.
|
||||
displays them in the organizer's local time zone. With no URL settings,
|
||||
the builder starts at 5:00 PM tomorrow and ends at 5:00 PM the following
|
||||
day in the selected time zone.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-generator-layout">
|
||||
<form class="preset-test-generator" data-role="url-generator">
|
||||
<div class="preset-test-generator-fields">
|
||||
<div class="preset-test-time-fields">
|
||||
<label>
|
||||
<span>Start date and time</span>
|
||||
<input type="datetime-local" name="start" step="60" required>
|
||||
@@ -362,35 +390,40 @@ clock</code></pre>
|
||||
<span>End date and time</span>
|
||||
<input type="datetime-local" name="end" step="60" required>
|
||||
</label>
|
||||
<div class="preset-test-timezone-picker">
|
||||
<div class="preset-test-timezone-toolbar">
|
||||
<div>
|
||||
<span>Selected time zone</span>
|
||||
<strong data-role="selected-time-zone">Detecting browser time zone…</strong>
|
||||
</div>
|
||||
<button type="button" data-action="use-browser-time-zone">
|
||||
Use browser time zone
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="preset-test-timezone-picker">
|
||||
<div class="preset-test-timezone-toolbar">
|
||||
<div>
|
||||
<span>Selected time zone</span>
|
||||
<strong data-role="selected-time-zone">Detecting browser time zone…</strong>
|
||||
</div>
|
||||
<input type="hidden" name="tz" required>
|
||||
<div
|
||||
class="preset-test-timezone-map"
|
||||
data-role="timezone-map"
|
||||
aria-label="Interactive world map for selecting a time zone"
|
||||
></div>
|
||||
<p class="preset-test-timezone-status" data-role="timezone-map-status" aria-live="polite">
|
||||
Loading time zone map…
|
||||
</p>
|
||||
<small>
|
||||
Click a region to select its IANA time zone. The initial selection
|
||||
comes from <code>tz=</code> when present; otherwise it uses your
|
||||
browser's time zone. Map design inspired by
|
||||
<a href="https://zones.arilyn.cc/" target="_blank" rel="noopener">zones.arilyn.cc</a>;
|
||||
boundaries from
|
||||
<a href="https://github.com/evansiroky/timezone-boundary-builder" target="_blank" rel="noopener">Timezone Boundary Builder</a>
|
||||
and © OpenStreetMap contributors.
|
||||
</small>
|
||||
<button type="button" data-action="use-browser-time-zone">
|
||||
Use browser time zone
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="tz" required>
|
||||
<div
|
||||
class="preset-test-timezone-map"
|
||||
data-role="timezone-map"
|
||||
aria-label="Interactive world map for selecting a time zone"
|
||||
></div>
|
||||
<p class="preset-test-timezone-status" data-role="timezone-map-status" aria-live="polite">
|
||||
Loading time zone map…
|
||||
</p>
|
||||
<small>
|
||||
Click a region to select its IANA time zone. The initial selection
|
||||
comes from <code>tz=</code> when present; otherwise it uses your
|
||||
browser's time zone. Map design inspired by
|
||||
<a href="https://zones.arilyn.cc/" target="_blank" rel="noopener">zones.arilyn.cc</a>;
|
||||
boundaries from
|
||||
<a href="https://github.com/evansiroky/timezone-boundary-builder" target="_blank" rel="noopener">Timezone Boundary Builder</a>
|
||||
and © OpenStreetMap contributors.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="preset-test-radio-layout">
|
||||
<div class="preset-test-radio-fields">
|
||||
<div class="preset-test-generator-subheading">Test radio profile</div>
|
||||
<label>
|
||||
<span>Frequency (MHz)</span>
|
||||
@@ -422,26 +455,28 @@ clock</code></pre>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<p class="preset-test-error preset-test-generator-error"
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ extra_css:
|
||||
- _stylesheets/telemetry_decoder.css
|
||||
- _stylesheets/management_decoder.css
|
||||
- _stylesheets/filter_tool.css
|
||||
- _stylesheets/preset_test.css?v=20260922-1
|
||||
- _stylesheets/preset_test.css?v=20260923-1
|
||||
|
||||
extra_javascript:
|
||||
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
|
||||
@@ -30,4 +30,4 @@ extra_javascript:
|
||||
- _javascript/telemetry_decoder.js
|
||||
- _javascript/management_decoder.js
|
||||
- _javascript/filter_tool.js
|
||||
- _javascript/preset_test.js?v=20260922-1
|
||||
- _javascript/preset_test.js?v=20260923-1
|
||||
|
||||
@@ -51,6 +51,13 @@ assert.match(pageSource, /data-action="apply-node-clock"/);
|
||||
assert.match(pageSource, /data-role="node-clock-status"/);
|
||||
assert.match(pageSource, /data-command="primary-scheduled"/);
|
||||
assert.match(pageSource, /data-command="primary-cancel"/);
|
||||
assert.match(pageSource, /name="schedule-command-mode" value="relative" checked/);
|
||||
assert.match(pageSource, /KeyMind Cascade · <code>\+minutes<\/code>/);
|
||||
assert.match(pageSource, /name="schedule-command-mode" value="absolute"/);
|
||||
assert.match(pageSource, /data-role="absolute-clock-controls" hidden/);
|
||||
assert.match(pageSource, /class="preset-test-time-fields"/);
|
||||
assert.match(pageSource, /class="preset-test-radio-layout"/);
|
||||
assert.match(pageSource, /class="preset-test-radio-fields"/);
|
||||
assert.match(pageSource, /Simple Repeater primary radio/);
|
||||
assert.doesNotMatch(pageSource, /Stock firmware has no <code>tempradioat<\/code> command/);
|
||||
assert.doesNotMatch(pageSource, /Normal return profile/);
|
||||
@@ -61,7 +68,8 @@ const query =
|
||||
"&tz=America%2FLos_Angeles" +
|
||||
"&freq=910.1&bw=500&sf=8&cr=7";
|
||||
const config = tool.configFromSearch(query);
|
||||
const defaults = tool.configFromSearch("", "America/Los_Angeles");
|
||||
const defaultNow = Date.parse("2026-09-22T08:00:00.000Z");
|
||||
const defaults = tool.configFromSearch("", "America/Los_Angeles", defaultNow);
|
||||
|
||||
assert.strictEqual(tool.hasPresetParameters(""), false);
|
||||
assert.strictEqual(tool.hasPresetParameters("?"), false);
|
||||
@@ -107,8 +115,15 @@ 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.startMs,
|
||||
Date.parse("2026-09-23T17:00:00-07:00")
|
||||
);
|
||||
assert.strictEqual(
|
||||
defaults.endMs,
|
||||
Date.parse("2026-09-24T17:00:00-07:00")
|
||||
);
|
||||
assert.strictEqual(defaults.endEpoch - defaults.startEpoch, 24 * 60 * 60);
|
||||
assert.strictEqual(defaults.freq, config.freq);
|
||||
assert.strictEqual(defaults.bw, 500);
|
||||
assert.strictEqual(defaults.sf, 8);
|
||||
@@ -116,9 +131,26 @@ assert.strictEqual(defaults.cr, 7);
|
||||
assert.strictEqual(defaults.tx, 22);
|
||||
assert.strictEqual(defaults.tz, config.tz);
|
||||
assert.strictEqual(tool.isDefaultPreset(defaults), true);
|
||||
assert.strictEqual(tool.isDefaultPreset(config), false);
|
||||
assert.strictEqual(tool.presetPageTitle(defaults), "Default temporary radio test · 910.1 MHz");
|
||||
assert.match(tool.presetPageSummary(defaults), /910\.1 MHz, 500 kHz, SF8, CR7, 22 dBm/);
|
||||
assert.strictEqual(tool.presetEyebrow(defaults), "MeshCore · default 48-hour temporary preset test");
|
||||
assert.strictEqual(tool.presetEyebrow(defaults), "MeshCore · default 24-hour temporary preset test");
|
||||
|
||||
const dstDefault = tool.configFromSearch(
|
||||
"?tz=America%2FLos_Angeles",
|
||||
"UTC",
|
||||
Date.parse("2026-03-06T08:00:00.000Z")
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.zonedInputValue(dstDefault.startMs, dstDefault.tz),
|
||||
"2026-03-07T17:00"
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.zonedInputValue(dstDefault.endMs, dstDefault.tz),
|
||||
"2026-03-08T17:00"
|
||||
);
|
||||
assert.strictEqual(dstDefault.endMs - dstDefault.startMs, 23 * 60 * 60 * 1000);
|
||||
assert.strictEqual(tool.isDefaultPreset(dstDefault), true);
|
||||
|
||||
const requestedLink = tool.configFromSearch(
|
||||
"?start=2026-09-22T00:00:00.000Z&end=2026-09-24T00:00:00.000Z" +
|
||||
@@ -135,6 +167,15 @@ assert.strictEqual(
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.commandsFor(requestedLink, requestedLink.startMs).primaryScheduled,
|
||||
"set tempradioat 911.3,500,8,7,+1,+2880\nget tempradioat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
tool.commandsFor(
|
||||
requestedLink,
|
||||
requestedLink.startMs,
|
||||
0,
|
||||
tool.SCHEDULE_MODE_ABSOLUTE
|
||||
).primaryScheduled,
|
||||
"set tempradioat 911.3,500,8,7,1790035200,1790208000\nget tempradioat"
|
||||
);
|
||||
|
||||
@@ -151,7 +192,8 @@ assert.deepStrictEqual(adjustedEpochs, {
|
||||
const adjustedCommands = tool.commandsFor(
|
||||
clockAdjustedLink,
|
||||
clockAdjustedLink.startMs,
|
||||
clockOffset
|
||||
clockOffset,
|
||||
tool.SCHEDULE_MODE_ABSOLUTE
|
||||
);
|
||||
assert.strictEqual(
|
||||
adjustedCommands.primaryScheduled,
|
||||
@@ -178,10 +220,12 @@ assert.throws(
|
||||
/outside the firmware range/
|
||||
);
|
||||
|
||||
const browserZoneFallback = tool.configFromSearch("", "America/New_York");
|
||||
const browserZoneFallback = tool.configFromSearch(
|
||||
"", "America/New_York", defaultNow
|
||||
);
|
||||
assert.strictEqual(browserZoneFallback.tz, "America/New_York");
|
||||
assert.strictEqual(
|
||||
tool.configFromSearch("?tz=UTC", "America/New_York").tz,
|
||||
tool.configFromSearch("?tz=UTC", "America/New_York", defaultNow).tz,
|
||||
"UTC"
|
||||
);
|
||||
assert.doesNotThrow(() => tool.validateTimeZone(tool.browserTimeZone()));
|
||||
@@ -218,7 +262,7 @@ const commands = tool.commandsFor(config, active);
|
||||
assert.strictEqual(commands.stockNow, "tempradio 910.1,500,8,7,2880");
|
||||
assert.strictEqual(
|
||||
commands.primaryScheduled,
|
||||
"set tempradioat 910.1,500,8,7,1790035200,1790208000\nget tempradioat"
|
||||
"set tempradioat 910.1,500,8,7,+1,+2880\nget tempradioat"
|
||||
);
|
||||
assert.strictEqual(commands.primaryCancel, "get tempradioat\ndel tempradioat all");
|
||||
assert.strictEqual(
|
||||
@@ -228,7 +272,7 @@ assert.strictEqual(
|
||||
assert.strictEqual(
|
||||
commands.companionScheduled,
|
||||
"set radio2.cross on\n" +
|
||||
"set tempradioat2 910.1,500,8,7,rxtx,1790035200,1790208000\n" +
|
||||
"set tempradioat2 910.1,500,8,7,rxtx,+1,+2880\n" +
|
||||
"get tempradioat2"
|
||||
);
|
||||
assert.strictEqual(commands.stockLeaveIn30, "tempradio 910.1,500,8,7,30");
|
||||
@@ -243,6 +287,33 @@ assert.strictEqual(
|
||||
"set tempradio2 910.1,500,8,7,rxtx,30"
|
||||
);
|
||||
|
||||
const absoluteCommands = tool.commandsFor(
|
||||
config,
|
||||
active,
|
||||
0,
|
||||
tool.SCHEDULE_MODE_ABSOLUTE
|
||||
);
|
||||
assert.strictEqual(
|
||||
absoluteCommands.primaryScheduled,
|
||||
"set tempradioat 910.1,500,8,7,1790035200,1790208000\nget tempradioat"
|
||||
);
|
||||
assert.strictEqual(
|
||||
absoluteCommands.companionScheduled,
|
||||
"set radio2.cross on\n" +
|
||||
"set tempradioat2 910.1,500,8,7,rxtx,1790035200,1790208000\n" +
|
||||
"get tempradioat2"
|
||||
);
|
||||
assert.doesNotThrow(() => tool.commandsFor(
|
||||
config,
|
||||
active,
|
||||
tool.SCHEDULER_EPOCH_MAX,
|
||||
tool.SCHEDULE_MODE_RELATIVE
|
||||
));
|
||||
assert.throws(
|
||||
() => tool.commandsFor(config, active, 0, "unsupported"),
|
||||
/schedule mode must be relative or absolute/
|
||||
);
|
||||
|
||||
assert.strictEqual(tool.scheduleAvailability(config, before).available, true);
|
||||
assert.strictEqual(tool.scheduleAvailability(config, active).available, false);
|
||||
assert.strictEqual(tool.primaryScheduleAvailability(config, before).available, true);
|
||||
|
||||
Reference in New Issue
Block a user