Skip to content

Commit edb9874

Browse files
Antonio ...claude
andcommitted
feat: v2.2.0 — measured charging power as charging-state SSOT (status fallback)
Make measured phase charging power (W) the single source of truth for "is the EV charging right now", with the textual charger-status sensor as a fallback. Closes the blind-spot where a wallbox reports `charger_charging` while drawing 0 W, and fixes the dashboard green banner (was exact-match on `charger_charging`). - power_model: read_charging_power (summed phases, all-or-nothing, clamp >=0, kW->W unit normalization), is_charging (power>floor -> tolerant status blocklist), is_plugged_in. Floor 200 W. - config_flow: CONF_CHARGING_POWER/_L2/_L3 optional; CONF_EV_CHARGER_STATUS Required -> Optional (kept as fallback). Steps/entity counts unchanged. - charger_controller: cache measured power for the operation diagnostic ONLY; all control stays commanded (byte-for-byte v2.1.x). - night_smart_charge grid monitor: power-based blind-spot stop with startup ramp grace + low-draw debounce; clears stale clock on sensor unavailable; plug gates proceed on power-only installs (no status sensor). - dashboard: maps charging_power_entity (+ per-phase array), shared _isDrawingNow() across banner/ring/structural-key; animated "live charging" green banner (energy sweep + charge ring, reduced-motion safe). - None-guards for the now-optional status sensor across night/solar/boost/ automations. Diagnostic surfaces charging_power_w/source/basis. Backward compatible: no power sensor -> status/switch-echo fallback, identical to v2.1.x, no migration, entity counts unchanged (66/52). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2d0b4e6 commit edb9874

19 files changed

Lines changed: 1125 additions & 135 deletions

‎CLAUDE.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,44 @@ async def _set_amperage(self, target_amperage: int):
756756

757757
## Version History
758758

759+
### v2.2.0 (2026-06-01)
760+
**FEATURE: Measured charging power as the charging-state SSOT (with legacy status fallback)**
761+
762+
**Problem**: "is the EV charging right now?" was answered by three incompatible proxies — `ChargerController.is_charging()` (commanded switch echo), `get_current_amperage()` (a setpoint, not a measurement), and the textual `CONF_EV_CHARGER_STATUS` string (`charger_charging`), which varies per wallbox. None detect a wallbox that reports `charger_charging` while drawing **0 W** (curtailment, EV paused at 100%, BMS throttle). This was also the root cause of the dashboard's green "EV charging" banner failing to appear: it used an exact `=== "charger_charging"` match that fails on any other brand string.
763+
764+
**Solution**: **measured phase charging power (W)** becomes the single source of truth for `drawing_now`. Single-phase = 1 optional sensor; three-phase = 3 sensors summed. The textual status sensor becomes a **fallback only**. Surfaced through one new reader on the already-shared `ChargingModel` (`power_model.py`, stored on `runtime_data.power_model`), so every consumer reaches the same instantaneous truth.
765+
766+
**Sacred invariant — byte-for-byte backward compat**: existing installs (status mapped, no power sensor) take the fallback path → zero behaviour change, no migration, entity counts unchanged (66 / 52). `read_charging_power` returns `None` when unconfigured → the controller answers from the switch echo exactly as v2.1.x. The regression guard `test_is_charging_no_power_sensor_uses_switch_echo` locks this.
767+
768+
**SSOT API** ([power_model.py](custom_components/ev_smart_charger/power_model.py)): `read_charging_power(hass)` (summed W, **all-or-nothing** in three-phase: any unreadable mapped phase → `None`; clamped `max(0, …)` so a reversed-sign sensor reads a flat 0 W; **unit-normalized kW→W** by `unit_of_measurement` so a kW wallbox sensor agrees with the W floor and the frontend), `is_charging(hass)` (stateless: `power > CHARGING_POWER_DRAWING_FLOOR_W` → else a **tolerant status blocklist** matching the frontend → else False), `is_plugged_in(hass)`. New constants `CHARGING_POWER_DRAWING_FLOOR_W = 200`, `CHARGING_POWER_GRACE_SECONDS = 15`, `NIGHT_GRID_DRAW_START_GRACE_SECONDS = 90`.
769+
770+
**Controller** ([charger_controller.py](custom_components/ev_smart_charger/charger_controller.py)): `_refresh_state` caches `_measured_power_w` **for the operation diagnostic only**. After code review, all controller *control* decisions stay strictly on the commanded switch echo / setpoint (byte-for-byte v2.1.x) — the earlier `_drawing_now_graced()` wiring into the Tuya decrease gate and `recover_to_target` was reverted because a single sub-floor measured glitch could (a) skip the safe Tuya stop/set/start decrease and change current live mid-charge, or (b) restart the charger from scratch on a transient dip. `is_charging()` is unchanged (commanded-on); the measured `drawing_now` is surfaced in the diagnostic via `power_model.is_charging`.
771+
772+
**Config flow**: `CONF_CHARGING_POWER` / `_L2` / `_L3` added as `vol.Optional` to the phase-aware sensors step; `CONF_EV_CHARGER_STATUS` changed Required → **Optional** (kept forever as fallback, never locked). No `async_migrate_entry`, `ConfigFlow.VERSION` stays 1, step counts unchanged (10/9/9). A companion **None-guard** was added to every status reader/subscriber (`night_smart_charge`, `solar_surplus`, `boost_charge`, `automations`) so optional status never crashes setup.
773+
774+
**The one control change** ([night_smart_charge.py](custom_components/ev_smart_charger/night_smart_charge.py) grid monitor Check 2): with a power sensor, the grid-mode session ends on (a) an explicit not-charging **lifecycle** status (free/end — immediate, preserved from v2.1.x), or (b) the **blind-spot** stop — status insists `charger_charging` but measured power stays below the floor for `CHARGING_POWER_GRACE_SECONDS` (debounced; `charger_wait` is treated as the transitional decrease and never debounces). Two code-review hardenings: a **startup ramp grace** (`NIGHT_GRID_DRAW_START_GRACE_SECONDS = 90`) suppresses the blind-spot stop right after the session starts, so a slow-to-draw EV (cold battery, scheduled charging, contactor delay) is not killed ~30 s in; and the debounce clock is **cleared when the power sensor goes unavailable** so a stale timestamp can't fire a premature stop on sensor recovery. No power sensor → byte-for-byte legacy status check. Seven unit tests cover blind-spot stop, debounce wait, keep-charging, lifecycle-survives-noisy-power, legacy fallback, startup-ramp grace, and stale-clock-clear.
775+
776+
**Diagnostic**: Solar Surplus attaches `charging_power_w` / `charging_power_source` / `is_charging_basis` to every diagnostic event (no new entity), so a reversed-sign sensor shows a flat 0 W — the user's cue to apply a `| abs` template fix. The controller diagnostic also carries `measured_power_w` + `drawing_now` for traceability.
777+
778+
**Dashboard**: `dashboard_manager` maps `charging_power_entity` (single) + `charging_power_entities` (per-phase array, whitelisted in `setConfig`). A single shared `_isDrawingNow()` (measured-W, unit-normalized, summed across phases → else a **tolerant status blocklist**, not exact-match) drives the hero banner, the SOC ring, and `_computeStructuralKey` (fixing the three-phase stale-banner case). The tolerant fallback fixes the green-banner symptom even for installs that never map a power sensor.
779+
780+
**Deliberate refinements vs the design plan** (all reviewed):
781+
- The Smart Blocker does **not** get a new power rising-edge listener: its switch-ON listener already triggers it for power-only installs, and its block/allow decision depends on time/solar, not draw. **Known limitation**: a power-only install with no status sensor won't re-trigger the blocker when a car is plugged into an already-on switch (no switch event). Most power-sensor users keep their status sensor, so both listeners remain present.
782+
- `charger_controller.is_charging()` kept as commanded-on (not split); `is_plugged_in()` added but existing plug gates not refactored.
783+
784+
**Code-review fixes (post-implementation, a 7-angle review of the diff)**:
785+
- **Controller reverted to commanded-only control** (see Controller above) — eliminates the live-current-change-on-Tuya-glitch and restart-on-transient-dip risks; `_measured_power_w` stays for diagnostics.
786+
- **Night-charge grid-stop hardened** with the startup ramp grace + stale-clock clear (see The one control change above).
787+
- **Power-only installs (power mapped, status unmapped) now work for Night Smart Charge**: the battery-start pre-check and the handover gate previously read the status string and rejected a `None` status as "not connected", so Night Charge never started without a status sensor. Both now proceed when no status sensor is mapped and let the power-based monitor stop a non-charging session. **Known limitation that remains**: plug-DISCONNECT handling (Solar Surplus releasing control / Hybrid Mode force-exit on `charger_free`) still needs the status sensor — measured 0 W cannot tell "paused" from "unplugged". Mapping the status sensor is recommended for full plug-lifecycle coverage.
788+
- **Backend/frontend SSOT alignment**: `power_model.is_charging`'s status fallback is now a tolerant blocklist (was exact `== charger_charging`), matching the frontend so a non-Tuya brand string ("Charging") reads identically on both sides.
789+
- **kW→W unit normalization in the backend reader** (was frontend-only) — a kW wallbox sensor would otherwise read ~3.7 < the 200 W floor and falsely report "not charging", killing real sessions.
790+
791+
**Files**: `const.py`, `power_model.py`, `charger_controller.py`, `config_flow.py`, `night_smart_charge.py`, `solar_surplus.py`, `automations.py`, `dashboard_manager.py`, `frontend/ev-smart-charger-dashboard.js`, `manifest.json`, `strings.json` + `translations/{en,it,nl}.json`; tests: `test_power_model_and_charger_model.py`, `test_night_smart_charge.py` (+7), `test_config_flow.py` (+2). `VERSION = "2.2.0"`. Full suite green except the 19 pre-existing environment-only baseline failures (identical on clean master).
792+
793+
**Upgrade priority**: 🟢 RECOMMENDED — map your wallbox's charging-power sensor (single-phase) or the three per-phase sensors (three-phase) via reconfigure to get a reliable charging-state SSOT and a working green banner. ⚪ NO-OP for everyone else — without a power sensor, behaviour is identical to v2.1.x (and the tolerant banner fallback still improves on the old exact-match). **Beta ask**: report wallbox brand, sign correctness (diagnostic `charging_power_w` should track real draw, not a flat 0), and three-phase tile match.
794+
795+
---
796+
759797
### v2.1.1 (2026-06-01)
760798
**FIX: Tolerate user-disabled helper entities at startup ([issue #22](https://github.com/antbald/ha-ev-smart-charger/issues/22) — xion2000)**
761799

‎README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,12 @@ Choose the display name used in the Home Assistant UI.
398398
|---|:---:|---|
399399
| Charger switch | Yes | `switch` |
400400
| Charging current control | Yes | `number`, `input_number`, `select`, `input_select` |
401-
| Charger status sensor | Yes | `sensor` |
401+
| Charger status sensor | No (v2.2.0) | `sensor` |
402402

403403
The charger switch entity is used as the unique ID for the config entry. Adding the same charger twice is prevented automatically.
404404

405+
**Charger status sensor (v2.2.0):** now optional. It is used as a *fallback* for charging detection when no charging-power sensor is mapped (see Step 3), and as the source of plug/idle/finished lifecycle. Mapping it is still recommended — it tells "plugged in but not charging" apart from "unplugged", which measured power alone cannot.
406+
405407
### Step 3 — Energy Sensors
406408

407409
| Field | Required | Unit |
@@ -411,6 +413,9 @@ The charger switch entity is used as the unique ID for the config entry. Adding
411413
| Solar production | Yes | `W` |
412414
| Home consumption | Yes | `W` |
413415
| Grid import | Yes | `W` |
416+
| EV charging power (v2.2.0) | No | `W` |
417+
418+
**EV charging power (v2.2.0):** the most reliable signal for whether the car is actually drawing current — when mapped it becomes the single source of truth for charging detection (and the dashboard's green "EV charging" banner), overriding the status string. In three-phase mode map all three per-phase sensors (they are summed) or leave all blank. The sensor must report positive watts while charging; if yours reports negative, wrap it in a template sensor (`{{ states('sensor.your_power') | float(0) | abs }}`) — the diagnostic sensor's `charging_power_w` will read a flat 0 W if the sign is reversed.
414419

415420
### Step 4 — PV Forecast
416421

‎custom_components/ev_smart_charger/automations.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,16 @@ async def async_setup(self) -> None:
210210
f"Using default values. Restart Home Assistant to create missing helper entities."
211211
)
212212

213-
# Listen for charger status changes (charger_free -> charger_charging)
214-
self._unsub_status = async_track_state_change_event(
215-
self.hass, charger_status_entity, self._async_charger_status_changed
216-
)
213+
# Listen for charger status changes (charger_free -> charger_charging).
214+
# v2.2.0: CONF_EV_CHARGER_STATUS is optional — guard before subscribing.
215+
# The switch-ON listener below remains the primary trigger; the power
216+
# rising-edge listener (v2.2.0) is the SSOT trigger when a charging-power
217+
# sensor is mapped. Stored for that wiring.
218+
self._charger_status_entity = charger_status_entity
219+
if charger_status_entity:
220+
self._unsub_status = async_track_state_change_event(
221+
self.hass, charger_status_entity, self._async_charger_status_changed
222+
)
217223

218224
# Listen for charger switch turning ON (off -> on)
219225
self._unsub_switch = async_track_state_change_event(

‎custom_components/ev_smart_charger/charger_controller.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ def __init__(
162162
self._last_operation_time: Optional[datetime] = None
163163
self._current_amperage: Optional[int] = None
164164
self._is_on: Optional[bool] = None
165+
# v2.2.0 — measured charging power (W) cached on each _refresh_state.
166+
# None when no charging-power sensor is mapped (→ drawing-now falls back
167+
# to the commanded switch echo, byte-for-byte legacy behaviour).
168+
self._measured_power_w: Optional[float] = None
165169
self._lock = asyncio.Lock()
166170

167171
self.logger.info(
@@ -195,6 +199,15 @@ async def _emit_operation_diagnostic(
195199
"target_amps": target_amps,
196200
"current_amps": self._current_amperage,
197201
"charger_on": self._is_on,
202+
# v2.2.0: measured charging power (diagnostic only — control uses
203+
# the commanded switch echo). drawing_now is the model's stateless
204+
# verdict; None when no power sensor is mapped.
205+
"measured_power_w": self._measured_power_w,
206+
"drawing_now": (
207+
self._runtime_data.power_model.is_charging(self.hass)
208+
if self._runtime_data and self._runtime_data.power_model
209+
else None
210+
),
198211
"charger_switch": self._charger_switch,
199212
"current_entity": self._charger_current,
200213
"error_message": error_message,
@@ -214,11 +227,21 @@ async def async_setup(self):
214227
)
215228

216229
async def _refresh_state(self):
217-
"""Refresh cached state from Home Assistant."""
230+
"""Refresh cached state from Home Assistant.
231+
232+
``_is_on`` / ``_current_amperage`` stay strictly COMMANDED (switch echo,
233+
setpoint) and drive ALL control decisions (byte-for-byte v2.1.x). v2.2.0
234+
also caches the measured charging power for the operation DIAGNOSTIC only
235+
(it does not gate any control path); None when no power sensor is mapped.
236+
"""
218237
charger_state = self.hass.states.get(self._charger_switch)
219238
if charger_state:
220239
self._is_on = charger_state.state == "on"
221240
self._current_amperage = self._current_control.get_numeric_state()
241+
if self._runtime_data is not None and self._runtime_data.power_model is not None:
242+
self._measured_power_w = self._runtime_data.power_model.read_charging_power(
243+
self.hass
244+
)
222245

223246
async def start_charger(
224247
self,

‎custom_components/ev_smart_charger/config_flow.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
CONF_BATTERY_POWER,
1414
CONF_CAR_OWNER,
1515
CONF_CHARGER_MODEL,
16+
CONF_CHARGING_POWER,
17+
CONF_CHARGING_POWER_L2,
18+
CONF_CHARGING_POWER_L3,
1619
CONF_CREATE_DASHBOARD,
1720
CONF_ENERGY_FORECAST_TARGET,
1821
CONF_EV_CHARGER_CURRENT,
@@ -131,7 +134,13 @@ def _charger_schema(current_data: dict[str, Any] | None = None) -> vol.Schema:
131134
CONF_EV_CHARGER_CURRENT,
132135
**_field_config(current_data.get(CONF_EV_CHARGER_CURRENT)),
133136
): _entity_selector(CURRENT_CONTROL_DOMAINS),
134-
vol.Required(
137+
# v2.2.0: Optional (was Required). Now a FALLBACK for the charging
138+
# power SSOT (used when no charging-power sensor is mapped) and the
139+
# source for plug/idle/finished lifecycle that power cannot express.
140+
# Never removed — kept prefilled in reconfigure/options so users don't
141+
# silently drop their fallback. No "Required-if-set" lock (unlike
142+
# soc_home) because status maps to zero helper entities → no orphans.
143+
vol.Optional(
135144
CONF_EV_CHARGER_STATUS,
136145
**_field_config(current_data.get(CONF_EV_CHARGER_STATUS)),
137146
): _entity_selector("sensor"),
@@ -233,6 +242,23 @@ def _sensor_schema(
233242
fields[vol.Required(l2, **_field_config(current_data.get(l2)))] = _entity_selector("sensor")
234243
fields[vol.Required(l3, **_field_config(current_data.get(l3)))] = _entity_selector("sensor")
235244

245+
# v2.2.0: measured EV charging power — the SSOT for "is the car drawing now".
246+
# Optional (most installs lack a charger CT; the switch echo + status string
247+
# are the fallback). Single-phase = 1 sensor; three-phase = 3 summed. The
248+
# "all three or none" rule is enforced in ChargingModel.read_charging_power
249+
# (Optional fields cannot be made schema-Required without breaking the
250+
# single-phase / no-sensor cases).
251+
fields[
252+
vol.Optional(CONF_CHARGING_POWER, **_field_config(current_data.get(CONF_CHARGING_POWER)))
253+
] = _entity_selector("sensor")
254+
if three_phase:
255+
fields[
256+
vol.Optional(CONF_CHARGING_POWER_L2, **_field_config(current_data.get(CONF_CHARGING_POWER_L2)))
257+
] = _entity_selector("sensor")
258+
fields[
259+
vol.Optional(CONF_CHARGING_POWER_L3, **_field_config(current_data.get(CONF_CHARGING_POWER_L3)))
260+
] = _entity_selector("sensor")
261+
236262
return vol.Schema(fields)
237263

238264

‎custom_components/ev_smart_charger/const.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# ========== INTEGRATION METADATA ==========
44
DOMAIN = "ev_smart_charger"
5-
VERSION = "2.1.1"
5+
VERSION = "2.2.0"
66
DEFAULT_NAME = "EV Smart Charger"
77
FRONTEND_URL_BASE = "/api/ev_smart_charger/frontend"
88
FRONTEND_CARD_FILENAME = "ev-smart-charger-dashboard.js"
@@ -65,6 +65,26 @@
6565
GENERIC_AMP_LEVELS = list(range(6, 33)) # [6, 7, 8, ..., 32]
6666
VOLTAGE_EU = 230 # European standard voltage (per phase)
6767

68+
# ========== CHARGING-STATE SSOT (v2.2.0) ==========
69+
# Drawing-now threshold on the TOTAL measured charging power (single absolute
70+
# value on the summed watts — NOT scaled by phase count). The minimum real
71+
# charging session is ~1380 W single-phase / ~4140 W three-phase total, both far
72+
# above this floor; the floor only rejects EVSE standby / CT noise (5-40 W).
73+
# "Is a session intended / plugged in" is answered by status/command, never the
74+
# floor. Tunable in beta against real wallbox standby observations.
75+
CHARGING_POWER_DRAWING_FLOOR_W = 200
76+
# Command-wins grace window. Measured charging power lags the commanded state
77+
# (ramp-up at start, decay at stop, and the Tuya stop→set→start decrease), so a
78+
# naive ``power > floor`` would oscillate against the rate-limited command loop.
79+
# Within this many seconds of a start/stop command, the command wins.
80+
CHARGING_POWER_GRACE_SECONDS = 15
81+
# Night-charge GRID mode: suppress the measured-power blind-spot stop for this
82+
# long after the session starts. An EV can take tens of seconds to begin drawing
83+
# after the wallbox reports 'charging' (cold battery, scheduled charging,
84+
# preconditioning, contactor delay); without this window the 15 s low-draw
85+
# debounce would false-terminate a legitimate session ~30 s in.
86+
NIGHT_GRID_DRAW_START_GRACE_SECONDS = 90
87+
6888
# ========== PHASE MODE & CHARGER MODEL (v2.0.0, opt-in) ==========
6989
# Phase mode: single-phase (default, unchanged behaviour) or three-phase.
7090
# In three-phase, production/consumption/grid are THREE sensors each (summed)
@@ -104,6 +124,17 @@
104124
CONF_HOME_CONSUMPTION_L3 = "home_consumption_l3"
105125
CONF_GRID_IMPORT_L2 = "grid_import_l2"
106126
CONF_GRID_IMPORT_L3 = "grid_import_l3"
127+
128+
# v2.2.0 — Measured EV charging power (W) sensor(s). The SSOT for "is the car
129+
# drawing current right now" (drawing_now). Unlike production/consumption/grid,
130+
# L1 is a NEW key (no pre-v2.2 single-phase equivalent). Single-phase = 1 sensor;
131+
# three-phase = 3 sensors summed. Optional: when unmapped, the charging-state
132+
# answer falls back to the textual CONF_EV_CHARGER_STATUS sensor → existing
133+
# installs are byte-for-byte unchanged. Unsigned positive watts (no sign toggle;
134+
# reversed-sign sensors read a flat 0 W after the clamp in read_charging_power).
135+
CONF_CHARGING_POWER = "charging_power"
136+
CONF_CHARGING_POWER_L2 = "charging_power_l2"
137+
CONF_CHARGING_POWER_L3 = "charging_power_l3"
107138
# v1.11.14: distinct from CONF_PV_FORECAST. Optional sensor that reports
108139
# the *next-day* solar production forecast in kWh. Consumed only by the
109140
# auto-dashboard "Forecast Domani" chip — Night Smart Charge stays wired

0 commit comments

Comments
 (0)