Skip to content

Release v3.2.0 (stable)#27

Merged
botts7 merged 61 commits into
mainfrom
feature/3.2
Jul 18, 2026
Merged

Release v3.2.0 (stable)#27
botts7 merged 61 commits into
mainfrom
feature/3.2

Conversation

@botts7

@botts7 botts7 commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Promotes 3.2.0-rc.4 → 3.2.0 stable. Soaked clean on two gateways (241h on peter-mcc's box, 20h+ locally), rc.4 confirmed OTA'ing cleanly, and all open reports from the 3.2 cycle resolved.

Highlights since rc.4

  • Crash diagnostics (coredump) — dedicated coredump partition + /api/coredump/summary (crashing task, PC, backtrace over HTTP, no cable/ELF) and auth-gated raw /api/coredump. Groundwork for #168. Adds a partition, so it activates on a USB re-flash; OTA installs are unaffected.
  • Invalid-JSON gateway payload fix — device strings JSON-escaped, chip_temp emits null on NaN (#172).
  • OTA history: shows installed (TO) version (beta.14 → rc.4) and fixes a silent NVS freeze that had stopped history recording on some boxes (Did Reboot Gateway from ESP web page and unit didn't come back up #13).
  • WDT restore panic-flag fix — watchdog could be left unable to reboot after an OTA.

Reports closed this cycle

Full detail in the [3.2.0] section of CHANGELOG.md.

Known / not blocking

  • #168 (watchdog reboot ~1/8–15h) is pre-existing across all of 3.x, self-recovers, and has never been user-reported. It is intentionally not a blocker — the coredump tooling in this release is exactly how it gets chased in a follow-up. It appears environment-specific (local box hits it; peter-mcc's 241h did not).

Release steps after merge

  1. Create GitHub release tag v3.2.0 on the merge commit → CI release job builds + attaches firmware.bin / SHA256SUMS.txt.
  2. The stable build's version string comes from that tag (git-describe → v3.2.0).

🤖 Generated with Claude Code

botts7 and others added 30 commits June 18, 2026 04:12
…ot" (#13)

peter's /info showed 0 reconnect counters this boot but 8 events under
"Events this boot" — those were leftover NVS events recorded by firmware
from before boot-id stamping. The split fell back to e.start<=curUp when an
event had no boot id, which is always true for a low-uptime prior-boot event,
so they bucketed as "this boot". When the summary carries a boot id, an event
without one is definitionally a prior boot → classify it as prior. Keep the
start-heuristic only for the (ancient) case where the summary has no boot id
at all. Counters and the event list now agree; stale events show under "From
prior boots" and age out of the ring over time. /info regression-clean (CDP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The original Pulsar uses a Zentri AMS module running TruConnect serial-over-
BLE (service 175f8f23) — not u-blox (MAX) or BGX13P (Plus). It had zero
support; this adds it, validated on real hardware (gambys, #12): lock/unlock,
start/stop, current control, session energy and autolock all work.

The path, all gated on the TruConnect MODE char (20b9794f) so it's inert on
MAX/Plus/Copper/Quasar (regression-verified on the user's MAX — normal BAPI
unaffected):
- detect the module by the MODE char; relax conn params (latency 0, 6s
  supervision) — the older module dropped the link ~15-17s in otherwise;
- skip the proactive SMP pair — this module is unauthenticated and pairing
  returns ENOTCONN and corrupts the ATT link (empty reads, silent BAPI);
- write STREAM_MODE (1) to the MODE char so the module passes BAPI bytes
  transparently (RX char in, TX char notifications out);
- fragment BAPI writes into (mtu-3) chunks: the module caps the ATT MTU at
  the 23-byte default and never negotiates up, so a ~41-byte frame can't go
  in one write (NimBLE would attempt a long/prepared write the stream char
  rejects). MTU>=44 links (MAX/Plus at 247) keep the single-write path.

Known gap: schedules don't display on this firmware (3.0.0.23) — its r_schs
shape differs; tracked as a #12 follow-up pending the charger's report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The original (Zentri) Pulsar's r_schs times out; it reads schedules one slot
at a time via r_sch with a bare-integer sid (confirmed on gambys's hardware).
Surface the BLE-detected Zentri flag in /api/status, and in loadSchedules
dispatch on it: Zentri reads r_sch 0..3 and normalizes each
{sid,start:"HHMM",stop:"HHMM",days,mcr,nrg} into the array shape
renderSchedules already uses (start/stop are "HHMM" like MAX, so utcToLocal
handles them; enabled = days!=0; nrg -> target). renderSchedules gains a
view-only mode (no toggle/edit/delete buttons + a note) since this firmware's
schedule WRITE method isn't known yet. MAX/Plus unchanged (zentri=false ->
existing r_schs path; regression-verified on the user's MAX: 2 schedules
still render with full edit controls, zero console errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadSchedulesZentri hardcoded slots 0..3. Read sequentially until the charger
reports no more — an error or non-object reply for an out-of-range sid marks
the end — so it adapts to whatever slot count the firmware exposes (gambys's
has 4; others may differ). Empty-but-valid slots are skipped while still
reading past them; MAXSLOTS=16 is a safety backstop for a charger that returns
empty instead of erroring past its last slot. MAX/Plus unaffected (array path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Mon-first labels (#12)

The original Pulsar's schedule days bitmask is Sunday-first (bit0=Sun;
confirmed days:1 == Sundays), but renderSchedules labels bits Monday-first
(DAYS_M). Rotate Sun-first -> Mon-first in loadSchedulesZentri so the view-only
display shows the correct days. Times confirmed stored UTC (utcToLocal already
handles them). Zentri-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
The original Pulsar's schedule write is w_sch with a packed string
"<sid><startHHMM><stopHHMM><days3>": 1-digit slot, UTC start/stop as HHMM,
and a Sunday-first 3-digit day bitmask (e.g. "317002100001" = slot 3,
17:00-21:00, days=Sunday). Delete = the same slot all-zeroed
("<sid>00000000000"). Flips the original-Pulsar schedule UI from view-only to
fully editable:
- loadSchedules sets window._schZentri from /api/status; gated everywhere.
- saveSchZentri builds the w_sch packed string (converts the editor's local
  time -> UTC HHMM and the Mon-first day picker -> the charger's Sun-first
  bitmask); doDeleteScheduleZentri zeroes the slot.
- renderSchedules shows edit+delete (no toggle — this fw has no enabled flag);
  the editor hides the Power/Energy-limit row and Enabled selector (w_sch
  carries none of those). New schedules pick the first free slot (max 4).
MAX/Plus unchanged (window._schZentri=false -> s_sch/clr_sch path);
regression-verified on the user's MAX: toggle+edit+delete intact, editor shows
all fields, zero console errors. Zentri write path awaits gambys's test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… meter

Charge-reminder engine (#127): the BLE task fetches the charger's
schedules (r_schs on array models, per-sid r_sch on Zentri) on a fast
cadence until populated, normalises them (start HHMM UTC + days bitmask,
bit0=Sun), and computes the next enabled-schedule start in UTC against
NTP time. Exposes next_scheduled_charge (epoch) + plug_reminder (due
within reminderLeadMin AND car not connected) on /api/status and the
wallbox/gateway MQTT topic, plus two HA discovery entities (timestamp
sensor + binary_sensor). New reminderLeadMin config field + /config UI.
Dashboard shows a next-charge banner / "not plugged in" warning.

Meter capability (#129): r_dca error code 4 = no Power Boost / Power
Meter (e.g. original Pulsar). Latches meterPresent=false and the
dashboard hides the Power Flow card + House Power + Mains Voltage so a
meter-less charger doesn't show empty grid/solar fields.

Hardware-verified on Pulsar MAX: next_scheduled_charge matched the
charger's daily 14:00 UTC schedule; banner states (info/warn/hidden)
and meter show/hide toggled correctly; no console errors; no page
regressions. Fixed two bring-up bugs: schedule poll was gated behind
5 min uptime (now fast-retries to ~15 s), and r_schs "enabled" is an
integer so the strict `| false` read disabled every schedule (now
.as<bool>()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the official app's connect-cable prompt. The Power Flow Vehicle node swaps its kW label for a '🔌 Plug in' cue when no car is connected, derived from the r_dat status code (same set as carConnected()). Also exposes car_connected on /api/status (NB sta_connected is WiFi, not the car) so HTTP consumers (HA integration, Add-on) get a real cable signal. Verified live: status Ready (st=0) -> Plug-in shown; reverts to kW when connected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ command tagging)

Add control_owner to WBConfig (persisted, default wallbox_schedule) with a /config dropdown (Wallbox native schedule / Integration / Add-on / None). Expose control_owner + last_command_by + last_command_age_s on /api/status. /api/command records the commander (optional &owner= tag, 'manual' if untagged) for start/stop/resume/current via the new wb_control module (mutex-guarded, no alloc under the spinlock) so controllers can detect a manual/other override. Advisory only — never rejects. Config import/export carries ctrl_owner. See docs/control-owner.md. Foundation only; controller behaviour (obey owner, disable/restore overlapping schedules, manual-override backoff) lands in the integration next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dler

The port-80 server is async (wb_web_async.cpp); the sync wb_web.cpp handler is secondary. The owner-tagging recordCommand() must live in the async handler to take effect. Verified live: GET /api/command?action=current&value=32&owner=test sets last_command_by='test' (age 2s) on /api/status with max-current unchanged. (buildStatusJson is shared, so the /api/status control fields already worked.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New wb_charge_log module watches r_dat.cp and records each cp>0 burst {usid,start,stop,wh,gwh} to an NVS ring (survives reboot), ungated from MQTT. Exposed via /api/charge_log + status summary fields + 2 MQTT discovery entities (last_burst_energy, charge_log_count; kDiscoveryCount->68). Per-interval green via Dgen/Den fraction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ties

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sar (#12)

The original/Zentri Pulsar's r_dat omits the `cp` (charge power, kW) field
that Plus/MAX firmware reports — it only carries per-phase currents
L1/L2/L3 (deci-amps). Every consumer (dashboard kW, MQTT charging_power,
the charge-interval log) reads value_json.r.cp, so all of them silently
showed nothing on this hardware.

New wb_zentri_normalize module synthesises `cp` into the cached r_dat the
moment it's polled, so every downstream surface works unchanged:

  cp = (L1 + L2 + L3) / 10  x  V / 1000   [kW]

Voltage resolution, two tiers:
  - measured per-phase voltage from a fitted Power Meter (r_dca v1/v2/v3) —
    exact, region-agnostic, zero config;
  - else a user-set nominal mains voltage (new mainsVoltage config, default
    230) with region presets on /config: 230 UK/EU/AU/Asia, 240 NA L2,
    208 NA 3-phase, 200 Japan, 120 NA L1, 127 LATAM.

Power is only counted while st==1 (charging) so standby/decay current
during "waiting" can't log phantom bursts. Zentri-gated (isZentri()), so
Plus/MAX behaviour is unchanged; the existing cp guard is double protection.

Also adds a Zentri-specific dashboard status-name table (ZN) since the
small Zentri enum doesn't line up with the MAX 0-18 codes (esp. st4 =
charge ramp, not "Paused"), gated on /api/status zentri:true.

Verified: math matches real captures (L1=53@6A -> 1.22 kW, L1=103@11A ->
2.37 kW, 3-phase, idle/standby suppressed); builds clean; OTA to a Pulsar
MAX confirms cp stays native and the new path is skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rity)

Replaces the gateway dashboard's horizontal Grid->Charger->Vehicle Power
Flow card with the HA-style triangle the Add-on already uses: Solar (top) +
Grid (bottom-left) feeding the Vehicle (bottom-right). Per-source kWh come
from the r_lse live-session feed (green_energy / grid_energy /
charged_energy); the solar branch lights only when this session actually
drew solar, grid is the default source, and the live charge rate shows in
the centre. The Vehicle node keeps the segmented battery-fill charge pulse.

New staggered PLse() poller fetches r_lse on its own slower cadence (it's
not a WS push and P() early-returns under WS), offset off the boot burst to
respect the /api/command token bucket. Zentri/no-meter chargers that don't
return r_lse simply show the grid branch + live cp, degrading cleanly.

Built + OTA-verified: /dashboard/body.gz serves the new card (old pf-veh-kw
/ Power Flow markers gone), JS balanced, live r_lse populates the nodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The triangle nodes showed cumulative session energy (r_lse green/grid/
charged kWh), so an idle charger displayed last-session totals with a lit
grid line — looked like active flow when nothing was charging (#forum).

Switch the nodes to LIVE POWER (kW): Vehicle = charging_power, Grid = live
house/grid power from the r_dca meter (p1+p2+p3), Solar = live solar surplus
(r_lse active_feature.surplus_power). Flow lines animate only while
charging; the grid/solar split is approximated from surplus (charger has no
live solar-vs-grid power split). Cumulative session kWh stays in the
"Since plugged in" footer.

Built + OTA-verified; JS balanced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nergy

Solar node still shows live solar surplus AVAILABLE (kW) — honest about
being available, not in-use. The "Since plugged in" footer now shows the
cumulative SOLAR-USED (green) vs GRID energy split (kWh) from r_lse
green_energy/grid_energy — i.e. the official app's "green" number — so
available solar (node, kW) and used solar (footer, kWh) are both visible
and no longer conflated.

Built + OTA-verified; JS balanced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A negative meter reading (p1+p2+p3 < 0) means solar surplus is being
exported to the grid — which is now represented by the Solar node. Showing
it as a negative value on the Grid node (whose arrow points Grid->Vehicle)
was confusing and redundant. Grid now shows import only (max(0, kW)); export
surfaces as Solar surplus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Resume Schedule" action unconditionally sent a hard Stop (w_cha par=2
on the MAX) before s_cmode mode:0, on the assumption that Stop is a no-op
when not charging. A user hit error 114 pressing Resume on a paused/waiting
(not charging) charger — that assumption was wrong; the hard Stop faults the
charger in that state.

Add WallboxBLE::isCharging() (cached r_dat.st==1 / r_sta.charger_status==1)
and gate the defensive Stop on it in both the sync and async /api/command
resume handlers. s_cmode mode:0 is only rejected (subcode 6) while actually
charging, so the Stop is only needed then; when paused/idle, Resume now
sends s_cmode mode:0 alone.

Tested on a Pulsar MAX: Resume while idle sends s_cmode only, error_code
stays 0 (no 114); charger healthy throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Node value text sat at inconsistent heights (Solar +16, Grid +20, Vehicle
+26 from their circle centres), so the bottom-row Grid (y=160) and Vehicle
(y=166) values were misaligned. Unify all three at the same offset
(Solar 58, Grid 156, Vehicle 156), lift the Grid emoji off the value, and
raise the Vehicle car/battery so the value/plug line up with Grid. Also hide
the battery bar when no car is connected (was showing an empty battery beside
"Plug in").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Users running both MQTT discovery AND the HACS Integration get duplicate
entities (each is a separate entity source). Add a "HA MQTT Discovery"
on/off setting (Config -> Advanced, default ON for back-compat). When OFF,
the discovery state machine publishes EMPTY retained payloads to every
config topic (HA's "delete this entity"), so the gateway's MQTT entities are
removed cleanly — pick one entity source.

New WBConfig.haDiscoveryEnabled (NVS "ha_disc"), _discoveryClearing path in
sendDiscovery()/_tickDiscoveryFromTable() with a kind->component map,
/config select, and JSON config backup/restore. The HA Add-on creates no
entities, so it's never part of the duplication. READMEs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "House Power" entity read r_dca.p1 only — so on a 3-phase EM340 it
undercounted (missed L2/L3). Fix it to the net total (p1+p2+p3) and rename
to "Grid Power". Add per-phase Grid Power L1/L2/L3 (diagnostic) and Meter
Total Energy (lifetime kWh, for the HA Energy dashboard) from r_dca.
kDiscoveryCount 68 -> 72 (static_assert verified). Requested by a Pulsar
Plus + EM340 + P1 Power Boost user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ns from charge_log

Two fixes for the original/Zentri Pulsar (reported on #12):

1. applyMeterCapability() hid the WHOLE energy-flow card when no meter (#129).
   That now wrongly drops the derived charge-power visual on meter-less
   chargers. Keep the card; hide only the Solar branch + meter stat cells, so
   Grid -> Vehicle still shows the charge kW. (New pf-node-solar / pf-guide-
   solar ids.)

2. The Sessions heatmap was built only from per-session r_log, which the
   original Pulsar can't serve -> always empty. Add loadFromChargeLog(): probe
   r_log once on the newest session; if unsupported, fall back to the gateway's
   own /api/charge_log intervals (wh->en, gwh->gen) so the heatmap/list
   populate going forward. Avoids the slow 60-sid timeout loop too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auth-only (no CSRF) so the stateless HA integration / Add-on can set the
owner without opening the gateway's own Settings page, matching /api/command.
Persists to NVS immediately (no reboot) so the selection survives a power
cycle. Registered on both the sync and async web servers; owner validated
against wallbox_schedule|integration|addon|none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wallbox status 4 covers both an active Schedule/Solar override (r_dat.gen != 0)
and a plain stopped/idle session (gen == 0). The dashboard now shows
"Connected - not charging" for the idle case and reserves "Paused" for a real
override, matching the HA integration + Add-on so all surfaces agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The charger reported Europe/Tallinn, which wasn't in kTzOptions, so the HA
timezone select rejected it (and log-spammed). Added the three Baltic capitals
(Tallinn, Riga, Vilnius — all EET) alongside the other populated-region zones.
Same class of miss as Amsterdam in #14. The charger stores the IANA name
directly, so no POSIX mapping is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…point

Multi-charger + HA-compatibility fixes (live OTA-tested on a Pulsar MAX):

- MQTT topics namespaced per gateway (wallbox/<ha_device_id>/...) so two
  chargers no longer share wallbox/status — fixes HA entity values cycling
  between chargers (andypnz). Entities re-point automatically on update.
- Default HA Device ID is now MAC-unique (wallbox_pulsar_<mac6>) so two
  out-of-box gateways get distinct entity IDs. Existing installs keep their
  saved ID (only a fresh/wiped flash picks up the new default).
- gw_fw in /api/status — lets the Add-on / Integration warn when firmware is
  too old for the fields they read.
- wb_copper_normalize scaffold (#20): self-detecting no-op mapping layer for
  Copper SB / Business firmware (different r_dat/r_dca field names); wired into
  _pollStatus, ready to fill in once raw dumps arrive.
- POST /api/reboot_gateway (#150): auth-only, no-CSRF reboot for the stateless
  integration / Add-on, on both sync and async servers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a marginal BLE link (antenna-less S3 through the charger enclosure) the
periodic r_dat/r_sta could drop while an occasional read still got through,
leaving the status cache empty for the whole session -> dashboard/HA grid shows
"--". _pollStatus/_pollRealtime now retry once on an empty read. Static analysis
confirmed the poll and on-demand paths are identical, so this is a runtime
(link-timing) failure; root cause pending a serial log, this is cheap insurance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
)

- WiFi (#13, peter-mcc): a soft WiFi.reconnect() can't recover a wedged WiFi
  stack after long uptime (dropped at ~79h, needed a power-cycle). Escalating
  watchdog in wb_net::tick(): >5 min down -> full WiFi stack restart (off/on +
  begin); >30 min down -> ESP.restart(). Reset on GOT_IP; healthy links untouched.
- MQTT (#20, ManuMaxGit): an unreachable broker (rc=-2) was retried every 5s
  forever, wasting the loop + overflowing the pub ring. Reconnect gate now backs
  off exponentially (5s -> 60s), resets to 5s on connect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of "blank status boxes + integration no data" on MQTT-less gateways
(ManuMaxGit, blank MQTT host). The BLE->web cache bridge (webServer.updateCache)
and the WS broadcast ran only inside `if (mqtt.isConnected())` in the main loop,
so /api/charger served null to the dashboard AND the integration whenever MQTT
wasn't connected — even though BLE polling worked fine.

- main.cpp: status/realtime/meter/settings drains now run unconditionally; each
  gates only its own MQTT publish internally. Web cache + WebSocket update
  regardless of MQTT.
- wb_mqtt.cpp: don't attempt MQTT at all when the broker host is blank (stops
  pointless connect() churn on integration/add-on-only setups).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds WB_BOARD (the PlatformIO env name, e.g. "esp32s3") via version.py and
emits it as /api/status.board. The HA integration's new firmware Update entity
reads this to fetch the release .bin matching the gateway's build target
(future-proofs the multi-target case; the integration falls back to esp32s3 when
absent). Single source of truth — no separate board string to maintain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
botts7 and others added 28 commits July 5, 2026 18:28
last_burst_energy was published with state_class=measurement, which HA
rejects for device_class=energy (log spam). It's a per-burst snapshot, not
a cumulative total, so it now carries no state_class. Rides beta.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#13)

The live-session energy read (r_lse — largest periodic BAPI) freed its raw
response only after building the sanitized copy, briefly holding three copies
of the payload at once. deserializeJson copies into the document's own pool,
so the raw String can be freed right after parsing — dropping the peak to two
copies and easing heap-fragmentation pressure at long uptime.

A rare panic (#13) was traced to this read path on a strong, stable BLE link
(-54 dBm), which points at heap pressure rather than a link race. Defensive
insurance on the breadcrumb'd path; not a confirmed root cause. Rides beta.14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes verified on hardware, both rolled into beta.14:

- OTA falsely rejected valid uploads as "truncated": the size check compared
  firmware bytes written against request Content-Length, which for a multipart
  upload (HA integration + OTA page) includes the boundary + headers (a few
  hundred bytes not flashed). Longer boundaries tripped the 256-byte tolerance
  and aborted complete images, forcing USB re-flashes (gambys, ManuMaxGit).
  Raise the tolerance well clear of multipart framing in both OTA handlers;
  Update.end()'s image checksum stays the real integrity guard. Confirmed: a
  332-byte-framing upload that the old check rejected now flashes cleanly.

- Start/Stop now idempotent (#23): a start while already charging, or a stop
  while already stopped, is skipped. Some chargers (Pulsar Plus USA fw) treat
  w_cha as a TOGGLE, so a redundant write flipped the state the wrong way (set
  switch on-while-on -> off). New wallboxBLE.startStopRedundant() gates the sync
  web, async web, and MQTT handlers. Confirmed on hardware: stop-while-idle
  skipped, start still dispatches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up tidy on the merged EM340 per-phase sensors (#22): the group had
two comment blocks — the original one (predating the voltage/current
additions, so it only mentioned power+energy) and a second column-0 block
that broke the array's 4-space indentation. Fold them into one accurate
header covering power, mains voltage, house current and lifetime energy,
and drop the mis-indented duplicate. Comment-only; no entity or logic change
(discovery count stays 76).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ta.15)

A failed OTA left BLE paused for the full 5-min pause window (OTA pauses BLE
to free resources, but the error path never un-paused it), so a transient
flash hiccup looked like a dead gateway with BLE off. The OTA-error path now
un-pauses BLE (pause(0)) so it reconnects immediately and a retry works.

Also, the generic 500 "Upload failed" now carries the actual flash-layer
reason (flash begin failed / write failed / bad magic byte / truncated) via
otaRejectReason, which the HA integration already relays in its error — so an
OTA failure is diagnosable instead of opaque. Reproduced on the user's gateway
(HA update -> 500 "Upload failed" -> BLE stuck off until the pause lapsed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nsors

Release candidate for 3.2.0. Consolidates the OTA-error fixes and adds the
per-phase EM340 meter diagnostics (#22) to the notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#162: expose the ESP32-S3 internal temperature as chip_temp in /api/status and
as an MQTT diagnostic sensor ("Gateway Temperature"), so heat issues (e.g. a hot
garage — _Mike post #82) are visible instead of guessed. kDiscoveryCount 76->77.

#161: BLE error logs now decode the NimBLE host code (e.g. "err 0x07 ENOTCONN
(link dropped mid-op)") on the secureConnection/encryption/disconnect paths, so
a user's pasted log is self-diagnosing rather than a bare hex to look up.

Built clean on esp32s3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…all (#166)

The open burst lived only in RAM, so a reboot/OTA mid-charge — or an r_dat feed
stall that never delivered the cp-drop close-sample — silently dropped the whole
charge (intervals are only written on close). Root-caused from the user's log
going silent June 28 across a reboot-heavy OTA window.

- Persist the open burst to NVS on open + throttled every PERSIST_INTERVAL_S
  (300s), cleared on close. begin() recovers a persisted open burst by appending
  what was captured as a completed interval.
- wb_charge_log::tick() (called every main loop, self-throttled) auto-closes an
  open burst with no fresh cp sample for STALE_TIMEOUT_S (600s) — the exact case
  where the seq-gated feed is frozen and onRealtime never runs. Built clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The HA integration's OTA picks the release asset whose name ends '-<board>.bin'
(update.py pick_asset), but the release workflow only attached the generic
firmware.bin / bootloader.bin / partitions.bin (for ESP Web Tools) — so with 3+
unnamed .bins pick_asset couldn't disambiguate and HA reported "No firmware
asset for board 'esp32s3'". beta.14 worked because it shipped a
wallbox-gateway-<tag>-esp32s3.bin; a workflow refactor dropped it.

Add a board-named copy of the app firmware (wallbox-gateway-<tag>-esp32s3.bin)
to the release alongside the ESP-Web-Tools parts. rc.2 was backfilled manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…log (rc.3)

Pre-stable code-review fixes:
- chip_temp is now published in publishGatewayInfo() (the MQTT gateway payload),
  not just wb_buildStatusJson() — the "Gateway Temperature" discovery sensor was
  reading a missing field (stuck at 0).
- OTA error path un-pauses BLE only when THIS upload actually took the flash-start
  pause (new _otaBlePaused gate). Prevents an early admission reject (auth /
  another-OTA-in-progress / canAcceptOta) — which returns before the pause — from
  cancelling the pause and, for a concurrent upload, un-pausing BLE mid-flash.
- Charge-log boot recovery skips a burst already matching the newest stored
  interval (usid+start), so a crash between closeBurst()'s two NVS writes can't
  double-append.

Built clean on esp32s3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…race, per-model fixes (rc.4)

Charge-log recording was dead since late June — two causes, both fixed and
verified on hardware:
- Detection now fires on metered en rising, not just cp>0.10 (which missed
  Eco-Smart solar where cp reads ~0 while energy flows). Burst energy is metered
  Δen when available, else the cp integral (Zentri, which has no en).
- store() frees the old NVS value before writing, so a growing interval ring
  persists on a near-full partition (was silently failing → ring stuck).
- Green Wh now from authoritative r_lse.green_energy, not the r_dat.gen override
  flag (was costing solar at the grid rate).

Stability + per-model correctness:
- _parserMutex serialises the NimBLE notify callback vs _sendCommandDirect
  reset()/move() (the marginal-link response-buffer race → panic).
- MQTT resume_schedule Stop gated on isCharging() (a Stop while paused faults
  the charger, error 114).
- den MQTT scale ÷100 (was ÷1000, 10x off vs the integration).
- removed bogus MQTT green_energy sensor (was the override flag as kWh).
- car_connected uses the Zentri st enum when _isZentri.
- Zentri keepalive gated on isPlus() || _isZentri (was reconnect-looping).
- web /api/command?action=current clamped 6-32 A.
- docs/CHARGER_QUIRKS.md: per-model quirk/diff catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ntrol_mode)

/api/status now exposes schedule_paused = (r_lse.control_mode == 1), the real
"manual override active" signal (model-agnostic). Falls back to r_dat.gen != 0
only for chargers without r_lse (Zentri/original Pulsar, where gen genuinely is
the override flag). On the MAX Pro gen is accumulated green energy, so the old
gen!=0 guesses in the dashboard/add-on/integration were wrong (false-positive
"paused" banner during solar, never cleared on Resume). Verified the signal on
hardware (control_mode 0->1 on manual override, back to 0 on resume).

NOTE: not yet flash-verified — the local PlatformIO toolchain is broken
(xtensa-esp32s3-elf-g++ missing after a mid-session esptool reinstall); code
compiles, flash pending a toolchain repair. Gateway currently runs the prior
good build (capture + store + BLE-race fixes all present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wb_chipTempJson() (shared by /api/status + the MQTT gateway payload) emits the
real reading on ESP32-S3/S2/C3/C6 and JSON null on the classic ESP32 (WROOM),
whose temperatureRead() returns a fixed/garbage value. MQTT template renders
empty on null → HA shows "Gateway Temperature" unavailable instead of a fake 0.
No behaviour change on S3. Future-proofs the esp32dev target (#154).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
)

The BLE task queued every /api/command passthrough response for MQTT publish
regardless of MQTT state, so HACS-integration-only setups (MQTT off / no broker)
filled the small pending-pub ring on every poll -> "pending MQTT pub ring full,
dropping met=..." forever, with the sustained churn the likely panic trigger.
Now gated on wallboxBLE._mqttPubEnabled, which main sets from
wallboxMQTT.isConnected() each loop. MQTT off -> ring stays empty; nothing lost
(the response still returns via the wake path + caches). Reported by
geertvanvaerenbergh (rc.3, S3, Pulsar Plus).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…status payloads

The /api/status and MQTT gateway payloads are hand-built via += concatenation, so
a device-reported string with a stray quote/backslash/control char (partial BLE
read, unusual WiFi SSID) — or chip_temp returning NaN ("nan") — produced invalid
JSON that HA couldn't parse, logging "Invalid state message '' from
wallbox/.../response/gateway" and briefly blanking the gateway sensors. Now
wb_jsonEsc() escapes every device string in both builders and wb_chipTempJson()
emits null on a NaN/inf read. Latent robustness bug (not an rc.4 regression);
reported on the user's gateway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gateway has been watchdog/panic rebooting roughly once every 8-15h
across rc.3, rc.4, beta.14 and current tip alike, and every investigation
has died at the same wall: no evidence. The panic handler prints the
offending task to UART and the reset throws it away.

Arduino's precompiled IDF libs already ship CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH
+ DATA_FORMAT_ELF and link libespcoredump.a — the firmware has always been
able to write a full ELF core. It just had nowhere to put it. This adds the
somewhere.

The 128K comes out of the app slots (0x1F0000 -> 0x1E0000 each; the current
image is 1.44M, leaving ~525K headroom). nvs (0x9000) and nvs2 (0x3F0000)
keep their exact offsets so settings and the charge log survive a re-flash.

A partition-table change cannot be applied over OTA, so this is inert for
existing installs (old table, no coredump, no harm — a build for the 1920K
slot still fits their 1984K one). It takes effect on a USB flash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
esp_task_wdt_init()'s second arg is "panic on timeout". extendTo() passes
false deliberately — a long OTA flash-erase must not reboot us mid-write.
But restore() passed false too, so every extend/restore cycle left the
watchdog permanently unable to reset the box: a later wedge would log and
hang instead of recovering, and the crash evidence would go with it.

restore() now hands back the boot default (CONFIG_ESP_TASK_WDT_PANIC=1).
Symmetry: restore() must undo both of extendTo()'s changes, not just the
one it is named after.

Found while investigating #168; not the cause of it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The coredump partition (c2259e3) means panics now leave a full ELF core in
flash, but getting it out meant unplugging the gateway and reading it over
serial with esp-coredump. That is impractical for a bug that fires once a
day, and flatly impossible for a user reporting a crash on their own box.

New wb_coredump module + three routes:

  GET  /api/coredump/summary  crashing TASK NAME, PC, exception cause and
                              backtrace PCs, straight from
                              esp_core_dump_get_summary(). Needs no ELF and
                              no cable - this alone answers "which task
                              died", the question #168 has been stuck on all
                              along.
  GET  /api/coredump          raw ELF core, streamed chunked off flash (the
                              image is hundreds of KB; building it into a
                              String would blow the heap - see #103).
  POST /api/coredump/clear    erase. POST, not GET, because it destroys
                              evidence: a prefetch or crawler must not be
                              able to wipe a crash we have not read.

Two things worth calling out:

- The raw dump is gated STRICTER than _checkAuth. _checkAuth waves everything
  through when auth is disabled, which is fine for /api/status but would hand
  a full RAM image - MQTT password, auth password, whatever was in memory -
  to any client on the LAN. /api/config/export already masks its secrets; a
  memory snapshot cannot be masked, so we refuse with 403 and point at the
  summary instead. The summary carries no secrets and stays open.

- summaryJson() reports "partition":false distinctly from "present":false.
  A device whose table predates the coredump partition can NEVER capture a
  crash until a USB re-flash; reporting that identically to "no crash yet" is
  how you wait a week for evidence that was never coming.

Verified on hardware: partition found ("partition":true), raw dump 403s with
auth off, summary serves, GET /clear does not erase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…reeze + releases link (#13)

Three things in the /info Firmware section, all from peter-mcc's #13 follow-up.

1. OTA history now shows what an upload INSTALLED, not just what was running
   when it started. His rc.4 upload displayed as "beta.14" (the FROM), reading
   as "an OTA about beta.14" rather than "the one that got me to rc.4".

   The TO version is backfilled on boot: when the new firmware reaches healthy
   state, recordBoot() walks back to the OTA entry that installed it and stamps
   WB_VERSION onto it. That's the correct source — an earlier attempt to read
   the incoming image's app-descriptor version returned the IDF version string
   ("esp-idf: v4.4.7 ..."), because our git-describe WB_VERSION is a custom
   macro, not the ESP-IDF app version field. The row now renders
   "beta.14 -> rc.4" when the two differ; failed/aborted uploads and
   never-booted images keep no target, so the row reads as before.

2. Fix a SILENT NVS write freeze in the history store (found while verifying
   #1 on hardware: this box's history was stuck at beta.6 while a dozen later
   flashes went unrecorded). putString() on a near-full / fragmented NVS
   returns 0 and leaves the OLD value in place with no error — the same
   silent-fail class as the charge-log store. Now we remove the key first so
   the write has a free page, and log a failure instead of losing it quietly.

3. Added a direct link to the GitHub releases page from the Firmware section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the [3.2.0] section: promotes 3.2.0-rc.4 to stable and documents the
post-rc.4 work (coredump diagnostics, OTA-history TO-version + NVS-freeze fix,
WDT panic-flag fix, releases link).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main's only unique commit is the squash merge of PR #15 — an older snapshot of
feature/3.2's own history (base 1412196 is a shared ancestor). feature/3.2 is
strictly newer for every file, so -s ours keeps feature/3.2's tree intact and
records main as merged, clearing the artificial squash-vs-incremental conflicts
so the v3.2.0 PR (#27) can merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@botts7
botts7 merged commit e21c1d7 into main Jul 18, 2026
1 check passed
botts7 added a commit that referenced this pull request Jul 18, 2026
Brings the v3.2.0 release tag into feature/3.2's ancestry so git-describe on
the dev branch anchors to v3.2.0 (not the older rc.4), keeping dev-build
version strings monotonic — a build off feature/3.2 now reports
'v3.2.0-N-g<hash>' (> 3.2.0) instead of 'v3.2.0-rc.4-N-...' (< 3.2.0), so the
HA update entity never mistakes newer dev code for a downgrade target. Content
is unchanged (main == the PR #27 merge of this branch); this only records the
ancestry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants