diff --git a/docs/_javascript/preset_test.js b/docs/_javascript/preset_test.js
index 47db346a..01e2b227 100644
--- a/docs/_javascript/preset_test.js
+++ b/docs/_javascript/preset_test.js
@@ -15,6 +15,7 @@
7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500,
]);
const SCHEDULE_HORIZON_MS = 0x7fffffff;
+ const SCHEDULER_EPOCH_MAX = 0xffffffff;
const EARLY_JOIN_MS = 60 * 60 * 1000;
const CLOCK_RESET_COMMAND = "clkreboot";
const TIMEZONE_BOUNDARY_PATH = "../_data/timezones-2025b-simplified.json";
@@ -102,6 +103,74 @@
return milliseconds;
}
+ function parseNodeClock(value) {
+ const text = String(value || "").trim();
+ if (!text) return null;
+ const match = /^(\d{1,2}):(\d{2})\s*(?:-\s*)?(\d{1,2})\/(\d{1,2})\/(\d{4})\s+UTC$/i.exec(text);
+ if (!match) {
+ throw new PresetTestError(
+ "node clock must be HH:mm DD/M/YYYY UTC"
+ );
+ }
+ const hour = Number(match[1]);
+ const minute = Number(match[2]);
+ const day = Number(match[3]);
+ const month = Number(match[4]);
+ const year = Number(match[5]);
+ if (hour > 23 || minute > 59 || day < 1 || month < 1 || month > 12) {
+ throw new PresetTestError("node clock must be a real UTC date and time");
+ }
+ const milliseconds = Date.UTC(year, month - 1, day, hour, minute, 0);
+ const parsed = new Date(milliseconds);
+ if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 ||
+ parsed.getUTCDate() !== day || parsed.getUTCHours() !== hour ||
+ parsed.getUTCMinutes() !== minute) {
+ throw new PresetTestError("node clock must be a real UTC date and time");
+ }
+ const epoch = Math.floor(milliseconds / 1000);
+ if (epoch < 0 || epoch > SCHEDULER_EPOCH_MAX) {
+ throw new PresetTestError("node clock must be within the firmware epoch range");
+ }
+ return epoch;
+ }
+
+ function nodeClockOffsetSeconds(nodeClockEpoch, browserNowMs) {
+ if (nodeClockEpoch === null) return 0;
+ if (!Number.isSafeInteger(nodeClockEpoch) ||
+ nodeClockEpoch < 0 || nodeClockEpoch > SCHEDULER_EPOCH_MAX) {
+ throw new PresetTestError("node clock must be a Unix epoch within the firmware range");
+ }
+ if (!Number.isFinite(browserNowMs)) {
+ throw new PresetTestError("browser clock is unavailable");
+ }
+ const browserEpoch = Math.floor(browserNowMs / 60000) * 60;
+ return nodeClockEpoch - browserEpoch;
+ }
+
+ function schedulerEpochs(config, clockOffsetSeconds) {
+ const offset = clockOffsetSeconds == null ? 0 : clockOffsetSeconds;
+ if (!Number.isSafeInteger(offset)) {
+ throw new PresetTestError("node clock offset must be whole seconds");
+ }
+ const startEpoch = config.startEpoch + offset;
+ const endEpoch = config.endEpoch + offset;
+ if (startEpoch < 1 || endEpoch < 1 ||
+ startEpoch > SCHEDULER_EPOCH_MAX || endEpoch > SCHEDULER_EPOCH_MAX) {
+ throw new PresetTestError("adjusted scheduler epochs are outside the firmware range");
+ }
+ return Object.freeze({ startEpoch: startEpoch, endEpoch: endEpoch });
+ }
+
+ function formatClockOffset(seconds) {
+ const absolute = Math.abs(seconds);
+ const hours = Math.floor(absolute / 3600);
+ const minutes = Math.floor((absolute % 3600) / 60);
+ const parts = [];
+ if (hours) parts.push(hours + "h");
+ if (minutes || !parts.length) parts.push(minutes + "m");
+ return parts.join(" ");
+ }
+
function numberText(value) {
return String(Number(value));
}
@@ -295,19 +364,20 @@
return { available: true, reason: "Ready to queue after the node clock is verified." };
}
- function commandsFor(config, nowMs) {
+ function commandsFor(config, nowMs, clockOffsetSeconds) {
const tuple = [config.freqText, config.bwText, config.sf, config.cr].join(",");
const minutes = remainingMinutes(config, nowMs);
+ const scheduled = schedulerEpochs(config, clockOffsetSeconds);
return Object.freeze({
stockNow: "tempradio " + tuple + "," + minutes,
companionNow:
"set radio2.cross on\nset tempradio2 " + tuple + ",rxtx," + minutes,
primaryScheduled:
- "set tempradioat " + tuple + "," + config.startEpoch + "," +
- config.endEpoch + "\nget tempradioat",
+ "set tempradioat " + tuple + "," + scheduled.startEpoch + "," +
+ scheduled.endEpoch + "\nget tempradioat",
companionScheduled:
"set radio2.cross on\nset tempradioat2 " + tuple + ",rxtx," +
- config.startEpoch + "," + config.endEpoch + "\nget tempradioat2",
+ scheduled.startEpoch + "," + scheduled.endEpoch + "\nget tempradioat2",
stockCancelDuring: "tempradio " + tuple + ",1",
stockLeaveIn30: "tempradio " + tuple + ",30",
primaryCancel:
@@ -753,6 +823,61 @@
const builderDisclosure = root.querySelector('[data-role="url-builder-disclosure"]');
if (builderDisclosure) builderDisclosure.open = !showTest;
+ let activeClockOffsetSeconds = 0;
+
+ function setScheduledCommands() {
+ const staticCommands = commandsFor(
+ config,
+ config.startMs,
+ activeClockOffsetSeconds
+ );
+ setCommand(root, "primary-scheduled", staticCommands.primaryScheduled);
+ setCommand(root, "companion-scheduled", staticCommands.companionScheduled);
+ return staticCommands;
+ }
+
+ function setNodeClockStatus(message, state) {
+ const status = root.querySelector('[data-role="node-clock-status"]');
+ if (!status) return;
+ status.textContent = message;
+ status.dataset.state = state || "normal";
+ }
+
+ function applyNodeClock() {
+ const input = root.querySelector('[data-role="node-clock-input"]');
+ const text = input ? input.value : "";
+ if (!String(text).trim()) {
+ activeClockOffsetSeconds = 0;
+ setScheduledCommands();
+ setNodeClockStatus(
+ "No node-clock correction is applied. Scheduled commands use the normal UTC epochs.",
+ "normal"
+ );
+ return;
+ }
+ try {
+ const nodeClockEpoch = parseNodeClock(text);
+ const offset = nodeClockOffsetSeconds(nodeClockEpoch, Date.now());
+ schedulerEpochs(config, offset);
+ activeClockOffsetSeconds = offset;
+ setScheduledCommands();
+ const direction = offset === 0
+ ? "matches browser UTC to the minute"
+ : "is " + formatClockOffset(offset) + (offset > 0 ? " ahead of" : " behind") +
+ " browser UTC";
+ setNodeClockStatus(
+ "Node clock " + direction + ". Absolute schedule commands now use this fixed correction. " +
+ "If the node clock later syncs or jumps, delete and requeue the schedule.",
+ "adjusted"
+ );
+ } catch (error) {
+ setNodeClockStatus(
+ error.message + ". The previous schedule correction remains unchanged.",
+ "error"
+ );
+ }
+ }
+
if (showTest) {
setPageText('[data-role="preset-test-page-title"]', presetPageTitle(config));
setPageText('[data-role="preset-test-page-summary"]', presetPageSummary(config));
@@ -776,9 +901,7 @@
" window. Saved primary settings return automatically at the end."
);
- const staticCommands = commandsFor(config, config.startMs);
- setCommand(root, "primary-scheduled", staticCommands.primaryScheduled);
- setCommand(root, "companion-scheduled", staticCommands.companionScheduled);
+ const staticCommands = setScheduledCommands();
setCommand(root, "stock-cancel-during", staticCommands.stockCancelDuring);
setCommand(root, "stock-leave-30", staticCommands.stockLeaveIn30);
setCommand(root, "primary-cancel", staticCommands.primaryCancel);
@@ -788,6 +911,22 @@
setCommand(root, "reset-clock", CLOCK_RESET_COMMAND);
}
+ if (showTest) {
+ const nodeClockInput = root.querySelector('[data-role="node-clock-input"]');
+ const applyNodeClockButton = root.querySelector('[data-action="apply-node-clock"]');
+ if (nodeClockInput) {
+ nodeClockInput.addEventListener("change", applyNodeClock);
+ nodeClockInput.addEventListener("keydown", function (event) {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ applyNodeClock();
+ });
+ }
+ if (applyNodeClockButton) {
+ applyNodeClockButton.addEventListener("click", applyNodeClock);
+ }
+ }
+
const generator = root.querySelector('[data-role="url-generator"]');
if (generator) {
generator.elements.start.value = zonedInputValue(config.startMs, config.tz);
@@ -873,7 +1012,7 @@
const status = root.querySelector('[data-role="status"]');
const primarySchedule = primaryScheduleAvailability(config, nowMs);
const schedule = scheduleAvailability(config, nowMs);
- const commands = commandsFor(config, nowMs);
+ const commands = commandsFor(config, nowMs, activeClockOffsetSeconds);
status.dataset.state = phase;
if (phase === "before") {
@@ -963,9 +1102,14 @@
DEFAULTS: DEFAULTS,
VALID_BANDWIDTHS: VALID_BANDWIDTHS,
SCHEDULE_HORIZON_MS: SCHEDULE_HORIZON_MS,
+ SCHEDULER_EPOCH_MAX: SCHEDULER_EPOCH_MAX,
EARLY_JOIN_MS: EARLY_JOIN_MS,
CLOCK_RESET_COMMAND: CLOCK_RESET_COMMAND,
PresetTestError: PresetTestError,
+ parseNodeClock: parseNodeClock,
+ nodeClockOffsetSeconds: nodeClockOffsetSeconds,
+ schedulerEpochs: schedulerEpochs,
+ formatClockOffset: formatClockOffset,
configFromSearch: configFromSearch,
configFromGenerator: configFromGenerator,
isDefaultPreset: isDefaultPreset,
diff --git a/docs/_stylesheets/preset_test.css b/docs/_stylesheets/preset_test.css
index 730332dd..43cada35 100644
--- a/docs/_stylesheets/preset_test.css
+++ b/docs/_stylesheets/preset_test.css
@@ -244,6 +244,64 @@
font-family: var(--md-code-font-family);
}
+.preset-test-clock-override {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.8rem;
+ align-items: end;
+ margin: 1rem 0 0;
+}
+
+.preset-test-clock-override label {
+ display: grid;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.preset-test-clock-override label > span {
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.preset-test-clock-override input {
+ 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-code-bg-color);
+ color: var(--md-default-fg-color);
+ font: inherit;
+}
+
+.preset-test-clock-override input:focus {
+ border-color: var(--preset-cyan);
+ outline: 2px solid color-mix(in srgb, var(--preset-cyan) 24%, transparent);
+ outline-offset: 1px;
+}
+
+.preset-test-clock-override small,
+.preset-test-clock-status {
+ color: var(--md-default-fg-color--light);
+ font-size: 0.76rem;
+ line-height: 1.4;
+}
+
+.preset-test-clock-status {
+ margin: 0.5rem 0 0;
+}
+
+.preset-test-clock-status[data-state="adjusted"] {
+ color: var(--preset-accent);
+ font-weight: 650;
+}
+
+.preset-test-clock-status[data-state="error"] {
+ color: var(--md-code-hl-number-color);
+ font-weight: 650;
+}
+
.preset-test-generator-disclosure {
border: 1px solid var(--preset-border);
border-radius: 0.65rem;
@@ -510,6 +568,7 @@
.preset-test-grid,
.preset-test-grid--clock,
.preset-test-clock-check,
+ .preset-test-clock-override,
.preset-test-generator-layout,
.preset-test-generator-fields {
grid-template-columns: 1fr;
diff --git a/docs/preset_test.md b/docs/preset_test.md
index c0f72105..bd6cfa0c 100644
--- a/docs/preset_test.md
+++ b/docs/preset_test.md
@@ -82,6 +82,36 @@ Open a generated URL to see that test's status, instructions, and commands.
+
+
+ Node clock reported by clock (UTC, optional)
+
+
+ Paste the node's clock reply immediately after it arrives.
+ The firmware form 02:42 - 22/9/2026 UTC is also accepted.
+ Leave this blank to keep the normal UTC schedule epochs unchanged.
+
+
+ Apply clock conversion
+
+
+ No node-clock correction is applied. Scheduled commands use the normal UTC epochs.
+
+
+
+ This changes only the absolute tempradioat and
+ tempradioat2 epochs shown on this page; it does not issue a
+ clock-setting command. The correction is for this node only. If the node
+ clock later syncs or jumps, delete and queue the schedule again.
+
+
Remote admin session
@@ -111,6 +141,10 @@ clock
and then add the Companion tempradioat2 schedule again. The
reboot clears any pending scheduled entries.
+
+ If resetting or correcting this remote node's clock is not safe, leave
+ it unchanged and use the optional node-clock conversion above instead.
+
clkreboot
Copy clock reset command
@@ -167,7 +201,9 @@ clock
tempradioat. A Companion can instead schedule its second
profile with
tempradioat2. Both commands use the exact UTC
epochs below, switch at the common start, and restore saved settings at
- the common end. Room-server and sensor roles use Option 1 instead.
+ 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 Option 1 instead.
diff --git a/mkdocs.yml b/mkdocs.yml
index edb9fdf6..8b2b4299 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -22,7 +22,7 @@ extra_css:
- _stylesheets/telemetry_decoder.css
- _stylesheets/management_decoder.css
- _stylesheets/filter_tool.css
- - _stylesheets/preset_test.css?v=20260916-3
+ - _stylesheets/preset_test.css?v=20260921-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=20260921-2
+ - _javascript/preset_test.js?v=20260921-3
diff --git a/test/test_preset_test.js b/test/test_preset_test.js
index af644c97..5728adb9 100644
--- a/test/test_preset_test.js
+++ b/test/test_preset_test.js
@@ -14,6 +14,10 @@ assert.doesNotMatch(pageSource, / tool.parseNodeClock("02:42 22/9/2026"),
+ /node clock must be HH:mm DD\/M\/YYYY UTC/
+);
+assert.throws(
+ () => tool.parseNodeClock("25:42 22/9/2026 UTC"),
+ /real UTC date and time/
+);
+assert.throws(
+ () => tool.parseNodeClock("02:42 29/2/2025 UTC"),
+ /real UTC date and time/
+);
+
assert.strictEqual(config.startEpoch, 1790035200);
assert.strictEqual(config.endEpoch, 1790208000);
assert.strictEqual(config.endEpoch - config.startEpoch, 48 * 60 * 60);
@@ -70,6 +99,40 @@ assert.strictEqual(
"set tempradioat 911.3,500,8,7,1790035200,1790208000\nget tempradioat"
);
+const clockAdjustedLink = tool.configFromSearch(
+ "?start=2026-09-22T00:00:00.000Z&end=2026-09-24T00:00:00.000Z" +
+ "&tz=America%2FLos_Angeles&freq=910.3&bw=500&sf=8&cr=7&tx=22"
+);
+const clockOffset = tool.nodeClockOffsetSeconds(parsedNodeClock, observedAt);
+const adjustedEpochs = tool.schedulerEpochs(clockAdjustedLink, clockOffset);
+assert.deepStrictEqual(adjustedEpochs, {
+ startEpoch: 1790046000,
+ endEpoch: 1790218800,
+});
+const adjustedCommands = tool.commandsFor(
+ clockAdjustedLink,
+ clockAdjustedLink.startMs,
+ clockOffset
+);
+assert.strictEqual(
+ adjustedCommands.primaryScheduled,
+ "set tempradioat 910.3,500,8,7,1790046000,1790218800\nget tempradioat"
+);
+assert.strictEqual(
+ adjustedCommands.companionScheduled,
+ "set radio2.cross on\n" +
+ "set tempradioat2 910.3,500,8,7,rxtx,1790046000,1790218800\n" +
+ "get tempradioat2"
+);
+assert.strictEqual(
+ adjustedCommands.stockNow,
+ tool.commandsFor(clockAdjustedLink, clockAdjustedLink.startMs).stockNow
+);
+assert.throws(
+ () => tool.schedulerEpochs(clockAdjustedLink, tool.SCHEDULER_EPOCH_MAX),
+ /outside the firmware range/
+);
+
const browserZoneFallback = tool.configFromSearch("", "America/New_York");
assert.strictEqual(browserZoneFallback.tz, "America/New_York");
assert.strictEqual(