Skip to content

Commit 0f2fcda

Browse files
committed
Fix Smart Dispatch scheduler reconciliation
1 parent 289be5b commit 0f2fcda

6 files changed

Lines changed: 159 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Fixed Raspberry Pi Smart Dispatch synchronization so FoxESS receives dynamic
6+
charging periods in the next 24 hours, including slots that begin after
7+
midnight on the following calendar date.
8+
- Queued dispatch changes that arrive while a FoxESS scheduler write is being
9+
confirmed, so the newest schedule is applied immediately instead of being
10+
silently skipped until a later polling cycle.
11+
- Refresh the visible Active Mode Scheduler shortly after Octopus dispatches
12+
change, instead of leaving it stale until the normal 10-minute FoxESS poll.
13+
- Detect Smart Dispatch additions and removals in the Raspberry Pi worker within
14+
15 seconds, without increasing the standalone HTML edition's polling rate.
15+
- Refresh Octopus and the REST-confirmed FoxESS scheduler immediately when an
16+
iPhone or other LAN dashboard returns from the background.
17+
318
## v2026.08.01.3 — 2026-08-01
419

520
- Fix tariff charts escaping their dashboard column when the browser becomes

‎RELEASE_NOTES.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,28 @@ v2026.07.26.9:
421421

422422
- JavaScript syntax, scheduler, tariff-selection, chart, encrypted-state, proxy restriction, static-GUI, standalone build, and Raspberry Pi package checks run through `npm run check`.
423423
- GitHub Pages continues to publish `Octopus_IGO_Smart_Charging_Detector.html` as the root app.
424+
## Smart Dispatch scheduler reconciliation v2026.08.19.1
425+
426+
### Fixed
427+
428+
- Includes every valid Smart Dispatch in the next 24 hours, including
429+
after-midnight periods, when constructing the FoxESS schedule.
430+
- Queues a fresh scheduler evaluation if Octopus changes while FoxESS is still
431+
confirming the preceding write.
432+
- Removes obsolete dynamic Forced Charge periods promptly after Octopus removes
433+
a dispatch, while preserving intentional weekly charge periods.
434+
435+
### Improved
436+
437+
- The Raspberry Pi worker detects dispatch additions and removals within 15
438+
seconds and writes only when the calculated FoxESS schedule differs.
439+
- The Pi dashboard re-reads the confirmed scheduler after a dispatch change and
440+
refreshes immediately when an iPhone or LAN client returns to the foreground.
441+
- Live WS remains read-only telemetry; all scheduler changes use official
442+
FoxESS REST requests with confirmation readback.
443+
444+
### Validation
445+
446+
- Covers overnight dispatch selection, queued schedule reconciliation, Pi-only
447+
fast polling, mobile resume refresh, Live WS policy, Linux service behavior,
448+
and both release artifacts.

‎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.1.3",
3+
"version": "2026.8.19.1",
44
"private": true,
55
"type": "module",
66
"dependencies": {

‎scripts/test.mjs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ const utilities = new Function(`
4141
${extractFunction('getWeeklyModePeriods')}
4242
${extractFunction('getWeeklyForcedChargePeriods')}
4343
${extractFunction('getWeeklyForcedDischargePeriods')}
44+
${extractFunction('getUpcomingSmartDispatches')}
4445
${extractFunction('resolveAutomationMinute')}
4546
${extractFunction('buildScheduleGroupsFromTimeline')}
4647
${extractFunction('getAutoResumeSource')}
4748
${extractFunction('getAutoResumeUntil')}
4849
${extractFunction('isScheduleMinuteSuppressed')}
49-
return { window, isFoxScheduleActiveAt, getActiveFoxScheduleAt, getEffectiveFoxWorkMode, scheduleFingerprint, prepareFoxSchedulePayload, getActiveAgreement, selectActiveElectricityTariffs, getProductCodeFromTariffCode, getRateAtTime, getTariffSlotsForDate, getWeeklyForcedChargePeriods, getWeeklyForcedDischargePeriods, resolveAutomationMinute, buildScheduleGroupsFromTimeline, getAutoResumeSource, getAutoResumeUntil, isScheduleMinuteSuppressed };
50+
return { window, isFoxScheduleActiveAt, getActiveFoxScheduleAt, getEffectiveFoxWorkMode, scheduleFingerprint, prepareFoxSchedulePayload, getActiveAgreement, selectActiveElectricityTariffs, getProductCodeFromTariffCode, getRateAtTime, getTariffSlotsForDate, getWeeklyForcedChargePeriods, getWeeklyForcedDischargePeriods, getUpcomingSmartDispatches, resolveAutomationMinute, buildScheduleGroupsFromTimeline, getAutoResumeSource, getAutoResumeUntil, isScheduleMinuteSuppressed };
5051
`)();
5152

5253
const tariffNow = new Date('2026-07-26T12:00:00Z');
@@ -192,6 +193,21 @@ assert.deepEqual(
192193
);
193194
assert.deepEqual(utilities.getWeeklyForcedChargePeriods({ enabled: true, startTime: '05:30', endTime: '05:30', days: [1] }, new Date(2026, 6, 20, 12, 0)), []);
194195

196+
const eveningDispatchCheck = new Date(2026, 7, 19, 20, 21);
197+
const upcomingDispatches = utilities.getUpcomingSmartDispatches([
198+
{ startDt: '2026-08-19T21:30:00+01:00', endDt: '2026-08-19T22:00:00+01:00' },
199+
{ startDt: '2026-08-20T02:00:00+01:00', endDt: '2026-08-20T03:00:00+01:00' },
200+
{ startDt: '2026-08-20T03:30:00+01:00', endDt: '2026-08-20T04:00:00+01:00' },
201+
{ startDt: '2026-08-20T05:00:00+01:00', endDt: '2026-08-20T05:30:00+01:00' },
202+
{ startDt: '2026-08-20T21:30:00+01:00', endDt: '2026-08-20T22:00:00+01:00' },
203+
{ startDt: 'invalid', endDt: 'invalid' }
204+
], eveningDispatchCheck);
205+
assert.deepEqual(
206+
upcomingDispatches.map(dispatch => [dispatch.start.getDate(), dispatch.start.getHours(), dispatch.start.getMinutes()]),
207+
[[19, 21, 30], [20, 2, 0], [20, 3, 30], [20, 5, 0]],
208+
'The FoxESS schedule must include every valid dispatch in the next 24 hours, including slots after midnight'
209+
);
210+
195211
const dischargeOvernight = utilities.getWeeklyForcedDischargePeriods({
196212
enabled: true,
197213
startTime: '23:30',
@@ -270,6 +286,12 @@ assert.equal(utilities.isScheduleMinuteSuppressed(fulfilledWeekly, 'weekly', new
270286

271287
assert.doesNotMatch(source, /localStorage\.setItem\(['"](?:octoAcc|octoApi|foxSn|foxToken|gasUrl)['"]/);
272288
assert.match(source, /if \(refreshTimerId !== null\) clearInterval\(refreshTimerId\)/);
289+
assert.match(source, /function scheduleFoxScheduleReadbacks\(\)[\s\S]*?\[5000, 20000, 40000\]/, 'A changed Octopus dispatch must trigger prompt FoxESS REST readbacks in the visible dashboard');
290+
assert.match(source, /currentDispatchesStr !== lastDispatchesStr[\s\S]*?scheduleFoxScheduleReadbacks\(\)/, 'Dispatch changes must start the FoxESS scheduler readback sequence');
291+
assert.match(source, /function getEffectiveOctopusRefreshInterval\(\)[\s\S]*?window\.linuxRole === 'worker'[\s\S]*?LINUX_WORKER_OCTOPUS_INTERVAL_SECONDS/, 'The Pi worker must detect added and removed dispatches faster than the visible dashboard polling interval');
292+
assert.match(source, /document\.addEventListener\('visibilitychange'[\s\S]*?refreshLinuxDashboardAfterResume/, 'A resumed iPhone dashboard must immediately refresh its Octopus and FoxESS state');
293+
assert.match(source, /window\.addEventListener\('pageshow', refreshLinuxDashboardAfterResume\)/, 'A restored iOS page must immediately refresh its scheduler state');
294+
assert.match(source, /if \(!window\.linuxRuntime \|\| window\.linuxRole === 'worker' \|\| !activeCredentials\)/, 'Pi dashboard resume handling must not affect the standalone HTML edition');
273295
assert.match(source, /async function fetchJson/);
274296
assert.match(source, /dispatchActiveNow/, 'Auto-Resume must not cancel an active Smart Dispatch');
275297
assert.match(source, /if \(isAutoPrice && window\.importRates\?\.length\)/, 'Target-price charging must use import rates');
@@ -284,6 +306,8 @@ assert.match(source, /getElementById\('importPriceChart'\)/, 'The import tariff
284306
assert.match(source, /getElementById\('exportPriceChart'\)/, 'The export tariff must use its own chart');
285307
assert.match(source, /window\.linuxRole === 'worker'/, 'Only the Raspberry Pi worker may run unattended actions');
286308
assert.match(source, /if \(!btn && !canRunAutomaticActions\(\)\) return false/, 'LAN dashboards must not duplicate unattended schedule writes');
309+
assert.match(source, /if \(isCurrentlyUpdatingMode\) \{[\s\S]*?pendingAutomationEvaluation = true;/, 'A dispatch refresh during a FoxESS write must queue the newest automation schedule');
310+
assert.match(source, /if \(pendingAutomationEvaluation\) \{[\s\S]*?evaluateLocalAutomations\(null, true\)/, 'A queued automation schedule must run as soon as the current FoxESS write completes');
287311
assert.match(source, /gasUrl: '\/api\/foxess'/, 'Raspberry Pi credentials must use the local FoxESS relay');
288312
assert.match(source, /scheduleFetchButton\.style\.display = hideManualFetch \? 'none' : ''/, 'Live WS must hide the redundant scheduler Fetch Now button');
289313
assert.match(source, /async function setLiveWsPolicy\(policy\)/, 'The Mobile and LAN dashboard must support all three Live WS policies');

‎src/app.js‎

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ let octoCountdown = 60;
1010
let foxRefreshInterval = 600;
1111
let foxCountdown = 600;
1212
let refreshTimerId = null;
13+
let foxScheduleReadbackTimeoutIds = [];
1314
let linuxConfigPollId = null;
1415
let linuxLiveTelemetryPollId = null;
1516
let linuxLiveTelemetryBusy = false;
1617
let activeCredentials = null;
18+
let pendingAutomationEvaluation = false;
1719

1820
const CREDENTIAL_STORAGE_KEY = 'encryptedCredentialsV1';
1921
const CREDENTIAL_DB_NAME = 'octopusFoxessSecureStorage';
@@ -22,6 +24,7 @@ const LINUX_CONFIG_ENDPOINT = '/api/config';
2224
const LINUX_OCTOPUS_ENDPOINT = '/api/octopus';
2325
const LINUX_FOX_LIVE_ENDPOINT = '/api/foxess/live';
2426
const LINUX_ACCESS_STORAGE_KEY = 'octopusFoxessDashboardAccessCode';
27+
const LINUX_WORKER_OCTOPUS_INTERVAL_SECONDS = 15;
2528
const DASHBOARD_SECTION_VISIBILITY_KEY = 'dashboardSectionVisibilityV1';
2629
const DASHBOARD_SECTION_IDS = {
2730
intelligentOctopus: 'section-intelligent-octopus',
@@ -407,6 +410,8 @@ function removeSavedAccessCode() {
407410
sessionStorage.removeItem('sessionCredentials');
408411
if (refreshTimerId !== null) clearInterval(refreshTimerId);
409412
refreshTimerId = null;
413+
foxScheduleReadbackTimeoutIds.forEach(clearTimeout);
414+
foxScheduleReadbackTimeoutIds = [];
410415
if (linuxLiveTelemetryPollId !== null) clearInterval(linuxLiveTelemetryPollId);
411416
linuxLiveTelemetryPollId = null;
412417
document.getElementById('dashboard')?.classList.remove('visible');
@@ -421,6 +426,53 @@ function removeSavedAccessCode() {
421426
document.getElementById('local-access-key')?.focus();
422427
}
423428

429+
function scheduleFoxScheduleReadbacks() {
430+
if (!window.linuxRuntime || window.linuxRole === 'worker' || !globalFoxSN || !globalGasUrl) return;
431+
432+
// The unattended worker and visible dashboard poll Octopus independently.
433+
// Give the worker up to one complete poll cycle to write the new schedule,
434+
// while refreshing the visible REST-confirmed state much sooner than 10 min.
435+
foxScheduleReadbackTimeoutIds.forEach(clearTimeout);
436+
foxScheduleReadbackTimeoutIds = [5000, 20000, 40000].map(delay => setTimeout(() => {
437+
fetchFoxSchedules().catch(error => {
438+
console.warn('FoxESS schedule readback after a dispatch change failed.', error);
439+
});
440+
}, delay));
441+
}
442+
443+
function getEffectiveOctopusRefreshInterval() {
444+
if (window.linuxRuntime && window.linuxRole === 'worker') {
445+
return Math.min(octoRefreshInterval, LINUX_WORKER_OCTOPUS_INTERVAL_SECONDS);
446+
}
447+
return octoRefreshInterval;
448+
}
449+
450+
let linuxDashboardResumeRefreshPromise = null;
451+
452+
function refreshLinuxDashboardAfterResume() {
453+
if (!window.linuxRuntime || window.linuxRole === 'worker' || !activeCredentials) return Promise.resolve();
454+
if (document.visibilityState === 'hidden') return Promise.resolve();
455+
if (linuxDashboardResumeRefreshPromise) return linuxDashboardResumeRefreshPromise;
456+
457+
linuxDashboardResumeRefreshPromise = (async () => {
458+
await initDashboard(true);
459+
scheduleFoxScheduleReadbacks();
460+
if (globalFoxSN && globalGasUrl) await fetchFoxSchedules();
461+
})().catch(error => {
462+
console.warn('Raspberry Pi dashboard refresh after resume failed.', error);
463+
}).finally(() => {
464+
linuxDashboardResumeRefreshPromise = null;
465+
});
466+
467+
return linuxDashboardResumeRefreshPromise;
468+
}
469+
470+
document.addEventListener('visibilitychange', () => {
471+
if (document.visibilityState === 'visible') refreshLinuxDashboardAfterResume();
472+
});
473+
window.addEventListener('pageshow', refreshLinuxDashboardAfterResume);
474+
window.addEventListener('focus', refreshLinuxDashboardAfterResume);
475+
424476
function startLinuxWorkerConfigPoll() {
425477
if (!window.linuxRuntime || window.linuxRole !== 'worker' || linuxConfigPollId !== null) return;
426478
linuxConfigPollId = setInterval(async () => {
@@ -1392,31 +1444,34 @@ async function initDashboard(isAutoRefresh = false, retryCount = 1) {
13921444
}
13931445

13941446
function startAutoRefreshTimer() {
1395-
window.nextOctoTime = Date.now() + (octoRefreshInterval * 1000);
1447+
const initialOctoInterval = getEffectiveOctopusRefreshInterval();
1448+
window.nextOctoTime = Date.now() + (initialOctoInterval * 1000);
13961449
window.nextFoxTime = Date.now() + (foxRefreshInterval * 1000);
13971450

13981451
if (refreshTimerId !== null) clearInterval(refreshTimerId);
13991452
refreshTimerId = setInterval(() => {
14001453
const now = Date.now();
1454+
const effectiveOctoInterval = getEffectiveOctopusRefreshInterval();
14011455

14021456
// 1. Octopus Logic Loop
14031457
octoCountdown = Math.max(0, Math.ceil((window.nextOctoTime - now) / 1000));
1404-
updateCircleTimer('octo-timer', octoCountdown, octoRefreshInterval);
1458+
updateCircleTimer('octo-timer', octoCountdown, effectiveOctoInterval);
14051459

14061460
if (now >= window.nextOctoTime) {
1407-
window.nextOctoTime = now + (octoRefreshInterval * 1000);
1461+
window.nextOctoTime = now + (effectiveOctoInterval * 1000);
14081462
initDashboard(true).then(() => {
14091463
const currentDispatchesStr = JSON.stringify(window.currentDispatches || []);
14101464
if (currentDispatchesStr !== lastDispatchesStr) {
14111465
lastDispatchesStr = currentDispatchesStr;
1466+
scheduleFoxScheduleReadbacks();
14121467
}
14131468
// Tariff slots, Smart Dispatches, and today's weekly windows can
14141469
// all change after an Octopus refresh. Rebuild the complete,
14151470
// conflict-resolved timeline once instead of updating one rule.
14161471
if (hasEnabledLocalAutomation()) {
14171472
evaluateLocalAutomations(null, true);
14181473
}
1419-
updateCircleTimer('octo-timer', octoCountdown, octoRefreshInterval);
1474+
updateCircleTimer('octo-timer', octoCountdown, effectiveOctoInterval);
14201475
});
14211476
}
14221477

@@ -1960,6 +2015,25 @@ function getWeeklyForcedDischargePeriods(config, now = new Date(), fdSoc = 11) {
19602015
return getWeeklyModePeriods(config, now, 'ForceDischarge', 'dischargeSchedule', fdSoc);
19612016
}
19622017

2018+
function getUpcomingSmartDispatches(dispatches, now = new Date(), horizonHours = 24) {
2019+
const nowTime = now.getTime();
2020+
const horizonTime = nowTime + horizonHours * 60 * 60 * 1000;
2021+
2022+
return (dispatches || [])
2023+
.map(dispatch => ({
2024+
...dispatch,
2025+
start: new Date(dispatch.startDt),
2026+
end: new Date(dispatch.endDt)
2027+
}))
2028+
.filter(dispatch =>
2029+
Number.isFinite(dispatch.start.getTime())
2030+
&& Number.isFinite(dispatch.end.getTime())
2031+
&& dispatch.end > now
2032+
&& dispatch.start < horizonTime
2033+
)
2034+
.sort((a, b) => a.start - b.start);
2035+
}
2036+
19632037
function resolveAutomationMinute(existingState, incomingState, options) {
19642038
options = options || {};
19652039
if (!incomingState) return existingState;
@@ -2130,6 +2204,12 @@ async function pushGroupsToFoxESS(groups, maxAttempts = 3) {
21302204
throw lastError || new Error('FoxESS schedule synchronization failed');
21312205
} finally {
21322206
isCurrentlyUpdatingMode = false;
2207+
if (pendingAutomationEvaluation) {
2208+
pendingAutomationEvaluation = false;
2209+
setTimeout(() => {
2210+
if (hasEnabledLocalAutomation()) evaluateLocalAutomations(null, true);
2211+
}, 0);
2212+
}
21332213
}
21342214
}
21352215

@@ -2211,8 +2291,12 @@ function resetAutomationApplyButton(button) {
22112291

22122292
// RUNS TO PUSH SCHEDULES VIA V3 API INSTEAD OF V0 LIVE-TOGGLE
22132293
async function evaluateLocalAutomations(btn = null, isStartup = false) {
2214-
if (!globalFoxSN || !globalGasUrl || isCurrentlyUpdatingMode) return;
2294+
if (!globalFoxSN || !globalGasUrl) return;
22152295
if (!btn && !canRunAutomaticActions()) return false;
2296+
if (isCurrentlyUpdatingMode) {
2297+
pendingAutomationEvaluation = true;
2298+
return false;
2299+
}
22162300

22172301
if (btn) {
22182302
btn.textContent = "Syncing...";
@@ -2380,9 +2464,8 @@ async function evaluateLocalAutomations(btn = null, isStartup = false) {
23802464
// 3. Dispatch Check (Higher priority - overrides target-price and weekly charge blocks)
23812465
if (isAutoDispatch && window.currentDispatches) {
23822466
const soc = applyDispatchLimit ? dispatchSocLimit : 100;
2383-
window.currentDispatches.forEach(d => {
2384-
const s = new Date(d.startDt); const e = new Date(d.endDt);
2385-
if(e > now && s.getDate() === now.getDate()) fillTimeline(s, e, "ForceCharge", soc, "dispatch");
2467+
getUpcomingSmartDispatches(window.currentDispatches, now).forEach(dispatch => {
2468+
fillTimeline(dispatch.start, dispatch.end, "ForceCharge", soc, "dispatch");
23862469
});
23872470
}
23882471

0 commit comments

Comments
 (0)