Skip to content

Commit 981be19

Browse files
committed
Make Smart Dispatch worker authoritative
1 parent 99c194e commit 981be19

13 files changed

Lines changed: 459 additions & 50 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99
tag:
1010
description: Release tag
1111
required: true
12-
default: v2026.08.24.1
12+
default: v2026.09.01.1
1313

1414
permissions:
1515
contents: write

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
## Unreleased
44

5+
## v2026.09.01.1 — 2026-09-01
6+
7+
- Make the Raspberry Pi worker the single source of truth for Octopus Smart
8+
Dispatch windows; Pi dashboards no longer make an independent dispatch query.
9+
- Publish each new worker dispatch revision as syncing before immediately
10+
rebuilding and writing the FoxESS scheduler.
11+
- Mark a dispatch revision confirmed only after the FoxESS REST readback exactly
12+
matches the requested conflict-resolved schedule.
13+
- Reject stale FoxESS confirmations when Octopus replaces a dispatch while an
14+
earlier scheduler write is still in flight.
15+
- Show waiting, syncing, confirmed, failed, and disabled protection states in
16+
every Pi dashboard, refreshed from the worker every five seconds.
17+
- Add Linux integration coverage for worker-only updates, protected dashboard
18+
reads, rapid dispatch replacement, stale confirmation rejection, and invalid
19+
dispatch data.
20+
521
## v2026.08.24.1 — 2026-08-24
622

723
- Round Smart Dispatch windows outward to 30-minute Octopus slots, including

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ FoxESS merges compatible periods, the Mode Scheduler identifies the combined
8181
period as **Smart Dispatch + Weekly Charge** or **Smart Dispatch + Target
8282
Charge** so the protection remains visible.
8383

84+
On Raspberry Pi, the background worker is the single source of truth for Smart
85+
Dispatch. A new Octopus revision is immediately marked **SYNCING FOXESS**, sent
86+
to the official FoxESS REST scheduler, and marked **FOXESS CONFIRMED** only
87+
after an exact scheduler readback. Mobile, desktop, and Pi dashboards display
88+
that worker snapshot instead of querying Octopus independently. If Octopus
89+
changes a dispatch while an older FoxESS write is still being confirmed, the
90+
older result is rejected and the newest revision remains authoritative. An
91+
active charge is labelled **PROTECTED** only after confirmation and when the
92+
effective FoxESS mode is Forced Charge.
93+
8494
## What you need
8595

8696
- Your Octopus account number (`A-...`) and API key (`sk_live_...`) from

‎RELEASE_NOTES.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,36 @@
1+
## Worker-authoritative Smart Dispatch protection v2026.09.01.1
2+
3+
### Fixed
4+
5+
- Makes the Raspberry Pi worker the only Octopus Smart Dispatch consumer. Every
6+
Pi dashboard now displays the exact dispatch snapshot used to program FoxESS,
7+
eliminating mismatches caused by independent browser and worker API polls.
8+
- Publishes a changed dispatch revision as **SYNCING FOXESS** before immediately
9+
rebuilding and writing the conflict-resolved FoxESS schedule.
10+
- Marks a revision **FOXESS CONFIRMED** only after the official FoxESS REST API
11+
returns the exact schedule fingerprint requested by the worker.
12+
- Rejects a late confirmation for an older dispatch revision when Octopus
13+
changes the schedule during an in-flight FoxESS write; the newest revision
14+
remains authoritative and is applied next.
15+
- Shows explicit waiting, syncing, failed, disabled, confirmed, and protected
16+
states instead of presenting an unconfirmed dispatch as safely scheduled.
17+
18+
### Safety behaviour
19+
20+
- The worker still polls Octopus at most every 15 seconds and begins the FoxESS
21+
write before tariff refresh work completes.
22+
- Dashboards refresh the worker snapshot every five seconds and perform a prompt
23+
FoxESS readback after confirmation.
24+
- An active dispatch is labelled **PROTECTED** only when the worker revision is
25+
confirmed and the effective FoxESS mode is Forced Charge.
26+
27+
### Validation
28+
29+
- Covers worker-only snapshot publication, authenticated dashboard reads,
30+
dispatch normalization, rapid revision replacement, stale-result rejection,
31+
confirmed scheduler fingerprints, the standalone web build, and the complete
32+
Raspberry Pi release package.
33+
134
## Smart Dispatch priority and immediate REST sync v2026.08.24.1
235

336
### Fixed

‎linux/README-RASPBERRY-PI.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ address directly. Every client reads the Pi's current state and sends
6262
deliberate changes back to the Pi; it is not a pixel-by-pixel screen mirror.
6363
Only the loopback worker performs unattended schedule writes.
6464

65+
The worker is also the single source of truth for Octopus Smart Dispatch. It
66+
publishes every changed dispatch revision as syncing, immediately writes the
67+
matching conflict-resolved FoxESS schedule, and publishes confirmed status only
68+
after an exact official-REST scheduler readback. All dashboards display this
69+
worker snapshot and refresh it every five seconds. A stale confirmation for an
70+
older Octopus revision is rejected, and the dashboard clearly distinguishes
71+
**SYNCING FOXESS**, **FOXESS CONFIRMED**, and **FOXESS SYNC FAILED**.
72+
6573
Remote access can be configured with a private VPN or a DDNS hostname and
6674
router port forwarding. If using DDNS, place an authenticated HTTPS reverse
6775
proxy in front of the dashboard, forward only the HTTPS endpoint, keep access

‎linux/server.mjs‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const integrationStatus = {
2828
foxRest: { lastSuccessAt: null, lastErrorAt: null, lastError: null }
2929
};
3030
const liveWsPolicies = new Set(['on-demand', 'always', 'rest']);
31+
const dispatchSyncStatuses = new Set(['waiting', 'syncing', 'confirmed', 'failed', 'disabled']);
3132
let packageContents;
3233
try {
3334
packageContents = await readFile(path.join(appRoot, 'package.json'), 'utf8');
@@ -253,6 +254,39 @@ function getClientState(state) {
253254
};
254255
}
255256

257+
function normalizeDispatches(dispatches) {
258+
if (!Array.isArray(dispatches) || dispatches.length > 96) {
259+
throw Object.assign(new Error('Smart Dispatch data must be an array of at most 96 windows'), { statusCode: 400 });
260+
}
261+
return dispatches.map(dispatch => {
262+
const start = new Date(dispatch?.startDt);
263+
const end = new Date(dispatch?.endDt);
264+
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start) {
265+
throw Object.assign(new Error('Invalid Smart Dispatch window'), { statusCode: 400 });
266+
}
267+
return { startDt: start.toISOString(), endDt: end.toISOString() };
268+
}).sort((a, b) => a.startDt.localeCompare(b.startDt) || a.endDt.localeCompare(b.endDt));
269+
}
270+
271+
function dispatchFingerprint(dispatches) {
272+
return createHash('sha256').update(JSON.stringify(dispatches)).digest('hex');
273+
}
274+
275+
function getDispatchSnapshot(state = {}) {
276+
const snapshot = state.dispatchSnapshot || {};
277+
return {
278+
dispatches: Array.isArray(snapshot.dispatches) ? snapshot.dispatches : [],
279+
fingerprint: snapshot.fingerprint || dispatchFingerprint([]),
280+
status: dispatchSyncStatuses.has(snapshot.status) ? snapshot.status : 'waiting',
281+
fetchedAt: snapshot.fetchedAt || null,
282+
updatedAt: snapshot.updatedAt || null,
283+
confirmedAt: snapshot.confirmedAt || null,
284+
scheduleFingerprint: snapshot.scheduleFingerprint || null,
285+
error: snapshot.error || null,
286+
revision: Number(snapshot.revision || 0)
287+
};
288+
}
289+
256290
function relayUpstreamResponse(upstream, response) {
257291
return upstream.text().then(body => {
258292
response.writeHead(upstream.status, {
@@ -601,7 +635,7 @@ const server = http.createServer(async (request, response) => {
601635
});
602636
}
603637

604-
const protectedApi = ['/api/config', '/api/foxess', '/api/octopus'];
638+
const protectedApi = ['/api/config', '/api/dispatch-snapshot', '/api/foxess', '/api/octopus'];
605639
const isProtected = protectedApi.includes(pathname) || pathname.startsWith('/api/foxess/live');
606640
if (isProtected && !(await isAuthorized(request))) {
607641
return sendJson(response, 401, { error: 'Invalid LAN access code' });
@@ -648,6 +682,59 @@ const server = http.createServer(async (request, response) => {
648682
void foxessLive.reconcile({ force: true });
649683
return sendEmpty(response);
650684
}
685+
if (pathname === '/api/dispatch-snapshot' && request.method === 'GET') {
686+
return sendJson(response, 200, getDispatchSnapshot(await loadState()));
687+
}
688+
if (pathname === '/api/dispatch-snapshot' && request.method === 'POST') {
689+
if (!isLoopback(request) || !isExplicitWorkerRequest(request)) {
690+
return sendJson(response, 403, { error: 'Smart Dispatch state can only be updated by the Raspberry Pi worker' });
691+
}
692+
const update = await readJsonBody(request);
693+
if (!dispatchSyncStatuses.has(update.status)) {
694+
return sendJson(response, 400, { error: 'Invalid Smart Dispatch synchronization state' });
695+
}
696+
const dispatches = normalizeDispatches(update.dispatches || []);
697+
const fingerprint = dispatchFingerprint(dispatches);
698+
const current = await loadState();
699+
const previous = getDispatchSnapshot(current);
700+
701+
// A slower FoxESS confirmation must never overwrite a newer Octopus
702+
// schedule that arrived while the earlier scheduler write was in flight.
703+
if (['confirmed', 'failed'].includes(update.status) && previous.fingerprint !== fingerprint) {
704+
return sendJson(response, 409, {
705+
error: 'Stale Smart Dispatch synchronization result',
706+
currentFingerprint: previous.fingerprint
707+
});
708+
}
709+
710+
const scheduleFingerprint = String(update.scheduleFingerprint || '').slice(0, 2048) || null;
711+
const error = update.status === 'failed'
712+
? String(update.error || 'FoxESS did not confirm the Smart Dispatch schedule').slice(0, 240)
713+
: null;
714+
const unchanged = previous.fingerprint === fingerprint
715+
&& previous.status === update.status
716+
&& previous.scheduleFingerprint === scheduleFingerprint
717+
&& previous.error === error;
718+
if (unchanged) return sendJson(response, 200, { saved: false, ...previous });
719+
720+
const now = new Date().toISOString();
721+
const fetchedAt = previous.fingerprint === fingerprint && previous.fetchedAt
722+
? previous.fetchedAt
723+
: (Number.isFinite(new Date(update.fetchedAt).getTime()) ? new Date(update.fetchedAt).toISOString() : now);
724+
const snapshot = {
725+
dispatches,
726+
fingerprint,
727+
status: update.status,
728+
fetchedAt,
729+
updatedAt: now,
730+
confirmedAt: update.status === 'confirmed' ? now : null,
731+
scheduleFingerprint,
732+
error,
733+
revision: Date.now()
734+
};
735+
await saveState({ ...current, dispatchSnapshot: snapshot });
736+
return sendJson(response, 200, { saved: true, ...snapshot });
737+
}
651738
if (pathname === '/api/octopus' && request.method === 'POST') {
652739
try {
653740
return await proxyOctopusRequest(request, response);

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "octopus-foxess-smart-charging",
3-
"version": "2026.8.24.1",
3+
"version": "2026.9.1.1",
44
"private": true,
55
"type": "module",
66
"dependencies": {

‎scripts/test-foxess-live.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,10 @@ assert.equal(liveTelemetry.source, 'live-ws');
170170
assert.equal(liveTelemetry.values.SoC, 82);
171171
assert.equal(liveTelemetry.values.batDischargePower, 1.2);
172172
assert.equal(liveOpenApiCalls, 1);
173-
await new Promise(resolve => setTimeout(resolve, 35));
173+
const heartbeatDeadline = Date.now() + 250;
174+
while (liveHeartbeats < 3 && Date.now() < heartbeatDeadline) {
175+
await new Promise(resolve => setTimeout(resolve, 10));
176+
}
174177
assert.equal(liveFramesRequested, 1, 'Live WS should subscribe to the telemetry stream once');
175178
assert.ok(liveHeartbeats >= 3, 'Live WS should keep the transport alive with protocol heartbeats');
176179
assert.equal(liveService.getPublicStatus().source, 'live-ws');

‎scripts/test-linux.mjs‎

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,17 @@ assert.match(markupSource, /class="input-group pi-config-only"/);
8686
assert.match(markupSource, /id="octopus-account-toggle"/);
8787
assert.match(markupSource, /rel="apple-touch-icon"/);
8888
assert.match(markupSource, /rel="manifest"/);
89-
assert.match(markupSource, /styles\.css\?v=2026\.8\.24\.1/);
90-
assert.match(markupSource, /app\.js\?v=2026\.8\.24\.1/);
91-
assert.match(markupSource, /Build v2026\.08\.24\.1/);
89+
assert.match(markupSource, /styles\.css\?v=2026\.9\.1\.1/);
90+
assert.match(markupSource, /app\.js\?v=2026\.9\.1\.1/);
91+
assert.match(markupSource, /Build v2026\.09\.01\.1/);
9292
assert.match(stylesSource, /\.linux-runtime \.pi-config-only \{ display: none !important; \}/);
9393
assert.match(stylesSource, /\.account-number-row \.account-secret-control \.val \{ max-width: none; white-space: nowrap/);
9494
assert.match(appSource, /LINUX_OCTOPUS_ENDPOINT = '\/api\/octopus'/);
95+
assert.match(appSource, /LINUX_DISPATCH_SNAPSHOT_ENDPOINT = '\/api\/dispatch-snapshot'/);
96+
assert.match(appSource, /shouldFetchDispatches = !window\.linuxRuntime \|\| window\.linuxRole === 'worker'/, 'Only the worker may query Octopus plannedDispatches on Raspberry Pi');
97+
assert.match(appSource, /await publishLinuxDispatchSnapshot\(dispatches, dispatchEnabled \? 'syncing' : 'disabled'\)/, 'A new worker dispatch revision must be published before FoxESS synchronization');
98+
assert.match(appSource, /await publishLinuxDispatchSnapshot\([\s\S]*?isAutoDispatch \? 'confirmed' : 'disabled'/, 'The worker must publish only a REST-confirmed scheduler result');
99+
assert.match(appSource, /startLinuxDispatchSnapshotPoll/, 'Dashboards must follow the worker dispatch snapshot promptly');
95100
assert.match(appSource, /LINUX_FOX_LIVE_ENDPOINT = '\/api\/foxess\/live'/);
96101
assert.match(appSource, /startLinuxLiveTelemetryPoll/);
97102
assert.match(appSource, /octopusFoxessDashboardAccessCode/);
@@ -113,6 +118,8 @@ assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /\/
113118
assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /\/api\/service-status/);
114119
assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /\/api\/native-config/);
115120
assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /\/api\/live-demand/);
121+
assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /\/api\/dispatch-snapshot/);
122+
assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /Stale Smart Dispatch synchronization result/);
116123
assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /lanAccessRequired/);
117124
assert.match(await readFile(path.join(root, 'linux', 'server.mjs'), 'utf8'), /'Cache-Control': 'no-cache'/);
118125
assert.match(linuxBuildSource, /octopus-foxess\.desktop/);
@@ -276,6 +283,79 @@ try {
276283
});
277284
assert.equal(acceptedLiveDemand.status, 200);
278285

286+
const dispatchA = [
287+
{ startDt: '2026-09-01T22:00:00.000Z', endDt: '2026-09-01T22:30:00.000Z' }
288+
];
289+
const dispatchB = [
290+
{ startDt: '2026-09-02T00:00:00.000Z', endDt: '2026-09-02T02:00:00.000Z' }
291+
];
292+
const rejectedDispatchUpdate = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
293+
method: 'POST',
294+
headers: { 'Content-Type': 'application/json' },
295+
body: JSON.stringify({ dispatches: dispatchA, status: 'syncing' })
296+
});
297+
assert.equal(rejectedDispatchUpdate.status, 403, 'Only the loopback worker may publish Smart Dispatch state');
298+
299+
const workerHeaders = {
300+
'Content-Type': 'application/json',
301+
Referer: `http://127.0.0.1:${port}/?worker=1`
302+
};
303+
const syncingDispatchA = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
304+
method: 'POST',
305+
headers: workerHeaders,
306+
body: JSON.stringify({ dispatches: dispatchA, status: 'syncing', fetchedAt: '2026-09-01T20:00:00.000Z' })
307+
});
308+
assert.equal(syncingDispatchA.status, 200);
309+
const dispatchAState = await syncingDispatchA.json();
310+
assert.equal(dispatchAState.status, 'syncing');
311+
assert.deepEqual(dispatchAState.dispatches, dispatchA);
312+
313+
const unauthorizedDispatchRead = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
314+
headers: { Referer: `http://127.0.0.1:${port}/?client=1` }
315+
});
316+
assert.equal(unauthorizedDispatchRead.status, 401);
317+
const authorizedDispatchA = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
318+
headers: {
319+
Referer: `http://127.0.0.1:${port}/?client=1`,
320+
'X-Octopus-Access-Key': changedAccessKey
321+
}
322+
}).then(response => response.json());
323+
assert.equal(authorizedDispatchA.status, 'syncing');
324+
assert.equal(authorizedDispatchA.fetchedAt, '2026-09-01T20:00:00.000Z');
325+
326+
const syncingDispatchB = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
327+
method: 'POST',
328+
headers: workerHeaders,
329+
body: JSON.stringify({ dispatches: dispatchB, status: 'syncing' })
330+
});
331+
assert.equal(syncingDispatchB.status, 200);
332+
const staleDispatchAConfirmation = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
333+
method: 'POST',
334+
headers: workerHeaders,
335+
body: JSON.stringify({ dispatches: dispatchA, status: 'confirmed', scheduleFingerprint: 'schedule-a' })
336+
});
337+
assert.equal(staleDispatchAConfirmation.status, 409, 'A stale FoxESS confirmation must not replace a newer Octopus revision');
338+
const confirmedDispatchB = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
339+
method: 'POST',
340+
headers: workerHeaders,
341+
body: JSON.stringify({ dispatches: dispatchB, status: 'confirmed', scheduleFingerprint: 'schedule-b' })
342+
});
343+
assert.equal(confirmedDispatchB.status, 200);
344+
const confirmedDispatchState = await confirmedDispatchB.json();
345+
assert.equal(confirmedDispatchState.status, 'confirmed');
346+
assert.equal(confirmedDispatchState.scheduleFingerprint, 'schedule-b');
347+
assert.ok(confirmedDispatchState.confirmedAt);
348+
349+
const invalidDispatchUpdate = await fetch(`http://127.0.0.1:${port}/api/dispatch-snapshot`, {
350+
method: 'POST',
351+
headers: workerHeaders,
352+
body: JSON.stringify({
353+
dispatches: [{ startDt: 'invalid', endDt: 'also-invalid' }],
354+
status: 'syncing'
355+
})
356+
});
357+
assert.equal(invalidDispatchUpdate.status, 400);
358+
279359
const nativeConfig = await fetch(`http://127.0.0.1:${port}/api/native-config`).then(response => response.json());
280360
assert.equal(nativeConfig.accessRequired, true);
281361
assert.equal(nativeConfig.accessKey, changedAccessKey);

0 commit comments

Comments
 (0)