Skip to content

Feature/3.2#15

Merged
botts7 merged 22 commits into
mainfrom
feature/3.2
Jun 22, 2026
Merged

Feature/3.2#15
botts7 merged 22 commits into
mainfrom
feature/3.2

Conversation

@botts7

@botts7 botts7 commented Jun 22, 2026

Copy link
Copy Markdown
Owner

No description provided.

botts7 and others added 22 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>
@botts7
botts7 merged commit 10e46f4 into main Jun 22, 2026
1 check passed
botts7 added a commit that referenced this pull request Jul 18, 2026
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>
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.

1 participant