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

+ 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
@@ -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, /