# ESP32 Control Structure (BatterySolarMonitor)
## Purpose
This documents how `BatterySolarMonitor` (the FireBeetle 2 ESP32-E firmware)
does live solar forecasting, car-charger/hot-water load control, and status
display entirely on-device.
Two things the ESP32 still relies on the PC pipeline for, both loaded from
LittleFS data files at boot rather than hardcoded in source: the
shading-model lookup table (§7) and its Enphase credentials (§1) — see
[`quarterly_update_process.md`](quarterly_update_process.md) for that side.
All source referenced below lives in `BatterySolarMonitor/`.
---
## Contents
1. Credentials — `EnphaseAuth.h`
1a. Site config — `SiteSettings.h`
1b. Self-refresh — `EnlightenLoginFetch.h`
2. One shared background task for every internet-facing poll
3. Car charger / hot water: Fetch + Task + Instances pattern
4. Ownership tracking — `LoadControlState.h`
5. The control decision — `LoadControlTask.cpp`
5a. V2H trip recovery
5b. Blocking a start near the V2H window
6. Status display — `LoadStatusFormat.h/.cpp`
7. Live forecast + shading lookup
8. Average consumption forecast + battery SOC projection
9. Car charge history — `CarChargeHistory.h/.cpp`
10. Buzzer alarm — `BuzzerControl.h/.cpp`
11. Daily reboot — `DailyReboot.h/.cpp`
12. Debug output + Log Tail — `TimestampStream.h`, `DebugLogBuffer.h/.cpp`
13. Enlighten history backfill — `EnlightenHistoryFetch.h/.cpp`
14. Car charger / hot water wire protocols
15. Discharge-to-grid button — `DischargeToGridState.h`/`DischargeToGridFetch.h`/`DischargeToGridTask.h`
16. Hot Day — `HotDayState.h`/`ChargeFromGridState.h`/`ChargeFromGridFetch.h`/`HotDayTask.h`
---
## 1. Credentials — `EnphaseAuth.h`
Two Enphase credentials the ESP32 needs — the local IQ Gateway's JWT
(JSON Web Token — a signed, self-contained access token; here it's just
an opaque bearer credential the gateway issues and later verifies, not
something this project ever decodes or inspects itself) + IP
(`GatewayFetch.cpp`, live battery/solar/consumption readings) and the
Enlighten cloud session cookie + system ID (`EnlightenHistoryFetch.cpp`,
backfills today's already-elapsed history once near boot, then again
every 15 minutes for the rest of the day — §13 — since the local
gateway only ever gives "right now") — are **different credentials** for
**different APIs**, and expire on different clocks (the gateway JWT lasts
months; the Enlighten session cookie only ~14 days).
Hardcoding these as `static const char*` constants would mean updating
either one by manually pasting a fresh value into the ESP32 source and
recompiling + reflashing. Instead,
`EnphaseAuth.h`/`EnphaseAuth.cpp` load values from LittleFS
(`loadEnphaseAuth()`, called once from `setup()`, same pattern as
`ShadeFactorTable.cpp`'s `loadShadeFactorTable()`).
Two files, split by owner:
- **`/site_settings.csv`** — IDENTITY (`gateway_ip`, `enlighten_system_id`,
`enlighten_email`, `enlighten_password`, `gateway_serial`) — fixed
account/hardware config that can't be derived on-device. **PC-owned**:
lives at `../site_settings.csv` (one level above
`BatterySolarMonitorUpdate/`, kept outside `BatterySolarMonitor/data/`
since it holds the plaintext Enlighten account password among other
identity fields) as a genuine, entirely human-maintained config file --
nothing in the PC-side pipeline writes to it. Copied into
`BatterySolarMonitor/data/site_settings.csv` **by hand** whenever it's
edited, re-upload at the next
sync+flash, not a source edit. `loadEnphaseAuth()` requires all 5 of
these to report success. This same file also carries 8 further fixed per-site fields
(latitude, longitude, panel tilt/azimuth, system kW, timezone, car
charger/hot water LAN IPs) — see §1a below; `EnphaseAuth.h`/
`SiteSettings.h` are two independent loaders sharing the one file, each
reading only its own keys.
- **`/auth_token.csv`** — LIVE TOKENS (`gateway_jwt`, `enlighten_session`)
— short-lived credentials that expire and need refreshing.
**Device-owned** — see Self-refresh below. `quarterly_update.py` does
**not** write this file at all (a PC-side copy would be stale within a
day); empty, or the file not existing yet, on a freshly-seeded device is
normal, not a load failure. Split into its own file specifically because
it's rewritten — `saveAuthToken()`
only ever touches these 2 fields.
`gatewayIp()`/`gatewayJwt()`/`enlightenSession()`/`enlightenSystemId()`
return the loaded values regardless of which file they came from — callers
don't need to know or care about the split. **Keep `site_settings.csv` out
of any public repo/screen-share** — `enlightenPassword()` is a real
account password. `auth_token.csv` is just as sensitive in principle
(`gatewayJwt()` grants local API access to the whole solar/battery/
consumption system), but the same caution doesn't apply the same way:
it only ever exists on the device's own LittleFS filesystem, never on
the PC side at all, so there's no repo or screen-share it could actually
leak through.
### 1a. Site config — `SiteSettings.h`
Fixed per-site physical/location/network config — coordinates, panel
install geometry, nameplate capacity, timezone, and the LAN IPs of the
two other local controllers this device talks to directly — loads from
`/site_settings.csv` (the same file §1's identity fields live in) via
`SiteSettings.cpp`'s `loadSiteConfig()`, called once from `setup()` —
same LittleFS-data-file pattern as `ShadeFactorTable.h`/`EnphaseAuth.h`,
rather than scattering these as hardcoded constants across
`SolarForecastFetch.cpp`/`SunPosition.cpp` (`LAT`/`LON`),
`SolarForecast.h` (`SYSTEM_KW`), `ntpSupport.cpp`'s weak
`get_ntpSupport_DefaultTZ()` (POSIX TZ string), `CarChargerFetch.cpp`/
`HotWaterFetch.cpp` (their own `HOSTNAME` literals), and bare unnamed
literals in `SolarForecastFetch.cpp`'s URI string (tilt/azimuth).
Changing a site constant is a data-file re-upload, not a source edit +
recompile + reflash.
Eight keys, all required (`loadSiteConfig()` returns false, and logs the
full expected key list, if any are short): `latitude`, `longitude`,
`panel_tilt`, `panel_azimuth`, `system_kw`, `posix_timezone`,
`car_charger_ip`, `hot_water_ip`. `posix_timezone` (a POSIX TZ string)
feeds `SiteSettings.cpp`'s own strong override of `ntpSupport.cpp`'s weak
`get_ntpSupport_DefaultTZ()` declaration. `car_charger_ip`/`hot_water_ip` feed
`CarChargerFetch.cpp`'s/`HotWaterFetch.cpp`'s own `siteCarChargerIp()`/
`siteHotWaterIp()` calls — the pfodWeb Designer-generated
`pfodMainMenu.cpp`'s "EV Settings" link and the Enphase system ID in its
"Enphase Settings" link are also rebuilt from this same loaded config at
boot (`initDynamicMenuLabels()`, `pfodMainMenuApp.cpp`), rather than
keeping the Designer's own generated literal — see that function's own
comment.
**`open_meteo_timezone` (an IANA name) is not one of this file's keys** —
`SolarForecastFetch.cpp` does not feed an IANA name to Open-Meteo's
`timezone=` query parameter, because Open-Meteo's archive API applies a
single fixed, non-DST-aware UTC offset to an entire IANA-timezone
request (see `BatterySolarMonitorUpdate/docs/data_sources.md`'s
"Timezone handling" section for the full story — the same defect, same
fix, on both the PC and the device side). Instead, `SolarForecastFetch.cpp`
requests `timezone=UTC` and converts each returned hourly timestamp to
local wall-clock time itself, via `localtime_r()` — the device's global
`TZ` is already set correctly by `ntpSupport.cpp`'s `ESP_setTZ()` (via
`sitePosixTimezone()`, above), so no extra `setenv()`/`tzset()` call is
needed there. There's also no POSIX-TZ↔IANA-name conversion table on
this device, so a single POSIX TZ string (`posix_timezone`) serves both
this device's own clock and its Open-Meteo forecast query, with nothing
else needed. See §7 below for the fetch/parsing details.
The daily weather forecast (`temperature_2m_max`, `temperature_2m_min`,
`precipitation_sum`) rides along on the SAME request `SolarForecastFetch.cpp`
already makes for hourly solar GTI — no second HTTPS connection and one less poll on `InternetPollTask.cpp`'s tick order
(§2). `WeatherForecastInstances.h` (the `todayWeatherForecast`/
`tomorrowWeatherForecast` model instances, read by `Dwg_StatusApp.cpp`)
is populated by `SolarForecastTask.cpp`'s `checkSolarForecastPoll()`
under `lockSolarForecast()`/`unlockSolarForecast()` (`SolarForecastTask.h`).
`WeatherForecast.h`'s rainfall field is a single deterministic figure
(Open-Meteo's `precipitation_sum`) — `Dwg_StatusApp.cpp` shows it as
`~X.Xmm` (the `~` flagging it as an estimate, not a measured amount),
`---` for a genuine 0mm forecast.
`loadSiteConfig()` must run before `resetDefaultTZstr()` and before
anything that could reach `SolarForecastFetch.cpp`/`SunPosition.cpp` —
`BatterySolarMonitor.ino`'s `setup()` calls it right after
`loadEnphaseAuth()`, before `setupWiFi()`.
### 1b. Self-refresh — `EnlightenLoginFetch.h`
The device keeps `gatewayJwt()`/`enlightenSession()` current itself —
entirely device-managed from the first successful login onward;
`/site_settings.csv` (§1, human-maintained on the PC side) only ever
seeds the identity fields that make this possible. `checkEnlightenLoginPoll()` runs on the shared background task
(§2); each attempt (after a short startup-uptime settling window on first
call) does one of two things depending on what's already in
`/auth_token.csv`:
- **No existing token** (file missing/empty — a fresh device, or one that
just had its LittleFS data re-uploaded): builds one immediately via a
full 2-step login. POST email/password to
`enlighten.enphaseenergy.com/login/login.json` for a session_id + the
`_enlighten_4_session` cookie, then POST that session_id to
`entrez.enphaseenergy.com/tokens` for a fresh JWT.
- **Existing token present**: TESTED first, not blindly replaced.
`checkGatewayTokenValid()`/`checkEnlightenTokenValid()`
(`EnlightenLoginFetch.cpp`) each make one lightweight authenticated
request (`GET /ivp/meters` on the local gateway; `GET
/systems/{id}/energy_lifetime` on Enlighten — chosen because neither
needs a `date=` parameter, so neither depends on NTP having synced yet)
and classify the result three ways: confirmed valid (200), confirmed
invalid (401), or inconclusive (anything else — connect failure,
timeout, a different error). Only a **confirmed 401 on either check**
triggers a full rebuild (same login flow as above); an inconclusive
result is treated the same as valid, deliberately — a transient
connectivity blip during the test must never itself trigger an
unnecessary relogin, or "test first" would provide no benefit over
always rebuilding.
Either way, once a valid token is confirmed (tested-valid, or freshly
built), that's it for this boot — `DailyReboot.h`'s daily reboot naturally
re-triggers the next check tomorrow, comfortably inside the session
cookie's ~14-day freshness window. If a rebuild is needed and FAILS, it's
retried every `RETRY_INTERVAL_MS` (5 min) — the same interval as this
project's various `POLL_INTERVAL_MS` constants — **indefinitely, until one
succeeds**. There is no "give up until tomorrow": the still-cached
`gatewayJwt()`/`enlightenSession()` from the last successful login remain
valid and keep working the whole time (even if untested this cycle), so a
failed rebuild costs nothing beyond staying on the old (still good, or at
worst unconfirmed) credential a bit longer.
**Missing credentials** (`enlightenEmail()`/`enlightenPassword()` empty —
shouldn't happen, since `loadEnphaseAuth()` requires them, but could
follow a hand-edited/truncated `/site_settings.csv`): logged once as a
configuration fault and NOT retried, since a retry can't fix a missing
credential. Checked before the existing-token logic above, so it takes
priority over either branch.
**Real-usage feedback — `notifyAuthTokenRejected()`**: the once-per-boot
check only looks at the token's state at that one moment. If it was
genuinely still valid then but expires or gets revoked sometime later the
same day, nothing would otherwise notice until tomorrow's reboot.
`GatewayFetch.cpp` (all three of its endpoints) and
`EnlightenHistoryFetch.cpp` (`httpGetFromCandidates()`) each call
`notifyAuthTokenRejected()` whenever a REAL (non-validation) request comes
back with a confirmed 401. That resets the "done for this boot" state and
re-arms the attempt timer to fire immediately — since `checkGatewayPoll()`/
`checkEnlightenHistoryPoll()` both run earlier than `checkEnlightenLoginPoll()`
in `InternetPollTask.cpp`'s tick order (§2), a 401 detected this tick can
trigger a full re-check-and-rebuild before that same tick finishes. Guards
against notify calls piling up mid-outage: it's a no-op once a re-check is
already pending, so a run of repeated 401s from one ongoing problem (e.g.
`GatewayPollTask.cpp`'s own 10s retry cadence while `latestBatterySocPercent`
is stuck at its -1 sentinel) doesn't keep resetting `RETRY_INTERVAL_MS`'s
5-minute backoff once a rebuild attempt is already in flight.
On success, `setGatewayJwt()`/`setEnlightenSession()` (`EnphaseAuth.h`)
update the in-RAM values and `saveAuthToken()` persists them back to
`/auth_token.csv` on LittleFS — `site_settings.csv` is never touched by
this — without that persistence step, a self-refreshed token would only
last until the next midnight reboot wiped RAM.
DNS for both hosts is resolved once at boot (`resolveEnlightenLoginHosts()`,
called from `setup()` alongside `resolveSolarForecastHost()` — **main
thread only, never from a background task**. Resolving a hostname (as
opposed to connecting via an already-cached IP-literal) can call into
ESP-IDF's `dns_clear_cache()`, which is unsafe from any task other than
the one holding the lwIP/TCPIP core lock — a confirmed match to
[espressif/esp-idf#14287](https://github.com/espressif/esp-idf/issues/14287),
and the actual root cause (confirmed via a symbolicated crash backtrace)
of a real crash this project hit, which always originated from a solar
forecast fetch running on the background task. Resolving once, here, on
the main thread avoids that path entirely; every later connection then
uses the cached IP (an IP-literal string), which short-circuits before
ever reaching `dns_clear_cache()`. Every other DNS-resolving function in
this project (`resolveSolarForecastHost()`, `resolveEnlightenHosts()`,
§13) follows the same rule for the same reason).
`enlightenHostIP()` exposes the resulting cached IP for
`enlighten.enphaseenergy.com` to other files — added so
`DischargeToGridFetch.cpp` (§15) could reuse it instead of resolving the
exact same hostname a third time.
The one specific risk flagged in the source itself:
ESP32's `HTTPClient::collectHeaders()`/`header()` may only capture a
single value if the login response sends more than one `Set-Cookie`
header (the real `_enlighten_4_session` cookie might not be the one kept)
— `loginStep()` logs a specific warning if the cookie can't be found,
which is the first thing to check if self-refresh never succeeds.
---
## 2. One shared background task for every internet-facing poll
`InternetPollTask.cpp` runs a single FreeRTOS task that ticks every job in
turn, once a second:
```
checkGatewayPoll() -> checkSolarForecastPoll()
-> checkEnlightenHistoryPoll() -> checkAvgConsumptionPoll()
-> checkEnlightenLoginPoll() -> checkCarChargerPoll() -> checkHotWaterPoll()
-> checkLoadControlPoll() -> checkDischargeToGridPoll() -> checkDischargeToGridLogic()
```
`checkSolarForecastPoll()` fetches the daily weather forecast (temp
max/min, rainfall) in the same Open-Meteo request as the hourly solar
forecast.
Each job still owns its own "is it due yet" timer and interval — this file
just ticks all of them. **Why one shared task instead of one per job**: an
earlier bug (`GatewayFetch.cpp`'s `/ivp/ensemble/power` handler) showed the
local gateway's embedded HTTPS server breaks if two `WiFiClientSecure`
connections are open at once, even from different tasks. Running every poll
from one task guarantees only one internet-facing connection is ever open
at a time — no cross-task overlap is possible. Cost: a slow poll delays the
ones queued behind it in the same round, but every poll is already bounded
(~13s worst case), so a worst-case ~50s serialized delay (every job
coinciding on the same tick) is a rare, one-off event, not a sustained
problem. The normal idle cadence for every job is 5 minutes or longer —
`CarChargerTask.cpp`'s/`HotWaterTask.cpp`'s own post-toggle/V2H recheck
timers (§3, as short as 20s) are the exception, running far more often
for a short while after a toggle — but those are themselves "soon after,"
not hard-deadline, promises, so an occasional worst-case serialized delay
stacking on top of one is still tolerable at this timescale.
`checkLoadControlPoll()` runs **last**, deliberately — it reads the state
`checkCarChargerPoll()`/`checkHotWaterPoll()` (just above it in the same
tick) just published, so it's always acting on this cycle's freshest fetch,
not a stale one from before.
---
## 3. Car charger / hot water: Fetch + Task + Instances pattern
Both loads follow the same 3-file pattern, independently:
| Role | Car charger | Hot water |
|---|---|---|
| Raw device I/O | `CarChargerFetch.h/.cpp` (plain HTTP to the charger) | `HotWaterFetch.h/.cpp` (raw TCP pfod protocol to the controller) |
| Poll scheduling + publishing | `CarChargerTask.h/.cpp` | `HotWaterTask.h/.cpp` |
| Shared state (mutex-guarded) | `CarChargerInstances.h/.cpp` | `HotWaterInstances.h/.cpp` |
Each `*Task.cpp` polls its device on its own `pfodDelay` timer,
`POLL_INTERVAL_MS = 5 min`, and publishes the result into the matching
`*Instances` globals under a lock (`lockCarChargerState()`/
`unlockCarChargerState()`, and the hot-water equivalents) — the only way
any other file touches this state.
**Post-toggle recheck (20s) — refreshing the STATE, not re-running the
DECISION**: `requestCarChargerRecheck()`/`requestHotWaterRecheck()` re-arm
that device's own poll timer, here in `*Task.cpp`, to `RECHECK_DELAY_MS =
20s` instead of the normal 5 minutes. `LoadControlTask.cpp` calls these
right after every successful toggle, so the *real* device state gets
confirmed within that 20s for both devices — comfortably past either
one's own worst-case transition time (a few seconds for hot water, up to
~8s for the car charger. The car V2H turn on has it own timer, see below) — not left standing on the optimistic
post-toggle guess (§5) for up to 5 minutes.
This is a **completely different timer from `LoadControlTask.cpp`'s own
`EARLY_RECHECK_MS` (30s, §5)** — easy to conflate since both fire "soon
after a toggle", but they do different jobs on different schedules:
| | This 20s timer | `LoadControlTask`'s 30s timer |
|---|---|---|
| Lives in | `CarChargerTask.cpp`/`HotWaterTask.cpp` | `LoadControlTask.cpp` |
| Job | Re-fetch the device and overwrite the optimistic guess with the real state | Re-run the SOC (State of Charge — battery %) hysteresis decision logic (§5) |
| Runs | Once, 20s after a toggle | Every `EARLY_RECHECK_MS` until a normal-cadence cycle passes with nothing to defer |
Because `InternetPollTask.cpp` (§2) always runs `checkCarChargerPoll()`/
`checkHotWaterPoll()` before `checkLoadControlPoll()` in the same 1s tick,
and 30s > 20s, the control decision at t=30s is *guaranteed* to see the
real state the 20s timer already fetched ten seconds earlier — never the
optimistic placeholder — **for hot water, unconditionally** (its poll timer
only ever serves this one reason). This ordering is deliberate, not
incidental; see §4's note on why it also made a separate startup-grace
timer unnecessary.
For the car charger specifically, that "20s always lands by t=30s" guarantee
has one rare exception since the priority arbitration below was added: if a
higher-priority V2H recheck/schedule-follow-up (30s/60s) happens to already
be counting down in `CarChargerTask.cpp` the instant this 20s request is
made, the request is deferred rather than fired at t=20 — so `LoadControlTask`'s
own t=30s decision could in that coincidence still see the pre-toggle
placeholder. See the priority table just below for why that's the correct
trade-off rather than a bug.
**V2H schedule-boundary follow-up**: the charger's own `v2h_auto` on/off
schedule (`EV_Charger_V2H_switch/switchSettings.cpp`'s `handleAuto()`, see
§5a) switches entirely on that OTHER device, on its own clock —
`CarChargerTask.cpp`'s plain 5-minute poll has no awareness of it. A poll
landing seconds before the boundary (e.g. `14:01:03` for
`v2h_time_on=14:01`) would otherwise read the pre-flip state, with the
next poll a full 5 minutes away — the device log (and the Status display)
would show the stale `v2h=off` for most of that window even though the
schedule had already flipped it. `checkCarChargerPoll()` tracks
`lastV2hScheduleCheckMin` (local minutes-since-midnight as of the last poll
with a synced clock) and, each successful fetch where `v2hAutoActive` is
true, checks whether `v2hOnTimeMinutes`/`v2hOffTimeMinutes` was crossed
since that last check (`crossedBoundary()`, wraparound-safe at midnight). If
so, it re-arms its own poll timer for `V2H_SCHEDULE_FOLLOWUP_MS` (**60s**)
instead of the normal 5 minutes, so one extra fetch lands promptly after the
boundary; the next poll after that reverts to the normal cadence
automatically (that same boundary won't "cross" again).
That follow-up fetch only refreshes `CarChargerInstances.h` — on its own it
does nothing to make `LoadControlTask.cpp`'s independent `pollTimer`/
`Status:` log line reflect it any sooner (that file's poll cycle runs on a
completely separate schedule, §2/§5). See "Sync with `LoadControlTask.cpp`"
below for how that gap is closed.
**Sync with `LoadControlTask.cpp`**: `CarChargerTask.cpp`'s poll cadence is
not phase-aligned with `LoadControlTask.cpp`'s own independent
`pollTimer`, which without this sync could let `carCheckStart()` make a
start decision against a `car.v2hActive` reading that's nearly a full
poll-interval stale, right at the charger's own V2H schedule boundary
(confirmed against a real captured device log). The fix:
`checkCarChargerPoll()` calls `requestLoadControlPollNow()`
(`LoadControlTask.h`) **unconditionally after EVERY successful fetch** —
not just the schedule-boundary follow-up or the V2H-recovery recheck
(§5a). That call forces `checkLoadControlPoll()`'s **full** 4-phase
decision cycle to run within about a second, using this fetch's fresh
data. Every trigger does the same, correct thing rather than a lighter,
status-only refresh for some cases — an accelerated poll must not sneak
in an extra, off-cadence run of `carCheckStart()`'s V2H recovery retry
logic, and §5b's schedule-aware start guard is what makes it safe for
every trigger to take the full path.
Safe to call this on every successful fetch, not just special ones: each
call corresponds to genuinely fresh, distinct data (`InternetPollTask.cpp`'s
tick order, §2, guarantees `checkLoadControlPoll()` runs after
`checkCarChargerPoll()`/`checkHotWaterPoll()` in the same tick), and the 4
decision phases are idempotent against repeated fresh evaluation — e.g.
`carCheckStart()`'s V2H recovery retry clears `carV2hRecoveryPending()`
**before** sending its toggle, so a second call immediately after never
re-sends it. A **failed** fetch triggers nothing — `LoadControlTask.cpp`'s
own `V2H_RECOVERY_RECHECK_MS`/`EARLY_RECHECK_MS` reschedules (§5/§5a) remain
as the fallback for a persistently-unreachable charger, and
`LoadControlTask.cpp`'s own timer keeps running regardless either way (see
below).
**Why `LoadControlTask.cpp` still needs its own timer at all**, given this
sync: `checkCarChargerPoll()` is not its only input.
`checkLoadControlPoll()` also depends on fresh Gateway (SOC/house load) and
hot-water readings, and neither `GatewayPollTask.cpp` nor `HotWaterTask.cpp`
triggers it the same way — so a SOC (State of Charge — battery %) crossing a hysteresis threshold, or a
hot-water reading changing, still needs its own regular cadence to ever get
evaluated. More importantly, if the car charger goes unreachable and this
sync were the *only* trigger, `LoadControlTask.cpp` would never run again
until it came back — silently stopping HW auto-control too, an unrelated
failure domain. The car-fetch sync is a "wake up sooner when there's a good
reason to" layer on top of an otherwise still-necessary regular heartbeat,
not a replacement for it.
**Priority arbitration between the 3 car-charger recheck reasons**:
`CarChargerTask.cpp`'s `pollTimer` is one shared timer serving 4 different
reasons to wake up early. Without priority arbitration ("last caller
wins", i.e. `pollTimer.start(newDelay)` on every request unconditionally),
a V2H schedule-boundary follow-up armed for 60s (above) could be cut
short to 20s by an unrelated plain charger toggle
(`requestCarChargerRecheck()`) happening moments later, firing the
follow-up fetch before the schedule's own flip had necessarily even
landed — a real (if rare) problem. Each reason's delay is a *minimum
settle time*, not an arbitrary deadline, so a later request for a
SHORTER delay must never pre-empt an earlier, still-relevant request for
a LONGER one — but any of the 3 real reasons should freely override the
plain 5-minute idle default, since that's not a "reason" at all, just
what happens when nothing special is pending. `currentRecheckPriority`
ranks them accordingly:
| Priority | Reason | Delay | Needs that long because |
|---|---|---|---|
| 0 `RECHECK_NONE` | Idle default | `POLL_INTERVAL_MS` (5min) | Nothing special pending — always overridable |
| 1 `RECHECK_CHARGER` | Plain charger toggle | `RECHECK_DELAY_MS` (20s) | Relay settling (~8s) |
| 2 `RECHECK_V2H` | V2H recovery toggle-**on** | `V2H_RECHECK_DELAY_MS` (30s) | Turning V2H **on** settles slowly (~16s+: ~2 relay-settling cycles plus a few seconds for AC to actually start) — turning it off is almost instant |
| 3 `RECHECK_SCHEDULE_FOLLOWUP` | Schedule-boundary crossing | `V2H_SCHEDULE_FOLLOWUP_MS` (60s) | Margin past the charger's own minute-boundary flip |
`armPollTimerIfHigherPriority(delayMs, priority)` — used by
`requestCarChargerRecheck()`/`requestCarChargerV2hRecheck()` — only applies
a request whose priority is `>=` `currentRecheckPriority`; a lower-priority
request while a higher one is already counting down is silently dropped
(the wait already in flight keeps running). An *equal*-priority re-request
(e.g. a second V2H toggle before the first's 30s has elapsed) still restarts
the wait fresh from now — a legitimate debounce, not a pre-emption, since
the second toggle genuinely needs its own full settle time measured from
when IT happened. The authoritative end-of-poll decision inside
`checkCarChargerPoll()` itself (which reason applies to the *next* poll)
bypasses this gate entirely and sets both `pollTimer` and
`currentRecheckPriority` unconditionally — by the time a poll actually
runs, whatever reason armed it has been serviced, so what runs next is
decided fresh from that poll's own findings, not blocked by comparing
against its own now-resolved priority.
Dropping a lower-priority `requestCarChargerRecheck()`/
`requestCarChargerV2hRecheck()` request never loses anything worth having:
since "Sync with `LoadControlTask.cpp`" above now fires on *every*
successful fetch regardless of which reason armed it, whichever poll
actually runs next — even one firing later than requested, for a
higher-priority reason that pre-empted it — still calls
`requestLoadControlPollNow()` the moment it lands with fresh data.
**`CarChargerTask.cpp`'s energy accumulation**: tracks actual elapsed time
between polls (`everPolled`/`lastPollMs`, `millis()` subtraction) rather
than assuming a fixed `POLL_INTERVAL_MS` — needed once the 20s recheck could
make consecutive polls much closer together than 5 minutes; a fixed-interval
assumption would have over-credited energy by ~15x on a recheck that caught
the charger mid-charge.
---
## 4. Ownership tracking — `LoadControlState.h`
The raw ON/OFF a device reports (`manualOn` for HW, `chargerOn` for the
car) is **not evidence a human did anything** — `LoadControlTask.cpp`'s own
toggles flip that exact same bit. `LoadControlState.h` tracks a separate
`AutoStarted` flag per load — "did WE (this control loop) start this" —
persisted to LittleFS (`/load_control_state.txt`) so an unscheduled reboot
(WiFi-drop watchdog reset, brownout) doesn't lose track of a still-running
load and leave it running unsupervised. Only written on an actual
start/stop transition, not every poll (LittleFS write wear).
A second flag, `carV2hRecoveryPending`, is
LittleFS-persisted the same way — written **before** each V2H toggle it
drives, not after — so a reboot landing mid-recovery still reflects what
the code was about to do rather than losing track of V2H having been
switched off. See §5a for the full recovery sequence this flag drives.
One further transient window is a RAM-only `pfodDelay` timer in
`LoadControlTask.cpp` (not persisted — losing it to a rare reboot is
low-stakes, unlike the `AutoStarted`/`carV2hRecoveryPending` flags
themselves): `carNoAcCooldownTimer` —
15min pause after a no-240VAC stop before retrying, so a car that's
unplugged (or finished charging) doesn't get immediately re-toggled every
poll. Queried two ways, both non-mutating: `carCheckStart()`'s own gating
check reads `.remaining()` directly (it needs the actual millisecond
value, for the "Xmin remaining" log line), while the display flag
published every cycle (`carInNoAcCooldown`, §6) uses the plain boolean
`.isRunning()` — `pfodDelay`'s own purpose-built "is this window still
open" check. Neither ever uses `justFinished()`, which is a one-shot
edge: it reads true exactly once, on whichever single poll happens to
land on the delay actually ending, then false on every poll after that
until the timer is restarted. Once it has fired, the delay is
definitively no longer active — but calling it again can never tell you
that a second time, so it's structurally unusable as a repeatable "is it
still open" query.
There is deliberately **no separate startup-grace timer** here:
`carCheckStart()`/`carCheckStop()` run in the
same `checkLoadControlPoll()` invocation, and `carCheckStart()` sets
`ctx.done=true` on the cycle it starts the car, so `carCheckStop()` never
even reaches its 240VAC check that cycle. By the time it next can (30s
later, §5), §3's 20s device recheck has already supplied the real state —
so nothing extra is needed to protect against reading a stale value too
soon.
---
## 5. The control decision — `LoadControlTask.cpp`
`checkLoadControlPoll()` runs the SOC-hysteresis decision logic every
`POLL_INTERVAL_MS` (**5.5 min**, or `EARLY_RECHECK_MS` = 30s right after a
car start, so the 240VAC check gets re-evaluated promptly against real
data — see §3's table for how this 30s decision-rerun timer differs from
§3's 20s state-refresh timer). Four phases, run in this exact order every
cycle — the order matters, since a stop decision in one phase can defer a
decision in a later one:
`POLL_INTERVAL_MS` here is deliberately longer than `CarChargerTask.cpp`'s/
`HotWaterTask.cpp`'s own 5min `POLL_INTERVAL_MS` — it barely matters in the common case, since §3's
"Sync with `LoadControlTask.cpp`" re-arms this timer for ~0ms after every
successful car fetch, so it only actually fires as a **fallback** (charger
unreachable, or before the first-ever sync). Were it the *same* 5min as
`CarChargerTask.cpp`'s own cadence, that fallback could race a fetch due at
almost the same moment and run against data about to be superseded moments
later — exactly the staleness the sync mechanism exists to close. Being
strictly longer guarantees that by the time this fallback ever does fire,
`CarChargerTask.cpp` has already had a full cycle to complete and publish,
so even the fallback path sees its freshest available data.
1. **`hwCheckStop()`** — stop HW if we started it and SOC has dropped below
HW's own turn-off threshold; clear the `AutoStarted` flag (without
stopping) if HW went off externally while we still thought we owned it.
2. **`carCheckStart()`** — start the car if SOC is above the car/EV's own
turn-on threshold and nothing (cooldown, V2H-active) blocks it.
3. **`hwCheckStart()`** — start HW if SOC allows it — **skipped entirely**
if HW was just stopped this same cycle (re-evaluate next poll instead of
flip-flopping in one cycle).
4. **`carCheckStop()`** — stop the car if SOC has dropped, or if 240VAC
disappeared (car unplugged/finished) — **deferred** if HW was stopped
this same cycle, so at most one load changes state per poll.
**SOC** = State of Charge — battery %
**Per-load turn-on/turn-off thresholds — `BatteryChargeSettings.h`**: HW
and the car/EV each have their own independently-settable SOC% turn-on
threshold (`hwSocOnPercent()`/`evSocOnPercent()`), rather than a single
shared pair both loads
apply alike. Each load's turn-off threshold is never
independently stored — it's always exactly 5% below that load's own
turn-on threshold (`hwSocOffPercent()`/`evSocOffPercent()`), so there is
only one persisted number per load. Both default to 35% whenever the saved
`/battery_charge_settings.txt` is missing, unreadable, or either value
parses outside the valid 10-100% range — a partially-valid file is treated
the same as a missing one and BOTH thresholds reset to 35%, rewriting the
file fresh, rather than patching one field. Same mutex-guarded/lazy-loaded
LittleFS pattern as `LoadControlState.h`, and for the same reason: read
from `LoadControlTask.cpp`'s background task, written from whatever task
services a pfodWeb HTTP request.
**A turn-on threshold of 99% or 100% means "disabled"**:
`LoadControlTask.cpp`'s `startConditionsMet(socPct, onPercent)` and
`keepConditionsMet(socPct, offPercent, onPercent)` both return `false`
unconditionally whenever `onPercent >= 99`, regardless of `socPct`/
`offPercent` — the load never starts, and is stopped on the very next poll
if it was already running (`keepConditionsMet()` takes `onPercent` as a
third parameter for this, not just `offPercent`, so a load left running
from before the threshold was raised to 99/100 doesn't keep coasting on
the ordinary "SOC hasn't dropped below offPercent yet" check). This makes
literal what the sub-menu's own prompt text already promises ("100%
settings => always off") — otherwise only true in practice if `socPct`
happened to read exactly `>=100.0` — and extends the same guarantee to
99%.
Settable live from the pfodApp main menu's **"HW/EV - Battery Charge%"**
sub-menu (`SubMenu_HW_EV___Battery_Char.h/.cpp`, generated; hooked up in
`SubMenu_HW_EV___Battery_CharApp.cpp` the same weak-override way
`pfodMainMenuApp.cpp` hooks the main menu) — two sliders, 'HW On at' and
'EV Charging at', each 10-100%. Moving a slider calls
`setHwSocOnPercent()`/`setEvSocOnPercent()` immediately, which updates the
in-RAM cache on the spot (so `LoadControlTask.cpp` sees it on its very next
poll and the sub-menu itself reflects the move instantly) but does **not**
write to LittleFS on the spot — a drag sends many intermediate slider
messages in quick succession, each a distinct value, and a real flash write
on every one of them would be exactly the write-wear
`LoadControlState.h`'s own comment already flags as worth avoiding.
**Echoing the clamped value back, not the raw one**: the generated
`handleCmd()` stores whatever raw value a client sends straight into
`pwm_HW_On_at_var`/`pwm_EV_Charging_at_var` (unclamped) before calling the
hook, then echoes that same variable back via `sendMenuUpdate()`
afterward — a client sending a raw `0` (below this slider's own
configured 10 minimum) would otherwise get that same `0` echoed straight
back, even though `setHwSocOnPercent()` had already correctly clamped its
own persisted copy to 10, a real mismatch between what's displayed and
what's actually stored. `onHwOnAtChanged()`/`onEvChargingAtChanged()`
(`SubMenu_HW_EV___Battery_CharApp.cpp`) also write the clamped value
(`hwSocOnPercent()`/`evSocOnPercent()`, read back right after persisting)
into that same variable before returning, so the echo — and hence what
the client's own slider visually snaps to — always matches what's
actually persisted.
Instead each change (re)starts a 1s debounce timer
(`BatteryChargeSettings.cpp`'s `saveTimer`); `checkBatteryChargeSettingsSave()`,
called every main-thread `loop()` (`BatterySolarMonitor.ino`, alongside
`checkMains()`), commits to LittleFS once the settings have been still for
that long — one write per settling, not one per tick. The sub-menu's own button on the main menu
shows the live values, "**Set at HW *n*% EV *n*%**" — recomputed fresh
right before every send, same "no separate push needed, just never stale
next time it's sent" convention `pfodMainMenuApp.cpp`'s own
`[Set Time/Timezone]` label already uses for the same reason (its
underlying value can also change from elsewhere without this menu being
told directly). **Shows "OFF" instead of the percent**
for whichever of HW/EV has its turn-on threshold at 99% or 100% —
`updateBatteryChargeLabel()` (`pfodMainMenuApp.cpp`) checks this
independently per load, matching the "disabled" threshold above (e.g.
"Set at HW OFF EV 35%" if only HW is disabled), rather than showing a
literal "99%"/"100%" that would read as just a very high number, not
"never turns on".
Every actual toggle goes through
`toggleCarChargerIfEnabled()`/`toggleHotWaterIfEnabled()`, never the raw
`toggleCarCharger()`/`toggleHotWater()` directly — these enforce the single
`ENABLE_LOAD_CONTROL` safety switch (defined in `BatterySolarMonitor.ino`,
**must** be `false` if any other controller could also be driving the same
hardware) and, on success, both:
- **Optimistically update** `CarChargerInstances`/`HotWaterInstances` with
the intended post-toggle state (both the primary switch field and the
secondary indicator — `ac240vActive`/`powered` — so the display doesn't
show a stale "not charging"/"not heating" state, and so a stop doesn't
leave a stale `ac240vActive=true` that would otherwise trip the FAULT
branch below), without waiting for a re-fetch (an immediate re-fetch was
tried first and rejected — it just as often caught the device mid-
transition and published the wrong pre-toggle value with false
confidence).
- **Trigger the 20s recheck** (§3) so the real state supersedes that
assumption promptly.
### 5a. V2H trip recovery
The car charger's V2H (export) mode can **trip**: `v2h_state` stays `"on"`
while `ac240v_status` drops to `"inactive"` — the car's own V2H safety
circuit has taken its AC output offline. This is distinct from the car
V2H being manually turned off (`ac240vActive=false` with `v2hActive=false` too,
which `carCheckStop()`'s ordinary no-AC-stop branch already handles). A
trip is detected inside `carCheckStart()` (§5, phase 2), ahead of the
normal start logic, and runs its own retry loop:
1. **Trip detected** (`v2hActive && !ac240vActive`) → `setCarV2hRecoveryPending(true)`
is written to LittleFS **before** the toggle is sent, then V2H itself is
toggled OFF (`toggleV2hIfEnabled(false)`, POSTs `/v2hPower`). Nothing
else happens this cycle — no early recheck is requested here, since
turning V2H off is almost instant (same as the car charger's own
on/off toggle), so even the next normal 5-minute cycle is comfortably
longer than needed to see it take effect.
2. **Next cycle where `carV2hRecoveryPending()` is true and `v2hActive` now
reads false** (confirms the OFF toggle actually took): first checks
whether the charger's own schedule can be POSITIVELY CONFIRMED to want
V2H on right now (below); only if so does it go on to check
`v2hRelevantLoadKw()` — the whole-home metered load, **minus HW's own
assumed 3.5kW draw when HW is powered** (`HW_ELEMENT_LOAD_KW`,
`LoadStatusFormat.h`/`.cpp`) — since hot water is on a physically
separate circuit from the car charger/V2H and shouldn't count against
its capacity.
- **`v2hScheduleConfirmsOn()` returns false** (below — covers "can't
confirm", not just "confirmed off"): `setCarV2hRecoveryPending(false)`
— standing down, no toggle sent — rather than attempting recovery.
- **Load below `V2H_RETRY_MAX_LOAD_KW` (3.0kW)**: `setCarV2hRecoveryPending(false)`
written to LittleFS first, then V2H is toggled back ON
(`toggleV2hIfEnabled(true)`), optimistically assuming both `v2hActive`
and `ac240vActive` come back on (same optimistic-update convention as
§5's main toggle path) — reverted back to `true` if the toggle itself
fails to even send, so LittleFS never claims recovery succeeded when
it didn't send at all. On success, `requestCarChargerV2hRecheck()`
(`CarChargerTask.h`) arms a **30s** recheck — longer than the ordinary
20s post-toggle recheck (§3), since turning V2H back **on** takes
roughly 2 × the charger relay's own ~8s settling time plus a few
more seconds before AC actually starts (~16s+ in total) — unlike
turning it off, which is almost instant (same as the car charger's
own on/off toggle) — so the real post-recovery state gets confirmed
rather than trusting the optimistic guess indefinitely.
- **Load still ≥ `V2H_RETRY_MAX_LOAD_KW`**: stays pending, no toggle
sent, nothing to undo — retries the same check again next cycle,
indefinitely (there is no give-up).
**Catching an immediate re-trip promptly**: `requestCarChargerV2hRecheck()`
arms `CarChargerTask.cpp`'s 30s device recheck (§3's priority table). The
moment that recheck poll actually lands with fresh data, §3's general
"Sync with `LoadControlTask.cpp`" mechanism calls
`requestLoadControlPollNow()` — forcing a **full** `checkLoadControlPoll()`
cycle (all 4 phases) to run within about a second, so the re-trip check
re-evaluates against the CONFIRMED real result as soon as it's available,
rather than waiting on a fixed timer alone.
This explicit push replaces relying SOLELY on timing: a successful
recovery-on toggle still sets `CarCtx.v2hRecoveryAttempted = true`, which
still makes `checkLoadControlPoll()` re-arm its own poll timer to
`V2H_RECOVERY_RECHECK_MS` (**35s**, deliberately longer than the 30s device
recheck) — but that reschedule now only matters as a **fallback**, in case
the push never arrives (e.g. the charger is unreachable right when its
recheck is due, so `requestLoadControlPollNow()` is never called). In the
common case the explicit push fires first and this 35s timer's own eventual
firing is a no-op re-confirmation. Either way, a re-trip sends a fresh OFF
toggle (step 1 again) instead of sitting mis-reported as "recovered".
**Deferring to the charger's own V2H schedule, via a confirm-only gate**:
`EV_Charger_V2H_switch/switchSettings.cpp`'s own `handleAuto()` switches
V2H fully on/off at its own configured times (`v2h_time_on`/`v2h_time_off`,
when `v2h_auto_active`) — entirely independent of this device. Without
awareness of that schedule, a recovery left pending from an earlier real
trip could see the schedule's own Auto Off (a deliberate, scheduled
shutdown — V2H genuinely off) and
"recover" V2H straight back on again, fighting the schedule. This is why
`LoadControlTask.cpp`'s `v2hScheduleConfirmsOn(car)` is deliberately
fail-safe: recovery is attempted **only** when
the schedule can be positively confirmed to want V2H on, not merely
whenever it *isn't* confirmed off. Fetched alongside the rest of
`/api/state` (`CarChargerFetch.h`'s
`v2hAutoActive`/`v2hOnTimeMinutes`/`v2hOffTimeMinutes`), confirmation
requires ALL of:
- `v2hAutoActive` is true — a schedule that isn't even enabled confirms
nothing;
- the raw on-time-to-off-time gap (wrapping past midnight if needed)
exceeds **2 × the edge slack (4 minutes)** — equal on/off times, or
times within a few minutes of each other, confirm nothing. This isn't
just the degenerate equal-times case (which also matches `handleAuto()`'s
own guard, never firing a transition on equal times either): shrinking a
near-zero-width window inward by the slack from BOTH ends would flip the
shifted on-time past the shifted off-time, which the wraparound math
below would misread as a window covering nearly the whole 24 hours —
exactly backwards from the tiny/no window the user actually configured.
So this gap check must run *before* the slack is even applied, not rely
on the window math to sort itself out;
- the clock has NTP-synced (`!missedSNTPupdate()`) — "now" is meaningless
without a real clock;
- "now" falls inside the schedule's on-window, **`[onTime+2, offTime-2)`**
(wrapping past midnight if `onTime > offTime`) — **2 minutes of slack at
each edge** so this check doesn't race the charger's own per-minute
`handleAuto()` tick (an off time of 7:02 or later withdraws confirmation;
7:00/7:01 still confirm on. An on time of 14:00 doesn't confirm on until
14:02).
Any single one of these failing means "can't confirm — don't recover":
step 2 above clears `carV2hRecoveryPending` instead of attempting
recovery, standing down and leaving V2H entirely to the schedule (which
turns it back on itself at its own on-time — this file does nothing
further). Because this check re-reads THIS cycle's freshly-fetched
`car.v2hOnTimeMinutes`/`v2hOffTimeMinutes` every poll (never a value
cached from whenever the recovery started), it also self-corrects
immediately if either the off-time boundary is crossed, or the schedule's
times are edited, while a recovery is still pending.
**Display**: the pending flag is published every cycle as
`CarChargerInstances.h`'s `carInV2hRecovery`, so `formatCarState()`
(§6) keeps showing "V2H tripped off" throughout the whole recovery
attempt even though `v2hActive` itself reads false in the meantime — a
raw read would otherwise be indistinguishable from V2H simply being off.
The load figure is shown only while genuinely tripped off (`v2hLoadKw`,
the same HW-adjusted figure the retry threshold itself checks against),
never while V2H is active and exporting normally.
### 5b. Blocking a start near the V2H window
§5a's trip recovery is *reactive* — it only engages after a trip has
already happened. The same real incident behind §3's sync fix showed a
*proactive* gap too: `carCheckStart()`'s phase 3 (Start) only ever looked
at the cached `car.v2hActive`, with no independent awareness of the
charger's own V2H schedule. Even with §3's sync fix, a start decision can
still land in the window between the schedule's on-time and
`CarChargerTask.cpp`'s next actual fetch confirming it (that incident's own
follow-up fetch didn't land until a minute after `carCheckStart()` had
already started the car) — starting a NEW charge session there puts a
real physical relay in direct conflict with the charger's own V2H safety
circuit about to (or having just) taken over the same AC output.
`nearV2hWindow(car)` closes this directly, without depending on fetch
freshness at all: it checks the charger's raw `v2hOnTimeMinutes`/
`v2hOffTimeMinutes` themselves, widened by `CAR_START_V2H_MARGIN_MIN`
(**2 minutes at EACH edge**, to allow for clock mismatch between this
ESP32 and the charger's own clock) — blocking from 2min *before* on-time
through 2min *after* off-time. `carCheckStart()`'s phase 3 checks this
first, and only when a start would otherwise actually happen (so a
low-SOC cycle that wouldn't have started anyway doesn't log a misleading
"blocked by V2H window" reason); if blocked, `ctx.carState =
CAR_STATE_V2H_WINDOW` and `carCheckStop()` reports "skipped (near V2H
window)". Only gates a **new** start (`!car.chargerOn`) — a charge session
already running into this window is `carCheckStop()`'s concern, unaffected
by this guard.
This is deliberately the mirror image of `v2hScheduleConfirmsOn()` (§5a):
that one *narrows* its confirm-on window inward by slack, because a false
negative there is cheap (just retry next cycle). Here a false negative
(failing to block) lets a real conflict happen, so the margin widens
*outward* instead — despite the coincidentally identical 2-minute value,
`CAR_START_V2H_MARGIN_MIN` is its own named constant, free to diverge from
`V2H_SCHEDULE_SLACK_MIN` if either is ever retuned. The fail-safe
*direction* also deliberately differs: with no synced clock
(`missedSNTPupdate()`), `nearV2hWindow()` returns `false` (don't block)
rather than `true` — an un-synced clock right after a reboot must not
permanently prevent charging whenever SOC is otherwise high enough, which
would be a worse regression than the narrow timing race this guards
against.
---
## 6. Status display — `LoadStatusFormat.h/.cpp`
`formatCarState()`/`formatHwState()` build the exact text
`Dwg_StatusApp.cpp`'s Status screen shows (and that `LoadControlTask.cpp`
logs every cycle, via the same two functions, so the device log always
matches what's on screen). Both take the `AutoStarted` flag as a parameter
(published into `CarChargerInstances`/`HotWaterInstances` by
`checkLoadControlPoll()` under the same locks as everything else) plus the
raw device state, and apply a 3-branch model — **the state tables are kept
as comments directly in `LoadStatusFormat.h`**, above the function
declarations; refer to those for the exact row-by-row text, not this doc,
so there's only one place to keep in sync. Summary of the branching logic:
1. **`autoStarted == true`** → label **Auto**, based purely on
`powered`/`ac240vActive` (heating/charging or not) — the raw
`manualOn`/`chargerOn` reading is ignored entirely here, since a
transient off-reading while `autoStarted` is still true self-corrects
within one poll cycle (it's not evidence of manual intervention).
2. **`autoStarted == false` and the raw switch reads ON** → label
**Manual** — the one genuine signal that something other than this
control loop engaged the load.
3. **`autoStarted == false` and the raw switch reads OFF** → still **Auto**,
based on `powered`/`ac240vActive`. For hot water this can legitimately
be "Auto off, heating" — the unit has its own independent solar
auto-diverter path. For the car charger, the equivalent combination
(`chargerOn=false` with `ac240vActive=true`) has no such second path, so
it's flagged as a **FAULT** (`Car: FAULT 240VAC active while OFF`)
rather than folded into the normal ladder.
`v2hActive` is checked first, unconditionally, ahead of all of the above —
a hardware safety block orthogonal to ownership.
---
## 7. Live forecast + shading lookup
`SolarForecastFetch.cpp` fetches Open-Meteo's hourly forecast directly from
the ESP32 (no PC involved) into `SolarForecast.h`'s hourly array — and
the daily temp max/min + rainfall forecast into `WeatherForecast.h`'s
instances in the SAME request/response (see §1a). `SunPosition.cpp`
computes elevation/azimuth per hour — the on-device equivalent of
`solar_shading_model.py`'s own `solar_position()`, same Spencer-equations
method.
**Fetch/timezone handling** — requests
`timezone=UTC&past_days=1&forecast_days=3` (4 raw UTC days), not
`timezone=&forecast_days=2`, because Open-Meteo's archive API
applies a single fixed, non-DST-aware UTC offset to an entire
IANA-timezone request — the same defect described in §1a and in
[`data_sources.md`](data_sources.md)'s "Timezone handling" section, which
means a simple assumption of index 0-23=today, 24-47=tomorrow (trusting
Open-Meteo's own IANA-timezone day alignment) can't be trusted either.
Instead, each returned `hourly.time[]` entry is parsed
(`parseIsoUtcToEpoch()`, a small UTC-civil-date-to-epoch routine,
deliberately NOT `mktime()`/`tzset()`, which assume LOCAL time) and
converted to local wall-clock time via `localtime_r()`; `dayOffsetFromToday()`
then places each hour into `today`'s or `tomorrow`'s own local hour slot
(or discards it, if it falls outside those two local days — the
`past_days`/`forecast_days` buffer). The `daily=` block needs the same
fix in miniature: `daily.time[]`'s index 0 is UTC yesterday, not local
today, so the target index is found by converting local noon
today/tomorrow to its own UTC calendar date and string-matching against
`daily.time[]`, rather than assuming a fixed position.
For each hour:
```
shadeFactor = shadeFactorForSunPosition(azimuthDeg, elevationDeg) // ShadeFactorTable.h
kw = siteSystemKw() * gtiWm2 / 1000.0 * shadeFactor // SiteSettings.h, §1a
```
`ShadeFactorTable.cpp` loads `/shade_grid.csv` from LittleFS once at boot
(`loadShadeFactorTable()`, called from `setup()`) into a fixed-size array
(currently sized for 320 entries; 216 are typically populated by a
refit). `shadeFactorForSunPosition()` floors `(az, el)` to the same
10°/5° bins `solar_shading_model.py` uses and does a linear lookup;
**falls back to `0.85`** — the same default the PC-side model itself uses
(`apply_shading()`: `shade_factor.fillna(0.85)`) — for any bin never seen
in training, or if the table failed to load at all.
This lookup table, alongside `data/morning_floor.csv` (`MorningFloorTable.cpp`'s
own equivalent (month, hour) → clear-sky floor kW lookup, loaded at boot
the same way, same `0.85`-style "never seen in training" fallback
philosophy) and `site_settings.csv` (§1/§1a), is what this pipeline
(`BatterySolarMonitorUpdate/`) hands to the ESP32 — all three the same
way: `quarterly_update.py` writes `data/shade_grid.csv`
and `data/morning_floor.csv` (regenerated every run) to
`../shade_grid.csv`/`../morning_floor.csv` (one level above
`BatterySolarMonitorUpdate/`), alongside `../site_settings.csv` (which it
never writes at all — a genuine, entirely human-maintained file, edited
by hand only when site config actually changes). Nothing in this
pipeline writes directly into `BatterySolarMonitor/data/` — that folder
is only ever updated by a deliberate manual step, the same convention
`site_settings.csv` uses, never a background script silently
overwriting it on every run — so all three files are copied from
`PRIVATE_DIR` into `BatterySolarMonitor/data/` **by hand**, then synced
into the Arduino-IDE project and uploaded to the device's LittleFS
filesystem along with the next compile+flash (this project's standing
convention). `quarterly_update.py`'s own log output ends with an explicit
"MANUAL STEP REQUIRED" line naming all three paths, as a reminder not to
skip this.
---
## 8. Average consumption forecast + battery SOC projection
Two layers: `AvgConsumptionTask.cpp`'s `checkAvgConsumptionPoll()` is the
actual Enlighten fetch, so — like every other internet-facing job — it
runs on `InternetPollTask.cpp`'s shared background task (§2), in the tick
order right after `checkEnlightenHistoryPoll()`. `AvgConsumptionRefresh.cpp`
and `BatteryProjection.cpp` are pure local computation over data already
fetched elsewhere, with no internet connection of their own to
serialise against, so both instead run on the **main thread** via
`loop()` (`checkAvgConsumptionRefresh()`/`checkBatteryProjection()`,
called right after `checkEnlightenHistory()`) — the same
fetch-on-the-shared-task/translate-on-the-main-thread split
`checkSolarForecastRefresh()`/`checkGatewayRefresh()`/`checkEnlightenHistory()`
already use for their own data.
**`AvgConsumptionTask.cpp`/`AvgConsumptionInstances.h`
(`avgConsumptionProfile`)**: a running average of true home load (kW) per
15-min slot, built from the last 5 complete days' Enlighten history
(`EnlightenHistoryFetch.h`'s `fetchEnlightenConsumptionForDate()`) —
`AvgConsumptionProfile::addDay()` folds each successful day in via an
incremental mean, skipping any slot a given day doesn't have real data
for (rather than dragging that slot toward zero), so a bin only reflects
the days that actually had a reading there. Unlike every other job on
that shared background task, `checkAvgConsumptionPoll()` does **nothing**
on its own regular tick — it only runs when `requestAvgConsumptionFetch()`
has armed it, and then fetches all 5 days in one batch before going idle
again until the next request.
**`AvgConsumptionRefresh.cpp`** owns triggering that batch and turning
its result into chart data: once NTP has synced and the device has been
up briefly (25s, giving `EnlightenHistoryRefresh.cpp`'s own first "today"
fetch a head start on the same shared candidate-IP resolution), it
requests the one-shot 5-day batch above, then repaints `consumptionData`
(today, future slots only — never overwriting an already-recorded actual
reading) and `tomorrowAvgConsumptionData` (the whole day, since tomorrow
has no actual readings to protect yet) every time `successCount`
advances, until the batch finishes. `avgConsumptionForecastReady()`
(`lastPaintedSuccessCount > 0`) is the one flag other code should check
— **not** `avgConsumptionProfile.hasData()` directly, which can already
be true mid-batch, before anything has actually been painted onto the
chart yet. A total-batch failure (0 of 5 days succeed — confirmed to
happen for real when this feature's own readiness gate fires before
`EnlightenLoginFetch.h`'s boot-time login has finished building a session
cookie, so all 5 date fetches hit an empty `enlightenSession()`) is
retried every 5 minutes indefinitely; a **partial** success (≥1 of 5) is
deliberately left alone and never retried, since that's already a usable
average and retrying would only discard already-good data for no
benefit.
**`BatteryProjection.cpp`** projects battery SOC forward from "now" to
10pm (`PlotData::END_MINUTES`) in 15-min steps, writing the result into
`batteryChargeData` as the predicted (black) line on the Battery Chart —
a net-energy-balance approach: `net_kw = solar_kw − consume_kw` charges
the battery when positive (capped at the BMS's max charge rate, tapered
to half above 98% SOC, with a 93% charge efficiency loss applied) and
discharges it when
negative (no efficiency loss modelled), converted to a %-of-capacity step
each 15 minutes and clipped to `[BATT_BACKUP_SOC, 100]`. Runs every 5
minutes once its two preconditions are both met —
`avgConsumptionForecastReady()` above and `solarForecastReady()`
(§7) — and retries on the very next `loop()` tick (not a full 5-minute
wait) whenever a call bails out early (no current SOC reading yet,
outside the charted 5am-10pm window, or either forecast not ready), so
the *first* real projection happens promptly once both inputs actually
become available rather than up to 5 minutes late. Projecting from
`consumptionData`'s future slots before they hold a real average (still
Dwg_ConsumptionPlot1App.cpp's boot-time dummy-fill demo curve) would
produce a plausible-looking but entirely fabricated projection, which is
exactly what the `avgConsumptionForecastReady()`/`solarForecastReady()`
gates exist to prevent.
**DTG (discharge-to-grid) override**: while
`DischargeToGridState.h`'s `dtgPolledEnabled()` is true (§15) — the
ACTUAL polled Enphase state, not the button's own latch, so this also
covers the "disable failed, Enphase still shows it on" case §15 itself
flags — the normal net-energy-balance update above is skipped for every
15-min slot inside the DTG schedule window (`dtgScheduleStartMinutes()`/
`dtgScheduleEndMinutes()`, each snapped to this line's own grid via
`PlotData::nearestSlotMinutes()`) and replaced with a straight-line drop
from whatever % was projected (or actually measured, if the window
already started before "now") AT the window's start, down to the
schedule's OWN configured end SOC% (`dtgScheduleEndSoc()` — Enphase's
"limit" field, read from the same `schedules` response as the window
itself; the same % the schedule's own edit page on
enlighten.enphaseenergy.com shows/lets you set, not a hardcoded value)
EXACTLY at the window's end. Normal projection resumes for any slot
after the window, continuing from that %. Both the override's SOC target
and its final clamp use the SAME `[BATT_BACKUP_SOC, 100]` safety net the
normal path applies — `limit` is boundary data from Enphase, not a
trusted constant. Skipped entirely (normal projection throughout, no
override) if the window or its end SOC% hasn't been polled yet, the
window is degenerate after snapping to the grid, or the starting slot's
own % reads back as PlotData.h's own "no data" sentinel (0.0/-1.0) —
never interpolates from a fabricated starting % or guesses an end %.
**CFG (charge-from-grid) override**: the mirror-image of the DTG override just above, in every
respect except direction — while `ChargeFromGridState.h`'s
`cfgPolledEnabled()` is true (§16), a straight-line RISE from whatever %
was projected at the cfg window's start (`cfgScheduleStartMinutes()`/
`cfgScheduleEndMinutes()`) UP TO the cfg schedule's own configured target
SOC% (`cfgScheduleTargetSoc()` — Enphase's "limit" field for the cfg
schedule, charged UP to rather than discharged DOWN to) EXACTLY at the
window's end, same `[BATT_BACKUP_SOC, 100]` clamp, same "never guess"
skip conditions. Both overrides share the same per-slot loop; DTG is
checked first, so it wins if the two windows ever overlapped, though in
practice they don't (cfg mid-day, dtg evening).
**`requestBatteryProjectionRecompute()`** (`BatteryProjection.h`) — called
from `DischargeToGridTask.cpp`'s own background task right after every
`setDtgPolledEnabledAssumed()` call (§15's three optimistic-update sites:
boot/button disable, status-poll resend, and the auto-discharge decision)
so the DTG override above reflects an optimistic change immediately
instead of sitting on whatever it last computed for up to another
~1min (`PROJECTION_INTERVAL_MS`) — otherwise the SOC plot would lag
behind the button text even after that text itself started updating
immediately, since this file's own throttle is independent of
`DischargeToGridState.h`'s. Mutex-protected (genuinely cross-thread);
`checkBatteryProjection()` picks the request up and forces
`projectionTimer` due on its very next call, ahead of the normal
`isRunning()`/`justFinished()` pair.
---
## 9. Car charge history — `CarChargeHistory.h/.cpp`
`CarChargerInstances.h`'s `carChargingKwhToday` is a pure ESP32-side RAM
estimate — the charger exposes no real energy reading at all, so this
device accumulates it itself from elapsed on-time at an assumed charge
rate (§3). Unlike the Solar/Consumption/Battery charts, which
re-backfill from Enphase's own cloud history on every boot and so look
continuous across a reboot regardless of uptime, nothing backs this
estimate at all — an unplanned reboot (WiFi-drop watchdog reset,
brownout) would otherwise silently zero it with no way to tell
afterwards that real charging had already happened earlier that day.
Two small LittleFS files, both written from `CarChargerTask.cpp`'s own
poll (§3) and requiring `lockCarChargerState()` already held by the
caller, same as every other access to that state:
- **`/car_charge_today.csv`** — today's own `(date, running kWh so far)`,
overwritten every time `carChargingKwhToday` actually changes (at most
once per ~5min poll). Read back at the *first* poll with a synced clock
each boot (`loadOrResumeCarChargeToday()`) to resume today's total
after an unplanned reboot instead of losing it — if the stored date is
no longer today (the device was off across a midnight), that stored
total is banked as a finished day first, since it never got a graceful
rollover before the device went down.
- **`/car_charge_history.csv`** — up to 5 completed days' final totals,
oldest first. Appended either gracefully, at a genuine local-midnight
rollover detected while running (`CarChargerTask.cpp`'s own date-changed
check calls `rolloverCarChargeDay()`), or retroactively at the next
boot via the missed-rollover path above.
`printCarChargeHistory()` formats the up-to-5-day history plus today's
own running total as one multi-line block (oldest first, today last, a
day the device was off for entirely simply absent rather than shown as a
misleading 0) — used by `DebugLogBuffer.cpp` at the top of the Log Tail
screen (a separate debug-output feature from this file, not covered
elsewhere in this doc).
---
## 10. Buzzer alarm — `BuzzerControl.h/.cpp`
Runs entirely on the **main thread**, called every `loop()` as `checkMains()`
(right after `processNTP()` in `BatterySolarMonitor.ino`) — unlike §2-§5,
this is not one of `InternetPollTask.cpp`'s background jobs. Two independent
alarm reasons share one physical buzzer pin (`buzzerPin`, `D7`), so each
reason is tracked as its own latched boolean and the pin itself is only ever
driven once per loop, from the OR of the two.
**Mains-loss alarm** — `mainsAlarmActive`, latched:
- **Set**: on the falling edge of `mainsDetectorSw` (`DebouncedSwitch` on
`D11`) — i.e. the instant
loss of voltage on D11 is debounced-confirmed.
- **Cleared**: mains voltage returns to D11 (`mainsDetectorSw.isDown()`
reads false again), **or** the pushbutton (`sw`, `D2`, grounded via `D6`
as its dedicated GND) is pressed — either silences it, per spec.
- Always enabled — no time-of-day gate, no cancel-latch that could leave it
permanently disarmed.
**Low-SOC alarm** — `socAlarmActive = inWindow && !socAlarmCancelled &&
(isSocLow() || socFetchStuck())`:
- **Window**: local time between 14:00 and 20:00 (`SOC_ALARM_START_MIN`/
`SOC_ALARM_END_MIN`), via `LocalTimeMinutes.h`'s
`localTimeMinutesSinceMidnight()` — fails safe to "not in window" (no
alarm) if the clock has never NTP-synced (`missedSNTPupdate()`), the same
fail-safe convention `LoadControlTask.cpp` (§5b) uses for its own
time-of-day checks.
- **SOC reading**: `latestBatterySocPercent` (`GatewayInstances.h`), read
under `lockGatewayReading()`/`unlockGatewayReading()` (`GatewayPollTask.h`)
since it's written by the background `InternetPollTask` and read here on
the main thread — same access pattern `Dwg_StatusApp.cpp`/
`GatewayRefresh.cpp` already use. The `-1` "last fetch failed/not yet
known" sentinel fails safe to "not low" (`isSocLow()` alone never alarms
on it).
- **Sustained-failure alarm** (`socFetchStuck()`): true once
`GatewayPollTask.h`'s `consecutiveBatteryFetchFailures()` reaches
`SOC_ALARM_FAIL_RETRY_COUNT` (20) — i.e. the battery-SOC fetch has failed
20 times in a row, each `FETCH_RETRY_DELAY_MS` (15s, `GatewayPollTask.cpp`)
apart, so 5 minutes of not being able to confirm the SOC at all. This
guards against a run of genuine gateway connection failures immediately
followed by one HTTP-200 response whose device list has no usable `soc`
field on it (gateway not fully back up yet). `GatewayFetch.cpp`'s
`fetchBatteryStatus()` validates each device's `soc`/`real_power_mw`
fields rather than trusting them blindly: a missing/null field fails the
whole fetch (returns `false`) rather than defaulting it to `0.0` via
ArduinoJson's `| 0.0` fallback — a silent default there would be accepted
as a genuine "SOC=0.0%" reading, plotting a false 0% point on the
Battery Charge chart and immediately sounding `isSocLow()`'s alarm off a
single bogus data point. The same applies if a field is present but not
actually a float (`!d["soc"].is()`/`!d["real_power_mw"].is()`),
and a device record reporting `soc == 0.0 && real_power_mw == 0.0`
together is also rejected, as the signature of an uninitialized/zeroed
record rather than a genuine reading — a real battery can't plausibly be
at 0% while drawing/supplying exactly 0W simultaneously, and Enphase's
own configured backup reserve keeps a real battery from legitimately
reaching 0% at all under normal grid-connected operation; a device
that's merely LOW with nonzero power is not rejected. Every one of these
failure modes feeds `consecutiveBatteryFetchFailures()`, so
`socFetchStuck()` still gives a real, eventually-fires alarm path for a
*genuinely* prolonged outage, without a single stray or bogus reading
ever sounding the alarm on its own.
- **Cancel latch** (`socAlarmCancelled`): set true by a pushbutton press
**only while the low-SOC alarm is actually sounding at that moment**
(`socAlarmActive` computed BEFORE the button is applied, same
already-active precondition the mains-loss alarm's own cancel uses);
reset to false the moment local time leaves the window. This is what
gives "once turned off, does not turn on again between 2pm and 8pm" (a
press latches silence for the rest of *that* window) while still
re-arming "at startup and between 2pm and 8pm if not already cancelled
during that interval" — starts `false` at boot (static initialisation)
and re-arms itself automatically every time the window is re-entered on a
later day. SOC naturally recovering above 15% without a button press is
**not** treated as "turned off" for latching purposes — the alarm just
stops (condition no longer met) and can resume later the same window if
SOC drops again; only an explicit press while it's sounding arms the
latch. This precondition matters because both alarms share the one
pushbutton: latching `socAlarmCancelled` on any press while merely
inside the window, regardless of whether the low-SOC alarm was actually
sounding, would let a press meant only to silence a concurrent
mains-loss alarm also silently arm this latch for the rest of the
window even though SOC had never dropped low at all.
The pushbutton is level-checked (`sw.isDown()`), not edge-checked, every
loop — holding it down keeps both `mainsAlarmActive` false and
`socAlarmCancelled` true for as long as it's held, which is harmless
(idempotent).
---
## 11. Daily reboot — `DailyReboot.h/.cpp`
`checkDailyReboot()` (main thread, every `loop()`) reboots the device
once, shortly after local midnight each day, purely as routine
housekeeping — clearing any heap fragmentation or small leaks that may
have accumulated over a full day of WiFi/HTTPS/JSON churn, rather than
letting them run unbounded across weeks of continuous uptime. Several
other features documented above quietly depend on this happening once a
day: §1b's self-refreshed Enlighten session naturally gets re-checked
every reboot, comfortably inside the session cookie's ~14-day freshness
window, and §9's car-charge history treats "the device was off across a
midnight" as a distinct case from an in-progress local-midnight rollover.
**Detection**: compares each call's `localTimeMinutesSinceMidnight()`
against the *previous* call's — a real midnight rollover is exactly
"today's reading is smaller than last time's" (23:59 → 00:00 wraps ~1439
down to ~0). No-op until NTP has synced at least once
(`missedSNTPupdate()`), so an un-synced clock's startup value is never
mistaken for a real midnight, and the very first post-sync check only
*records* the current reading rather than comparing against it — a device
that happens to boot right after a real midnight must not mistake its own
first reading for a rollover and reboot-loop.
**The non-obvious part**: a plain "did minutes decrease" check would also
fire on the once-a-year DST autumn fallback (AEDT→AEST), when the clock
itself jumps backward 60 minutes around 2-3am — a genuine decrease in
minutes-since-midnight, but not a midnight rollover. The spring-forward
transition only ever jumps the clock *forward*, so it can't cause a false
trigger here, but the fallback needed a real fix: the rollover check
requires the drop to exceed `MIN_ROLLBACK_MINUTES` (90) before triggering
a reboot, since a real midnight wrap is always a much larger drop (up to
~1439) than a 60-minute DST fallback, telling the two apart regardless of
exactly when either check happens to run relative to the transition.
Once a reboot fires, it can never fire again immediately afterward either
— the fresh boot session starts back at "haven't seen a reading yet" with
no previous value to compare against.
---
## 12. Debug output + Log Tail — `TimestampStream.h`, `DebugLogBuffer.h/.cpp`
Every `debugPtr->print()`/`println()` call anywhere in this project — on
the main thread or any background task — writes through one shared
`TimestampStream` instance, which does two things to every byte before
deciding where it goes: prefixes each line with a local-time
`[HH:MM:SS] ` timestamp (suppressed entirely until NTP has synced at
least once, rather than printing a plausible-looking but wrong time that
would make an ordinary startup delay look like a multi-hour freeze), and
unconditionally forwards it to `DebugLogBuffer.h`'s
`debugLogBufferWrite()` — regardless of whether a real output stream
(`setDebugOut()`, typically `Serial`) is even set, so turning Serial off
never turns off the Log Tail screen (pfodWeb's live "Raw Data" mechanism)
too; the two destinations are fully independent.
**Why a whole line needs its own lock, not just each byte**: this stream
is shared by every background poll task at once, and a single logical
debug line is usually built from several separate `print()` calls (e.g.
`print("x="); print(x); println("y")`). Without serialising across the
*whole* line, two tasks' lines can interleave mid-line — confirmed in
practice as genuinely garbled log output (one task's text spliced into
the middle of another's). `debugLogLineMutex()` (`DebugLogBuffer.h`,
since `TimestampStream` is header-only/inline with nowhere of its own for
a shared definition to live) is taken on a line's first byte and given
back only on that line's own `'\n'`, so a second task starting its own
line simply blocks until the first finishes rather than interleaving into
it. The lock/line-position tracking (`atLineStart`/`haveLock`) is
`thread_local`, not a plain shared field — a real, previously-hit bug
otherwise let one task see *another* task's `haveLock` state and call
`xSemaphoreGive()` on a mutex it never took, crashing the device outright.
The lock wait itself is bounded (1s), not indefinite, so a task that dies
mid-line (or a bug that never writes the closing `'\n'`) can't
permanently wedge every other task's debug output — timing out just
means that one line proceeds without exclusive access instead of
freezing the whole stream forever.
**`DebugLogBuffer.h`** owns where each byte actually ends up:
`debugLogBufferWrite()` forwards straight through to pfodWeb's own
`getRawDataWriter()` relay once the web server exists to receive it, or
to a small capture buffer (`preWebServerBuffer`) before that point. That
pre-webserver capture matters because `BatterySolarMonitor.ino` calls
`loadEnphaseAuth()`/`loadSiteConfig()` — whose own "loaded X/N
keys"/unexpected-extra-key WARNING messages are exactly the kind of thing
worth seeing in Log Tail — well before `start_pfodWebServer()` has run
and allocated the relay's backing buffer; writes before that point would
otherwise vanish silently, reaching only the physical Serial console.
`notifyWebServerStarted()` (called once, immediately after
`start_pfodWebServer()` in `setup()`) just latches `debugLogBufferWrite()`
over to the now-valid relay for everything written from that point on —
it no longer flushes `preWebServerBuffer` anywhere itself.
**Opening the screen**: `debugLogBufferDumpAll()` (called from
`pfodMainMenuApp.cpp`'s `onLogTailPressed()` override) prints the
`{=Last 16K of Debug Log~C}` open-screen response, `CarChargeHistory.h`'s
`printCarChargeHistory()` output next (§9), THEN re-prints
`preWebServerBuffer`'s own captured startup text — every time the screen
is opened, not just once at boot (this device reboots once a day at midnight — §11 — so by the
time anyone actually checks Log Tail, often hours later, e.g. the
following morning, ordinary logging has almost certainly pushed a
one-time boot-time flush out of the relay's own 16K window through
perfectly normal eviction, long before any client's own cursor ever
reaches it; confirmed in practice this way). `preWebServerBuffer` is
never written to again once `notifyWebServerStarted()` latches, so
re-reading it (`resetForRead()`, which only rewinds the READ position —
the underlying bytes are untouched) always reproduces the same boot-time
text regardless of how long ago boot actually was. Only after that does
`debugLogBufferDumpAll()` reset *THIS requesting client's own* read
cursor into the shared relay — each polling client tracks its own
independent cursor/position, so opening or re-opening the screen on one
device can never affect what any other currently-connected viewer sees
or has already read.
---
## 13. Enlighten history backfill — `EnlightenHistoryFetch.h/.cpp`
The local IQ Gateway only ever reports "right now" (§1) — nothing about
already-elapsed hours earlier today. `EnlightenHistoryFetch.cpp` closes
that gap by fetching today's already-elapsed solar/consumption/
battery-SOC history (15-min slots from midnight to now) from Enphase's
Enlighten cloud instead, in one call (`production.meter`, `consumption`,
and `acb.acb_soc_ts` all share the same `start_date` + `i*900s` slot
scheme). `EnlightenHistoryTask.cpp`'s `checkEnlightenHistoryPoll()` is the
actual fetch, so it runs on `InternetPollTask.cpp`'s shared background
task (§2); `EnlightenHistoryRefresh.cpp`'s `checkEnlightenHistory()` owns
triggering it and merging the result into the live chart data, on the
main thread — the same fetch-on-the-shared-task/translate-on-the-main-thread
split §8 uses for `AvgConsumption`/`BatteryProjection`.
**Why this keeps re-running every 5 minutes, not just once near boot**:
the local gateway's own points (`GatewayPollTask.cpp`) are *instantaneous*
readings taken every 5 minutes — spikier than Enphase's true 15-min
*average* for the same slot. Each successful refresh's `mergeHistoryInto()`
overwrites those instantaneous points with Enphase's smoother true
average for the elapsed slots it covers, and — since this fetch goes to
Enphase's cloud rather than the local gateway — also transparently
backfills any stretch where the local gateway connection itself was down.
`REFRESH_INTERVAL_MS` is 5 minutes, deliberately shorter than Enphase's own
15-minute history slot resolution, to bound the worst-case delay in
picking up a newly-available slot: a refresh landing just before the
cloud side advances to a new slot would otherwise mean waiting almost a
full 15 more minutes to pick it up; 5min caps that worst-case pickup
delay at ~5min. Most individual refreshes
still just re-confirm a slot that hasn't advanced yet (a harmless,
idempotent no-op) — that's expected and fine, since the point is
bounding the worst case, not the typical case.
**A real bug this design deliberately avoids**: `mergeHistoryInto()`
never copies a slot past "now"'s own currently-resolved point, regardless
of how much history the fetch actually returned. Without that limit, a
15-min-old fetch's own history array can still hold trailing zero/stale
values for slots that haven't happened yet from *that fetch's* point of
view, and copying those over `solarData`/`consumptionData`/
`batteryChargeData` would overwrite an already-correct current reading
with a stale one, mis-colored as "actual" — confirmed for real (a
captured chart showed the Battery Charge line's actual/orange colour
extending to ~95% well past the true current ~60% reading, staying wrong
until `BatteryProjection.cpp`'s own next 5-minute cycle happened to
repaint those same slots back to predicted/black). `GatewayRefresh.cpp`
(writes only *at* "now") and this merge are the only two writers of
actual-coloured chart data, so this is the one place that guard has to
live.
**A real external routing problem it works around**:
`enlighten.enphaseenergy.com` is a CNAME to an AWS load balancer with
several backend IPs, DNS round-robined between them — and one of them was
confirmed, via a direct TCP connect test, to simply hang rather than
connect from this network (a real routing problem external to the ESP32
or its credentials, not a bug here). Since the ESP32's lwIP DNS resolver
only ever returns one address per lookup (confirmed on real hardware,
unlike a full OS resolver), `resolveEnlightenHosts()` re-resolves before
*every* retry, not just the first attempt — each fresh lookup has a
decent chance of landing on a different, working IP rather than trusting
a single cached answer that might be the bad one. `fetchEnlightenHistoryToday()`/
`fetchEnlightenConsumptionForDate()` connect via the IP directly (the real
hostname passed separately as the TLS SNI/Host header, the same trick
`SolarForecastFetch.cpp` uses), so this fetch itself never triggers a DNS
lookup — only `resolveEnlightenHosts()` does, and that must only ever be
called from the main thread, same `dns_clear_cache()` hazard §1b
explains in full for `resolveSolarForecastHost()`.
`AvgConsumptionTask.cpp` (§8) reuses this same file's
`fetchEnlightenConsumptionForDate()` for its own 5-day rolling average —
one fully-elapsed *past* day at a time, the whole day (no "stop at now"
cutoff, since it's already over), consumption only. A missing/null slot
in that response is left as `-1.0` (a real "no data" sentinel, not a
fabricated `0.0`), so `AvgConsumptionProfile::addDay()` can correctly
exclude it from that slot's average instead of blending in a fake zero
reading.
---
## 14. Car charger / hot water wire protocols
§3/§5/§5a/§5b describe the fetch/task/control architecture and what each
field is used *for*; this section is the API/protocol reference itself —
the actual endpoints, commands, and response shapes `CarChargerFetch.cpp`/
`HotWaterFetch.cpp` parse.
### Car charger — HTTP, JSON (`CarChargerFetch.h/.cpp`)
Three endpoints on the charger's own local LAN IP (`siteCarChargerIp()`,
§1a):
| Endpoint | Method | Purpose |
|---|---|---|
| `/api/state` | GET | Returns the JSON fields below |
| `/chargerPower` | POST (empty body) | Toggles the charger relay on/off |
| `/v2hPower` | POST (empty body) | Toggles V2H (export) mode on/off |
`/api/state` response fields:
| Field | Meaning |
|---|---|
| `charger_state` | `"on"`/`"off"` — the charger relay itself |
| `ac240v_status` | `"active"`/`"inactive"` — whether the charger's AC output is actually live (distinguishes "on but nothing plugged in/finished" from genuinely charging) |
| `v2h_state` | `"on"`/`"off"` — V2H (export) mode |
| `v2h_auto_active` | `"active"` when the charger's own scheduled Auto on/off is enabled |
| `v2h_time_on` / `v2h_time_off` | `"HH:mm"` — the charger's own configured V2H schedule boundaries (§5a's `v2hScheduleConfirmsOn()` reads these fresh every poll, never a cached copy) |
`charger_state`/`ac240v_status`/`v2h_state` are required — a response
missing even one is treated as unreachable, not "definitely off" (see
§3's "Sync with `LoadControlTask.cpp`" for why a stale/wrong reading here
would be worse than an honest "couldn't tell"). `v2h_auto_active`/
`v2h_time_on`/`v2h_time_off` are not: a missing `v2h_auto_active` fails
safe to "false" (matching `nearV2hWindow()`'s own fail-safe direction,
§5b), and a missing/malformed time string parses to `0` (midnight),
matching that field's own documented "0 == disabled/unset" convention.
### Hot water controller — raw TCP, pfod protocol (`HotWaterFetch.h/.cpp`)
Not HTTP — a plain TCP socket to `siteHotWaterIp():4989` (§1a), speaking
the same pfod message protocol (`{...}`, self-delimiting by the closing
`}`) the ESP32's own pfodWeb UI uses. Every exchange after connecting
needs the firmware version prefix first:
| Step | Send | Typical response | Meaning |
|---|---|---|---|
| Version handshake | `{.}` | ``{,~`2500~V3|+A~c1}`` | Version is the text between the 2nd and 3rd `~` (e.g. `V3`) — prefixes every command below |
| Channel states | ``{:c2`0}`` | ``{+|C`13~9~3~~|h`1|...|uh`7|...}`` | ``|C`13~9`` = channel 13 (manual latch) is on; ``|uh`7`` = channel 7 (the element itself) is powered, ``|h`7`` = not powered |
| kWh today | ``{:c4`0}`` | ``{+|t`16~0~<+4>Hot Water 3.8kWhr~~}`` | Number between `Hot Water` and `kWhr` |
| Fully-heated flag | ``{:c5`0}`` | ``{+|t`17~2~<+3>Hot Water was Fully Heated~~}`` | Presence of `Fully Heated` in the response |
| Heating source | ``{:c8`0}`` | ``{+|t`19~12~<+4>Solar Hot Water/n2:01pm~~}`` | One of `Solar Hot Water`/`Off Peak Hot Water`/`Hot Water OFF`/`No WiFi Connection`, matched by substring |
| Toggle manual latch | ``{:A~c3`1`0`0}`` | (ack) | Flips channel 13's current manual state — see the safety note below |
The channel-state query (`c2`) is required — a failure fails the whole
fetch, since it's the field the control logic depends on (§5). The kWh
(`c4`), fully-heated (`c5`), and source (`c8`) queries are each
best-effort: a failure on any one of them just leaves that field at its
default (`0.0`/`false`/unknown) rather than failing the whole fetch.
**Toggle safety guard**: the toggle command flips whatever the manual
latch's *current* state is — there's no separate "set on"/"set off"
command — so every caller must read `manualOn` from a fresh `c2` fetch
before deciding whether to send it, exactly the same "check current state
before toggling" pattern §5's own `toggleCarChargerIfEnabled()`/
`toggleHotWaterIfEnabled()` use. Separately, the controller itself
auto-clears the manual latch after a fixed timeout on its own side (a
hardware/firmware behaviour of the hot water controller, not something
this ESP32 controls or can observe directly) — meaning `manualOn` can
read `false` even though this device's own `AutoStarted` flag (§4) still
thinks it started the load, purely because enough time passed since the
last toggle. §4/§5 already treat "flag says we started it, but the raw
switch now reads off" as "cleared, not stopped" for exactly this reason,
not as a bug to fix here.
---
## 15. Discharge-to-grid button — `DischargeToGridState.h`/`DischargeToGridFetch.h`/`DischargeToGridTask.h`
The main menu's "Auto Battery Charge/Discharge Enabled/Disabled" button
(`onAutoBatteryChargeDisPressed()`, `pfodMainMenuApp.cpp` — renamed from
`onBatteryCanDischargeTPressed()` when a pfodWeb Designer regeneration
split the old single button into this button plus a separate status
label, see "Button/label text" below) controls whether
the battery is **allowed** to discharge to the grid during Enphase's own
DTG (discharge-to-grid) schedule window — separate from
`checkDischargeToGridLogic()` (`DischargeToGridTask.cpp`, below), which
decides *when* to actually turn Enphase's own setting on while that's
allowed. Three new files, the same Fetch/Task split as §3, plus a fourth
for the persisted latch:
- **`DischargeToGridState.h/.cpp`** — the overall "Auto Battery
Charge/Discharge" master switch's own latch (`autoDischargeEnabled()`),
persisted to `/discharge_to_grid_state.txt` the same way
`LoadControlState.h` (§4) persists its own flags, plus the latest polled
Enphase status (`dtgPolledEnabled()`/`dtgScheduleStartMinutes()`/
`dtgScheduleEndMinutes()`/`dtgScheduleEndSoc()` — RAM only, recomputed
every poll, never persisted; the window is kept as raw
minutes-since-midnight, not a display string, so `BatteryProjection.cpp`'s
own DTG override below can align it to its 15-min plot grid directly —
`DischargeToGridFetch.h`'s `formatMinutesOfDay()` converts it to
"8:01pm" style for display). This latch ALSO gates §16's Hot Day
decision, not just discharge-to-grid, and persists across every reboot,
including this device's own daily midnight one (§11) — it is not
force-reset at boot (see below).
- **`DischargeToGridFetch.h/.cpp`** — pure networking against the SAME
Enphase Enlighten cloud `batteryConfig` API the
`enlighten.enphaseenergy.com` browser page's own DTG toggle uses
:
- `fetchDischargeToGridStatus()` — `GET .../battery/sites/{site}/schedules`
for the DTG window AND its own configured end-of-window SOC%
(`dtg.details[0].startTime`/`endTime`, reformatted "8:01pm" style
to match the web page; `dtg.details[0].limit` — the SAME % the
schedule's own edit page on enlighten.enphaseenergy.com
shows/lets you set), then `GET .../batterySettings/{site}` for the
current `dtgControl.enabled` flag. Both `limit` AND `enabled` are
checked for TYPE, not just presence — a wrong-type `limit` (fails
the whole fetch if it's missing, not actually a JSON number, below
`BATT_BACKUP_SOC` (10%, `BatteryConstants.h` — the setting can
never validly go below the battery's own backup reserve), or above
100%) or a wrong-type `enabled` (not actually a JSON boolean) would
otherwise silently convert to `0.0`/`false` via ArduinoJson's own
default-on-mismatch behaviour — for `enabled` specifically, that
wrong `false` reads as "Enphase confirms discharge off", which
both `DischargeToGridTask.cpp`'s resend check and
`BatteryProjection.cpp`'s drop-to-`limit`% override would act on,
not just a value nobody uses. Fails (returns `false`, leaves every
out-param untouched) rather than guess, on any network/parse
problem, an invalid `limit`/`enabled`, or if no DTG schedule is
configured at all.
- `setDischargeToGridEnabled()` — `GET .../schedules` (for a fresh
`X-Csrf-Token`) → `POST .../schedules/isValid`
(`{"scheduleType":"dtg"}`, returns a new token) →
`PUT .../batterySettings` (`{"dtgControl":{"enabled":true|false}}`).
Only returns `true` once the PUT itself comes back
`{"message":"success"}` — the site then applies the change
asynchronously (confirmed by hand against the live account:
~5-20s later), so a caller shouldn't assume it's already live the
instant this returns.
Authenticates with the same `enlightenSession()`/`enlightenSystemId()`
(`EnphaseAuth.h`) §1b's self-refresh already keeps current, over a
`Cookie: _enlighten_4_session=...` + `e-auth-token` header pair — the
same pair `checkEnlightenTokenValid()` (§1b) already uses — and calls
`notifyAuthTokenRejected()` (§1b) on a confirmed 401, same as every
other Enlighten-cloud caller. Reuses `enlightenHostIP()` (§1b) instead
of a third independent DNS resolve of the exact same hostname.
**`POST isValid` returning 403 — root cause found and confirmed fixed.**
`GET`s always returned 200, but `setDischargeToGridEnabled()`'s
`POST .../schedules/isValid` consistently came back 403
. Two header-matching guesses
first (`Origin`/`Referer`, then `User-Agent`/`Username`/`Requestid` —
all present in the real browser's HAR captures and in
`test/enphase_dtg_toggle.py`, both confirmed working) made no
difference — worth having added
regardless, but not the
actual cause. Found by inspecting a real login's cookie jar directly
(`python -c "import enphase_dtg_toggle as t; ..."`, dumping
`http.cookiejar` contents before/after a real GET): the GET to
`.../schedules` ALSO sets a `BP-XSRF-Token` COOKIE, carrying the exact
same value as the `X-Csrf-Token` response header/`X-XSRF-Token` request
header — a double-submit CSRF check that compares the header against
THIS cookie, not a server-side session store, which
`test/enphase_dtg_toggle.py`'s own cookie-jar-based opener satisfies
transparently (invisible in its own explicit `headers` dict, which is
why reading that script's source alone missed it). Confirmed by
replicating the ESP32's own exact approach in Python — manually-built
`Cookie` header, no cookie jar at all — end to end against the live
account: `GET -> 200`, `POST isValid -> 200`, `PUT -> 200
{"message":"success"}`, and a follow-up `status` read (after the usual
~20-50s asynchronous apply delay) confirmed `dtgControl.enabled` had
genuinely gone to `false`. Fix: `sendBatteryConfigRequest()` mirrors
`xsrfToken` into the `Cookie` header (`; BP-XSRF-Token=`)
whenever it's non-empty — no separate `Set-Cookie` parsing needed, since
the cookie and header always carry the identical value.
`sendBatteryConfigRequest()` also now logs the response body (truncated
to 300 chars) on any non-200, which is what made the generic Spring
Security `{"status":403,"error":"Forbidden"}` body visible in the first
place — enough to rule out the two header guesses, though the actual
cause needed the cookie-jar inspection to find.
- **`DischargeToGridTask.h/.cpp`** — runs on the shared background task
(§2), three jobs:
- a ONE-SHOT "send Enphase's own setting off" request, due
immediately on this task's very first tick (Enphase's own setting is
always disabled on restart; the auto-discharge decision logic then
takes over from there) and re-armed on demand by
`requestDischargeToGridDisableNow()` (the button handler, when the
user switches to disabled). NOT retried on its own — fired once,
optimistically assumed to have worked.
Immediately BEFORE firing the request, calls
`setDtgPolledEnabledAssumed(false)` (`DischargeToGridState.h`) AND
`requestBatteryProjectionRecompute()` (§8) so the button text and
the SOC plot's own DTG override both reflect it right away, rather
than sitting on whatever was last actually polled/projected for
however long `setDischargeToGridEnabled()`'s own GET/POST/PUT chain
takes (confirmed several seconds in practice) — let alone until the
next ~5min status poll happens to run.
`disablePending` is genuinely written from two tasks — this
background task AND whatever task services the button press — so
it's mutex-protected, the same two-caller situation §4's own
`carV2hRecoveryPending` describes for the "Clear Recovery Flag"
button. The lock is held only around the flag itself, never across
`setDischargeToGridEnabled()`'s own multi-second network call —
same "fetch unlocked, publish locked" split `checkEnlightenHistoryPoll()`
(§13) uses, so a button press is never left waiting behind a slow
HTTPS round trip.
- a recurring 5min status poll (`fetchDischargeToGridStatus()`),
publishing into `DischargeToGridState.h` via `setDtgPolledStatus()`.
A FAILED poll retries after 30s instead of the full 5min (same role
as §2's `FETCH_RETRY_DELAY_MS`) — otherwise a session that's still
mid-refresh right after boot (§1b's own login/validate cycle starts
at a 15s uptime floor) could leave the button showing stale/default
placeholder text for up to 5 minutes. **This poll is also the
one-shot disable's own retry mechanism**: `runStatusPoll()` reads
the button's latch BEFORE `setDtgPolledStatus()` can force it (see
below), and if Enphase still reports discharge on, resends
`setDischargeToGridEnabled(false)` whenever the latch wants it off,
the mandatory boot disable hasn't been confirmed off yet
(`bootDisableUnconfirmed`, cleared permanently the first time a poll
sees `enabled == false`), OR the auto-discharge decision below
hasn't positively decided to turn discharge ON — again just assumed
to work, with the same `setDtgPolledEnabledAssumed(false)` AND
`requestBatteryProjectionRecompute()` (§8) calls fired BEFORE the
resend itself, same reasoning as the one-shot disable above
(overriding the `enabled=true` this same poll's own
`setDtgPolledStatus()` call just published a moment earlier, so the
display never settles on that stale reading even transiently), so
a still-failing disable self-corrects every ~5min for as long as
the mismatch persists, instead of a dedicated fast-retry loop.
`bootDisableUnconfirmed` is deliberately a SEPARATE flag from the
latch check: the latch (`DischargeToGridState.h`) is persisted and
not force-reset at boot — if it happens to be in its ordinary
"enabled" state (the common case) going into a reboot, checking
only the latch would give the mandatory boot disable no resend
coverage at all if its one fire-and-forget attempt failed to land,
leaving the boot safety action with strictly weaker retry coverage
than a user's own button press. Cleared ONLY by this
poll actually observing `enabled == false` for real — never by the
optimistic `setDtgPolledEnabledAssumed()` calls, which affect only
the button/plot display, not this safety gate
(`checkDischargeToGridLogic()` below deliberately keeps requiring
real confirmation here).
**The third resend condition — the auto-logic's own pending/negative
decision — closes a separate, real gap found in practice** (no
"auto-discharge logic ... -> enabling discharge" line was ever
logged, yet the evening DTG window still showed the drop-to-25%
projection): once `bootDisableUnconfirmed` clears and the latch
allows discharge, nothing previously kept re-asserting "off" if
`checkDischargeToGridLogic()` hadn't positively decided to turn
discharge ON (either because it hadn't decided yet, or because it
had decided OFF) — Enphase's own cloud-side schedule re-engaging on
its own went completely uncorrected, since the old resend condition
(`wantedOff || bootDisableUnconfirmed`) only ever checked the
button's own latch, never the auto-logic's actual intent. Skipped
once the schedule window has actually started today (a plain
`nowMinutes >= dtgScheduleStartMinutes()` check, re-evaluated fresh
each poll, same as `checkDischargeToGridLogic()`'s own identical
check) — this poll shouldn't keep fighting Enphase's own setting
once that function has stopped caring either, per its own "ignore
changes during and after the discharge schedule starts" rule.
`haveDtgDecision`/`lastDtgDecision` (previously declared only next
to `checkDischargeToGridLogic()`) moved up alongside
`bootDisableUnconfirmed` so `runStatusPoll()` can read them too —
both still only ever touched from this same background-task tick,
so still no lock needed.
- the auto-discharge DECISION itself (`checkDischargeToGridLogic()`,
ticked right after the status poll above) — see "The auto-discharge
decision" further below for the full rule, cadence and threading.
`setDtgPolledStatus()` never touches the `autoDischargeEnabled()` latch,
in either direction. While the latch is disabled, discharge-to-grid and
charge-from-grid status are still reported, but the latch is never
automatically re-enabled. The recurring status poll still actively
resends a disable while the latch is off and Enphase reports discharge
on, correcting the actual Enphase setting back toward off, but it never
flips the latch itself back to enabled as a side effect of doing so.
**Button/label text — split into TWO items by a pfodWeb
Designer regeneration** (previously one single button,
`button_Battery_can_dis_Cmd`/`var`, now gone entirely from the generated
files): a plain status LABEL (`label_Battery_will_di_Cmd`/`var`) plus the
actual clickable BUTTON (`button_Auto_Battery_Ch_Cmd`/`var`, hook
`onAutoBatteryChargeDisPressed()`). Both recomputed fresh before every
`sendMainMenu()`/`sendMainMenuUpdate()`, same pattern as
`updateTzFormatLabel()`/`updateBatteryChargeLabel()`.
`updateBatteryStatusLabel()` (`pfodMainMenuApp.cpp`) — `label_Battery_will_di_var`.
Lines 2-4 are not gated by the master switch latch (`autoDischargeEnabled()`)
at all — both status polls run every background-task tick independent of
it, so these lines keep reporting Enphase's own actual polled state
(lines 3/4, always) or the Hot Day forecast/schedule (line 2's own
"tomorrow" cases) the same way whether the latch is enabled or disabled;
only the DECISION logic itself (`checkDischargeToGridLogic()`/
`checkHotDayLogic()`) is gated by it. Line 1 is the one exception,
checking the latch first (see its own row below):
| Line | Text |
|---|---|
| 1 (discharge-to-grid) | "Battery will not discharge⏎(auto disabled)" if the master switch (`autoDischargeEnabled()`) is currently `false` (⏎ = a line break, so the "(reason)" always sits on its own line below the main text) — takes priority over `dtgPolledEnabled()` below so this never falls through to a possibly-stale "will discharge" reading from the brief window before the resulting disable request has actually been confirmed off; else "Battery **will** discharge to ``% between `` and ``" if Enphase's own `dtgControl.enabled` is currently `true` (`` is `dtgScheduleEndSoc()`, "?" if not polled yet, times via `appendDtgWindowDetail()`); else reports SPECIFICALLY which of `checkDischargeToGridLogic()`'s own gates (§15 below) is currently withholding discharge, tested in the SAME order that function itself tests them, so the label can never name a reason that isn't really the (first) one actually blocking it: tomorrow forecast hot (`HotDayState.h`'s `readHotDayForecast()`) — "Battery won't discharge tonight⏎(hot day forecast)"; else tomorrow forecast cold (`tomorrowColdDayForecast()`, reading `tomorrowWeatherForecast.tempMaxC` directly against `DischargeToGridTask.h`'s own `coldDayMaxTempC()`) — "Battery won't discharge tonight⏎(cold day forecast)"; else tomorrow's forecast excess too low (`tomorrowExcessTooLow()`, against `minTomorrowExcessKwh()`) — "Battery won't discharge tonight⏎(tomorrow's forecast excess <5kWh)"; else today's car charging too low (`carChargeTodayTooLow()`, against `minCarChargeTodayKwh()`) — "Battery won't discharge tonight⏎(car charge today <10kWh)"; else none of the gates are blocking it, so it WILL discharge — "Battery will discharge to ``% between `` and ``" (whole line red like the `dtgControl.enabled==true` case above, just not ALSO bold — this is the decision logic's own intent, not yet confirmed by an actual Enphase poll, but still a "will discharge" statement, so still red; no parenthetical reason, so no line break here). Every case in this line whose text says "will discharge" (both red cases above) colours the WHOLE line red via `...`, not just the word "will"; the cases that say "will not discharge"/"won't discharge" are not red. The three threshold numbers are read from `DischargeToGridTask.h`'s own `coldDayMaxTempC()`/`minTomorrowExcessKwh()`/`minCarChargeTodayKwh()` rather than duplicated as separate magic numbers, so the displayed figures can never drift out of sync with the real decision's own fixed constants. |
| 2 (charge-from-grid) | Also reports the Hot Day forecast, not just today's live on/off — `tomorrowHot`/`tomorrowReady` (`HotDayState.h`'s `readHotDayForecast()`, read ONCE and shared with line 1 above) drives both; the target% comes from `appendCfgTargetSocPercent()` (shared with line 4, so the two can never show different %s for the same `cfgScheduleTargetSoc()`). Every "will charge from grid..." case is a WHOLE LINE red (`...`, same shape as line 1's own red cases), "will" also bold for the live/confirmed case only; "will not charge from grid" is NOT red: if `cfgPolledEnabled()` (§16) is `true` — "Battery **will** charge from grid to ``% between `` and ``" (`ChargeFromGridState.h`'s `cfgScheduleStartMinutes()`/`cfgScheduleEndMinutes()`/`cfgScheduleTargetSoc()`), PLUS, if `tomorrowReady && tomorrowHot` too (today's own Hot Day carrying into tomorrow's), a SECOND line, also red but not bold: "Battery will charge from grid to ``% tomorrow between `` and ``"; else if `tomorrowReady && tomorrowHot` — "Battery will charge from grid to ``% tomorrow between `` and ``" (red, not bold — a plan, not yet live); else "Battery will not charge from grid" (plain, not red). "Today" and "tomorrow" read the SAME recurring cfg schedule window — there's no separate "tomorrow's schedule" to fetch. |
| 3 (Enphase discharge-to-grid raw status) | A plain, raw readout of Enphase's own actual polled `dtgControl.enabled`, UNCONDITIONALLY — unlike line 1 above, never takes the master switch or any gate into account. A single `` opens here, left unclosed through to the end of line 4 (the `\n` between them doesn't reset the markup, so one opening tag covers both lines): "*Enphase Discharge to Grid down to* ``*% between* `` *and* ``" (`appendDtgWindowDetail()`) if `dtgPolledEnabled()`, else "*Enphase Discharge to Grid disabled*". |
| 4 (Enphase charge-from-grid raw status) | Same shape as line 3, for charge-from-grid, continuing the same italic span opened by line 3's `` (left unclosed, since this is the last line of the whole label): "Enphase Charge from Grid up to ``% between `` and ``" (`appendCfgWindowDetailWithTargetSoc()`) if `cfgPolledEnabled()`, else "Enphase Charge from Grid disabled". `appendCfgWindowDetailWithTargetSoc()` is built from `appendCfgTargetSocPercent()` (the "``%" part — the mirror-image of line 3's own `dtgScheduleEndSoc()`) + `appendCfgWindowDetail()` (the window part); the same `appendCfgTargetSocPercent()` line 2 above also uses, so lines 2 and 4 can never show a different % for the same underlying `cfgScheduleTargetSoc()`. |
`updateAutoBatteryButtonLabel()` (`pfodMainMenuApp.cpp`) —
`button_Auto_Battery_Ch_var`, the master switch toggle itself, from the
latch alone (replacing the OLD single button's own "Battery will/can
discharge to ``% between `` and ``" / "Disable Auto
Battery Discharge" text, which is now line 1 of the status label above
instead):
| Latch | Text |
|---|---|
| enabled | `Auto Battery Charge/Discharge Enabled` / "Click here to Disable" |
| disabled | `Auto Battery Charge/Discharge Disabled` / "Click here to Enable" |
**The auto-discharge decision — `checkDischargeToGridLogic()`
(`DischargeToGridTask.h`/`.cpp`, runs on `InternetPollTask.cpp`'s shared
background task, right after `checkDischargeToGridPoll()` — see the
threading note below for why it lives there and not in
`pfodMainMenuApp.cpp`).** While the button's own latch
(`autoDischargeEnabled()`) is enabled, decides whether Enphase's own
setting should be on, re-checking every 5 minutes until the DTG schedule's
own start time arrives each day:
- **Rule**: computes
tomorrow's forecast excess (`tomorrowSolarData.totalKwh()` minus
`tomorrowAvgConsumptionData.totalKwh()`, the average over however many
of the last 5 days have actually been fetched so far — the SAME
instances §8's `BatteryProjection.cpp` and the Tomorrow chart already
use, with the same 1-5-day partial-data tolerance) and today's
car-charging energy so far (`CarChargerInstances.h`'s
`carChargingKwhToday`, read under `lockCarChargerState()`). Enables
Enphase's setting only if the excess is at least `MIN_TOMORROW_EXCESS_KWH`
(5kWh — replacing the previous plain ">0" threshold) AND today's car
charging is at least `MIN_CAR_CHARGE_TODAY_KWH` (10kWh); otherwise it
disables it. Both are fixed constants in `DischargeToGridTask.cpp`, not
adjustable settings.
- **Only acts on a CHANGE**: re-computes every 5 minutes but only
requests a change when the computed decision actually differs from the
last one requested — never just because 5 minutes passed and the same
answer came out again.
- **Display updates immediately, assumed to have succeeded**: BEFORE
calling `setDischargeToGridEnabled()` (not after — the GET/POST/PUT
chain it runs can take several seconds, and there's no reason to
leave the display stale for that whole round trip when the outcome is
being assumed anyway), calls `setDtgPolledEnabledAssumed()`
(`DischargeToGridState.h`) so the button's "will"/"can" text reflects
the decision right away, instead of sitting stale for up to ~5 minutes
until the next real status poll happens to run. If
the assumption is wrong (the call silently failed), the next real
status poll's own `setDtgPolledStatus()` unconditionally overwrites it
with Enphase's actual state regardless — same "assume success, let the
next real poll correct it either way" convention the boot/button
disable action already relies on. Doesn't touch the schedule window
times (unaffected by this decision) or the latch (already true, by
construction, for this function to be running at all). Immediately
followed by `requestBatteryProjectionRecompute()` (§8) — without it,
`BatteryProjection.cpp`'s own DTG override reads the same
`dtgPolledEnabled()` but only every ~1min on its own independent
throttle, so the SOC plot would still lag behind the button text by up
to that long even after this fix.
- **Stops once the window starts**: `nowMinutes >= dtgScheduleStartMinutes()`
is re-evaluated fresh every call, not a sticky "frozen" flag — once
true, no further changes are requested for the rest of that day,
self-correcting regardless of any button toggling in the meantime and
resetting naturally at the next local midnight (in the ordinary case,
the next daily reboot too).
- **Disabled latch**: doesn't run at all — `DischargeToGridTask.cpp`'s own
boot/button-disable path owns turning Enphase off in that case. Resets
its own state when the latch goes false, so a later re-enable starts
completely fresh and begins requesting changes again immediately.
- **Boot safety gate**: also holds off requesting "on" until the mandatory
boot disable has actually been confirmed off (`bootDisableUnconfirmed`,
read directly — same file, same thread, no cross-thread read needed).
- **Hot Day gate — also
skips the normal decision entirely whenever `HotDayState.h`'s
`readHotDayForecast()` reports tomorrow's forecast as hot
(`tempMaxC >= tomorrowsMaxTempTriggerC()`), re-tested fresh on EVERY
tick, independent of §16's own once-a-day 2:02pm gate: "if forecast max
temp tomorrow is >= setting then disable discharge to grid (if it is
enabled)". Resets `haveDtgDecision` when this gate is hit, same shape as
the disabled-latch branch, so the existing resend-on-mismatch poll
(`runStatusPoll()`'s `wantedOff` check) keeps discharge forced off for
as long as a hot day stays forecast, with no separate one-shot "disable
now" call needed here. A "forecast not ready yet" read retries at 0
rather than falling through — never guesses hot/not-hot from unready
data. See §16 for the full Hot Day feature.
- **Cold Day gate**: also
skips the normal decision entirely whenever tomorrow's forecast max
temp (`tomorrowWeatherForecast.tempMaxC`, read directly under
`lockSolarForecast()`/`unlockSolarForecast()` — the same fields/lock
the Hot Day gate's own `readHotDayForecast()` reads, not reused as a
function since this is a separate, `DischargeToGridTask`-only
threshold) is at or below `COLD_DAY_MAX_TEMP_C` (18°C, a fixed constant, not
an adjustable setting like Hot Day's own `tomorrowsMaxTempTriggerC()`).
Same "reset `haveDtgDecision`, let the existing resend-on-mismatch poll
keep discharge forced off" shape as the Hot Day gate above. A "forecast
not ready yet" read retries at 0, same as every other not-ready gate in
this function.
**Threading — why the decision lives in `DischargeToGridTask.cpp`, not
`pfodMainMenuApp.cpp`** an earlier attempt had
this backwards, with the decision in `pfodMainMenuApp.cpp` and a
request/apply handshake into the background task; that handshake is gone
now. `setDischargeToGridEnabled()` itself must stay confined to
`InternetPollTask.cpp`'s one shared background task (§2's own reason for
existing: avoiding overlapping internet-facing connections), so the
decision that calls it directly — no handoff — lives there too:
`checkDischargeToGridLogic()` is called right after
`checkDischargeToGridPoll()` on every background-task tick, reading that
same poll's own `dtgScheduleStartMinutes()`.
Its two INPUT numbers are read directly too,
not through a throttled-refresh cache: `tomorrowSolarData.totalKwh()`/
`tomorrowAvgConsumptionData.totalKwh()` (`PlotDataInstances.h`, gated on
`avgConsumptionForecastReady()`/`solarForecastReady()`) — the SAME two
totals `Dwg_StatusApp.cpp`'s own status line and `BatteryProjection.cpp`
already read straight off. An earlier version of this design added a
throttled-refresh-plus-mutex cache in `pfodMainMenuApp.cpp` specifically
to avoid a cross-thread read of these main-thread-written totals; that
cache (and its own separate mutex) was removed,
replaced with a single lock that covers both the read side here AND the
write side — `PlotDataInstances.h`'s `lockTomorrowForecastData()`/
`unlockTomorrowForecastData()`. `checkDischargeToGridLogic()` holds it
across BOTH readiness checks AND both `totalKwh()` calls as one atomic
snapshot, so it can never see a readiness flag already true while the
matching `SolarForecastRefresh.cpp`/`AvgConsumptionRefresh.cpp` paint (main
thread) is still in progress. Those two files hold the same lock around
painting their own instance AND setting its own readiness flag together.
The lock is RECURSIVE (`xSemaphoreCreateRecursiveMutex`) because
`solarForecastReady()`/`avgConsumptionForecastReady()` also take it
internally, so they stay safe to call standalone from their existing
main-thread-only callers (`Dwg_StatusApp.cpp`, `BatteryProjection.cpp`,
unchanged) — a plain mutex would deadlock the moment
`checkDischargeToGridLogic()` holds the lock across its own call into
those same functions. `batteryChargeData`/`solarData`/`consumptionData`
(the other three `PlotDataInstances.h` instances) are only ever touched
from the main thread, so deliberately not covered by this lock.
**Battery SOC forecast**: §8's own `BatteryProjection.cpp` reads
`dtgPolledEnabled()`/`dtgScheduleStartMinutes()`/`dtgScheduleEndMinutes()`/
`dtgScheduleEndSoc()` too, overriding its normal projection to a
straight-line drop to the schedule's own configured end SOC% (its
"limit" field, not a hardcoded value) by the schedule's end whenever
Enphase is actually discharging — see that section's own "DTG override"
paragraph.
---
## 16. Hot Day — `HotDayState.h`/`ChargeFromGridState.h`/`ChargeFromGridFetch.h`/`HotDayTask.h`
On a forecast-hot tomorrow,
pre-charges the battery from the grid (Enphase's own `chargeFromGrid`
toggle, a DIFFERENT Enphase feature from §15's discharge-to-grid) and
switches the HW/Car start% over to separate, higher Hot Day thresholds —
instead of letting that evening's ordinary discharge-to-grid schedule run
at all. Continuing §15's Fetch/State/Task split:
- **`BatteryChargeSettings.h/.cpp`** — three more
persisted fields alongside the existing `hw_on`/`ev_on`:
`max_temp_trigger` (`tomorrowsMaxTempTriggerC()`/
`setTomorrowsMaxTempTriggerC()`, 20-40°C, matching the "Tomorrows Max"
slider's own range), `hot_day_hw_on`/`hot_day_car_on`
(`hotDayHwOnPercent()`/`hotDayCarOnPercent()` + their own `OffPercent()`
siblings (-5%, same convention as the originals), 10-100%, matching the
two Hot Day sliders' own range). Validated as its OWN all-or-nothing
group, SEPARATE from `hw_on`/`ev_on` — grouping all five into one check
would silently reset every existing device's already-valid `hw_on`/
`ev_on` to their own defaults too, purely because these three newly-added
fields are (quite normally) absent from an older saved file; keeping
them separate means an upgrade from the old 2-field file just adds the
three new ones at their own defaults, untouched otherwise. Default
values, used on a genuinely first boot or whenever this group is
missing/unreadable/out of range (`HOT_DAY_DEFAULT_MAX_TEMP_TRIGGER_C`/
`HOT_DAY_DEFAULT_HW_ON_PERCENT`/`HOT_DAY_DEFAULT_CAR_ON_PERCENT`):
30°C / 95% / 90%.
- **`HotDayState.h/.cpp`** — `hotDayActive()`/`setHotDayActive()`: the
APPLIED-state latch, persisted to `/hot_day_state.txt`. Unlike
`DischargeToGridState.h`'s `autoDischargeEnabled()`, this is NEVER
force-reset at boot, it survives exactly as left, including this
device's own daily midnight reboot (§11). Also exposes
`effectiveHwSocOnPercent()`/`effectiveHwSocOffPercent()`/
`effectiveEvSocOnPercent()`/`effectiveEvSocOffPercent()` — the Hot Day
settings if `hotDayActive()`, else the ordinary defaults;
`LoadControlTask.cpp`'s `hwCheckStop()`/`hwCheckStart()`/
`carCheckStart()` (§5) call these INSTEAD of `BatteryChargeSettings.h`'s
plain accessors, so load-control gating transparently follows whichever
settings are currently active without needing to know about Hot Day
itself. And `readHotDayForecast()` — compares `WeatherForecastInstances.h`'s
`tomorrowWeatherForecast.tempMaxC` (under `SolarForecastTask.h`'s
`lockSolarForecast()`) against `tomorrowsMaxTempTriggerC()`; returns
`false` (never guesses) if the forecast hasn't arrived yet. Read fresh
on EVERY tick by TWO different callers and this section's own
`checkHotDayLogic()` below (but only ACTED on once a day, at the 2:02pm
gate).
- **`ChargeFromGridState.h/.cpp`** — `cfgPolledEnabled()`/
`cfgScheduleStartMinutes()`/`cfgScheduleEndMinutes()`/
`cfgScheduleTargetSoc()`/`setCfgPolledStatus()`/
`setCfgPolledEnabledAssumed()`: the `chargeFromGrid` counterpart of
`DischargeToGridState.h`'s polled-status fields (`cfgScheduleTargetSoc()`
mirrors `dtgScheduleEndSoc()` -- Enphase's own "limit" field for the cfg
schedule, but charged UP to rather than discharged DOWN to) — RAM only,
no persistence, and no latch of its own to force: whether
`chargeFromGrid` SHOULD be on is entirely `hotDayActive()`, not a
separate button. Mutex-protected, since `BatteryProjection.cpp`'s own
CFG override (§8) reads these fields from the main thread -- a genuine
cross-task read against `HotDayTask.cpp`'s own background-task tick
writes, same shape as `DischargeToGridState.h`'s own mutex-guarded
fields.
- **`ChargeFromGridFetch.h/.cpp`** — pure networking against the SAME
Enphase Enlighten cloud `batteryConfig` API. Same
`sendBatteryConfigRequest()` shape as `DischargeToGridFetch.cpp`,
including that file's own `BP-XSRF-Token` double-submit cookie mirror
(the fix for DTG's own `POST isValid` 403, above) -- carried over on the
inference that both schedule types share the identical CSRF filter
chain (same API family, same `X-Csrf-Header`/`X-Csrf-Param` response
shape confirmed for both in the HAR). The cfg feature differs from dtg
in two confirmed ways:
- `fetchChargeFromGridStatus()` — `GET .../battery/sites/{site}/schedules`
for the "cfg" schedule's own `startTime`/`endTime` (`HotDayTask.cpp`
adds 2 minutes to `endTime` for its own daily decision trigger —
read dynamically, not hardcoded, so it tracks whatever window is
actually configured on Enphase's own schedule-edit page, same
reasoning as §15's `dtgScheduleStartMinutes()`) AND its own `limit`
(the target SOC% charged UP to by the window's end,
`BatteryProjection.cpp`'s own CFG override above reads this —
checked for type/range (0-100%) same as §15's own `limit` check,
just without that one's `BATT_BACKUP_SOC` floor, which is specific
to the discharge direction), then `GET .../batterySettings/{site}`
for the current TOP-LEVEL `chargeFromGrid` boolean — NOT nested
under a `cfgControl` object the way discharge-to-grid nests under
`dtgControl`.
- `setChargeFromGridEnabled()` — `GET .../batterySettings` (for a
fresh `X-Csrf-Token` AND the current `cfgControl.forceScheduleOpted`
flag — checked for type, boundary data, never assumed) →
`POST .../schedules/isValid`
(`{"scheduleType":"cfg","forceScheduleOpted":}` — cfg's
isValid call needs this extra flag echoed back; dtg's doesn't) →
`PUT .../batterySettings` (`{"chargeFromGrid":true|false}`, a
top-level field, not nested).
- **`HotDayTask.h/.cpp`** — runs on the shared background task (§2), right
after §15's two functions. Two jobs, same shape as
`DischargeToGridTask.h`'s own pair:
- a recurring ~5min status poll (`fetchChargeFromGridStatus()`),
publishing into `ChargeFromGridState.h` via `setCfgPolledStatus()`,
then resending `setChargeFromGridEnabled()` whenever the polled
`chargeFromGrid` flag doesn't match `hotDayActive()` — covers BOTH
the ordinary ongoing target AND `requestChargeFromGridDisableNow()`'s
own one-shot "force off now" request (armed by
`pfodMainMenuApp.cpp`'s `onAutoBatteryChargeDisPressed()` the
instant the master switch is turned off), since that request only
ever means "force off", which is also what `hotDayActive()==false`
already wants — no separate resend condition needed. A FAILED poll
retries after 30s instead of the full 5min, same role as §2's
`FETCH_RETRY_DELAY_MS`.
- the Hot Day DECISION itself (`checkHotDayLogic()`) — runs only while
the master switch (`autoDischargeEnabled()`) is enabled (disabled:
resets its own daily-decided flag and stops, same shape as §15's
own disabled branch — the immediate "disable both and revert to
default" transition is `pfodMainMenuApp.cpp`'s own job, not this
function's). Before the daily trigger time (2 minutes past
`cfgScheduleEndMinutes()`), does nothing but keep
re-testing on a 5min cadence (same "early-exit retries at 0" startup
fix as §15's own logic). Once that time is reached, decides AT MOST
ONCE PER DAY (a RAM-only `tomorrowHotDayDecided` flag — named for
what it tracks: the decision made at TODAY's trigger is always
about TOMORROW's forecast, never today's). Unlike §15's own
`haveDtgDecision`/`lastDtgDecision` (which don't need a daily reset
for correctness, just a clean slate, so a reboot-only reset is
fine), this flag is the ONLY thing stopping a second decision the
same day, so it's cleared by a plain time comparison instead — every
day, before its own trigger time is reached — rather than relying on
this device's own daily midnight reboot (§11) to reset it, which
could otherwise leave Hot Day permanently stuck after the first
decision if a reboot was ever missed. Reads `readHotDayForecast()`
and calls `setHotDayActive(isHot)` — persists, no-op if already
matching — then optimistically assumes `setChargeFromGridEnabled(isHot)`
will succeed (`setCfgPolledEnabledAssumed()`, same "assume success,
let the next real poll correct it either way" convention as §15
throughout), fire-and-forget, with the status poll above as its own
retry mechanism.
**On restart (including the daily midnight reboot)**: if `hotDayActive()`
was persisted `true`, the Hot Day HW/Car% (`effectiveHwSocOnPercent()`
etc.) are already in effect from the moment `LoadControlTask.cpp` first
reads them — no boot-time re-application needed, `HotDayState.h`'s own
persistence already covers it. `chargeFromGrid` itself is re-asserted by
the ordinary resend-on-mismatch poll above (due immediately on this
task's very first tick, same `statusPollTimer.start(0)` idiom as every
other poll timer in this project) if Enphase's own side didn't survive
the reboot confirmed — no separate dedicated one-shot boot action needed,
unlike §15's mandatory "always disable on boot" rule, since there's no
single fixed target here independent of `hotDayActive()` to re-assert.
**Master switch disable transition — `pfodMainMenuApp.cpp`'s
`onAutoBatteryChargeDisPressed()`**: switching the master switch off now
performs the WHOLE "stop check and disable both discharge to grid and
charge from grid and switch HW/car back to the default setting"
transition in one shot:
`requestDischargeToGridDisableNow()` (§15, unchanged) +
`requestChargeFromGridDisableNow()` (this section's own one-shot) +
`setHotDayActive(false)` (immediate — HW/Car% revert to default right
away, not left on the Hot Day settings until the next 2:02pm).
**HW/EV - Battery Charge% sub-menu button text**
(`updateBatteryChargeLabel()`, `pfodMainMenuApp.cpp`) — shows the Hot Day
HW/Car% settings and trigger temperature alongside the existing defaults,
then a trailing italic call-to-action line: ``"Set at HW `` EV
``" / "Hot Day >``C HW `` EV ``"`` /
``"Click here to change settings" — each percentage shown as "OFF"
instead of a literal "99%"/"100%", same rule as the original two-field
version of this label.