mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 14:07:58 +00:00
docs: compensate scheduled radio for node clock
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+37
-1
@@ -82,6 +82,36 @@ Open a generated URL to see that test's status, instructions, and commands.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preset-test-clock-override">
|
||||
<label>
|
||||
<span>Node clock reported by <code>clock</code> (UTC, optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-role="node-clock-input"
|
||||
placeholder="02:42 22/9/2026 UTC"
|
||||
>
|
||||
<small>
|
||||
Paste the node's <code>clock</code> reply immediately after it arrives.
|
||||
The firmware form <code>02:42 - 22/9/2026 UTC</code> is also accepted.
|
||||
Leave this blank to keep the normal UTC schedule epochs unchanged.
|
||||
</small>
|
||||
</label>
|
||||
<button type="button" data-action="apply-node-clock">Apply clock conversion</button>
|
||||
</div>
|
||||
<p class="preset-test-clock-status" data-role="node-clock-status" aria-live="polite">
|
||||
No node-clock correction is applied. Scheduled commands use the normal UTC epochs.
|
||||
</p>
|
||||
|
||||
<p class="preset-test-note">
|
||||
This changes only the absolute <code>tempradioat</code> and
|
||||
<code>tempradioat2</code> 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.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-grid preset-test-grid--clock">
|
||||
<article class="preset-test-card">
|
||||
<h3>Remote admin session</h3>
|
||||
@@ -111,6 +141,10 @@ clock</code></pre>
|
||||
and then add the Companion <code>tempradioat2</code> schedule again. The
|
||||
reboot clears any pending scheduled entries.
|
||||
</p>
|
||||
<p>
|
||||
If resetting or correcting this remote node's clock is not safe, leave
|
||||
it unchanged and use the optional node-clock conversion above instead.
|
||||
</p>
|
||||
<pre><code data-command="reset-clock">clkreboot</code></pre>
|
||||
<button type="button" data-copy-command="reset-clock">Copy clock reset command</button>
|
||||
</article>
|
||||
@@ -167,7 +201,9 @@ clock</code></pre>
|
||||
<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. 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.
|
||||
</p>
|
||||
|
||||
<div class="preset-test-grid">
|
||||
|
||||
+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=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
|
||||
|
||||
@@ -14,6 +14,10 @@ assert.doesNotMatch(pageSource, /<select name="tz"/);
|
||||
assert.match(pageSource, /data-role="test-content" hidden/);
|
||||
assert.match(pageSource, /data-role="test-content-footer" hidden/);
|
||||
assert.match(pageSource, /data-role="url-builder-disclosure" open/);
|
||||
assert.match(pageSource, /data-role="node-clock-input"/);
|
||||
assert.match(pageSource, /placeholder="02:42 22\/9\/2026 UTC"/);
|
||||
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, /Simple Repeater primary radio/);
|
||||
@@ -35,6 +39,31 @@ assert.strictEqual(tool.hasPresetParameters("?freq=911.3"), true);
|
||||
assert.strictEqual(tool.hasPresetParameters("?tz=UTC"), true);
|
||||
assert.strictEqual(tool.hasPresetParameters("?start=bad"), true);
|
||||
|
||||
const observedAt = Date.parse("2026-09-21T23:42:37.000Z");
|
||||
const parsedNodeClock = tool.parseNodeClock("02:42 22/9/2026 UTC");
|
||||
assert.strictEqual(parsedNodeClock, 1790044920);
|
||||
assert.strictEqual(
|
||||
tool.parseNodeClock("02:42 - 22/9/2026 UTC"),
|
||||
parsedNodeClock
|
||||
);
|
||||
assert.strictEqual(tool.parseNodeClock(""), null);
|
||||
assert.strictEqual(tool.nodeClockOffsetSeconds(parsedNodeClock, observedAt), 10800);
|
||||
assert.strictEqual(tool.nodeClockOffsetSeconds(null, observedAt), 0);
|
||||
assert.strictEqual(tool.formatClockOffset(10800), "3h");
|
||||
assert.strictEqual(tool.formatClockOffset(-90), "1m");
|
||||
assert.throws(
|
||||
() => 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(
|
||||
|
||||
Reference in New Issue
Block a user