Skip to content

Commit 689b85d

Browse files
author
Antonio ...
committed
fix: v2.9.1 — brand-vocabulary status classifiers (available/charged no longer misread)
Closes the residual from the v2.9.0 root-cause: non-Tuya wallboxes report OCPP-style 'available' (= no EV connected) and 'charged' (= session done), but three call sites still compared exact Tuya strings: - night-charge connect gates rejected only 'charger_free' → 'available' passed as connected and a session started with no car plugged - late-arrival detection compared old_state == 'charger_free' exactly → never fired on an available→charging plug-in - the v2.9.0 grid lifecycle blocklist matched only charger_free/charger_end New centralized classifiers in power_model.py — is_disconnected_status() and is_charge_complete_status() — case-insensitive conservative allowlists of unambiguous synonyms (unknown strings default to connected: the safe failure mode). Applied to both connect gates, late-arrival, both grid lifecycle paths, and power_model.is_charging/is_plugged_in. Solar Surplus / Smart Blocker gates intentionally untouched. Tuya installs byte-for-byte unchanged. Tests: +7 in tests/test_night_smart_charge.py, full suite 278 passed / 0 failed.
1 parent b191f6e commit 689b85d

7 files changed

Lines changed: 271 additions & 21 deletions

File tree

‎CLAUDE.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,53 @@ async def _set_amperage(self, target_amperage: int):
757757

758758
## Version History
759759

760+
### v2.9.1 (2026-07-19)
761+
**FIX: Brand-vocabulary status classifiers — 'available' no longer passes as "connected", 'charged' recognized as charge-complete**
762+
763+
Closes the residual identified while root-causing the v2.9.0 incident: the same
764+
non-Tuya wallbox that reports `charging` also reports OCPP-style **`available`**
765+
(= no EV connected) and **`charged`** (= session done). Three call sites still
766+
compared against exact Tuya strings:
767+
1. The night-charge **connect gates** (evaluation + handover) rejected only
768+
`charger_free` → `available` passed as "connected" and a session started
769+
**with no car plugged** (observed at 00:00 in the 2026-07-19 log).
770+
2. **Late-arrival detection** compared `old_state == charger_free` exactly →
771+
never fired on an `available` → `charging` plug-in transition.
772+
3. The v2.9.0 grid-monitor lifecycle blocklist matched only literal
773+
`charger_free`/`charger_end`.
774+
775+
**Fix — two centralized classifiers in [power_model.py](custom_components/ev_smart_charger/power_model.py)**
776+
(module-level, case/whitespace-insensitive, conservative allowlists of
777+
unambiguous synonyms; unknown strings default to "connected / not complete" —
778+
the safe failure mode, so no unknown vocabulary can ever lose night charging):
779+
- `is_disconnected_status()` — `charger_free`, OCPP `available`,
780+
`disconnected`, `unplugged`, `not_connected`, `no_car`, … (deliberately NO
781+
`idle`: ambiguous across brands).
782+
- `is_charge_complete_status()` — `charger_end`, `charged`, `complete(d)`,
783+
`finished`, OCPP `finishing`, `full`, …
784+
785+
Applied in `night_smart_charge.py` (both connect gates, late-arrival
786+
transition, both grid-monitor lifecycle paths — legacy path keeps its
787+
`charger_wait` + unavailable fail-safe) and in `power_model.is_charging`
788+
(status fallback) / `is_plugged_in`. Solar Surplus / Smart Blocker / Hybrid
789+
Mode `charger_free` gates intentionally untouched (skip-optimizations; wider
790+
blast radius for no observed defect). Tuya installs byte-for-byte unchanged
791+
(the Tuya strings are members of the classifier sets).
792+
793+
**Files**: `power_model.py`, `night_smart_charge.py`, `const.py` +
794+
`manifest.json` (VERSION), `README.md`, this file; tests:
795+
`tests/test_night_smart_charge.py` (+7: classifier units incl. OCPP
796+
`SuspendedEV`-stays-connected, brand disconnected/charged lifecycle stops on
797+
both grid paths, late-arrival fires on `available`→`charging` and not on
798+
`charging`→`charged`). `VERSION = "2.9.1"`. Full suite green: **278 passed /
799+
0 failed**.
800+
801+
**Upgrade priority**: 🟢 RECOMMENDED for non-Tuya wallboxes (companion to
802+
v2.9.0 — prevents phantom no-car sessions and makes plug-in late-arrival
803+
detection work). ⚪ NO-OP for Tuya vocabularies.
804+
805+
---
806+
760807
### v2.9.0 (2026-07-19)
761808
**FIX: Night Smart Charge — brand-status false stop in grid mode + "completed_today" ignores user intent changes**
762809

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ Most projects target one charger brand or assume a single-phase home. EV Smart C
5858
5959
> **📱 New in v2.7.3+** — EV charging **Live Activities / Live Updates** for the Home Assistant Companion app. When enabled from **Dashboard → Settings → Notifications**, the integration keeps a single `evsc_ev_charging` activity updated for Boost Charge, Night Smart Charge, Solar Surplus, Force Charge, and normal charging detected by the shared `charging_power` / charger-status SSOT. Updates are throttled to protect the iOS push budget and the activity closes only after charging has stopped for two monitor ticks. **Default: OFF**.
6060
61+
> **🔌 New in v2.9.1** — **brand-vocabulary status classifiers**. "Is the cable disconnected?" and "has the charge completed?" are now answered by two centralized, case-insensitive classifiers in `power_model.py` (`is_disconnected_status` / `is_charge_complete_status`) instead of exact Tuya-string comparisons. This fixes the remaining v2.9.0 blind spot: on wallboxes reporting OCPP-style statuses (`available` = no EV connected, `charged` = session done), Night Smart Charge could start a session **with no car plugged** (the connect gate only rejected `charger_free`) and the plug-in *late-arrival* detection never fired (it compared against `charger_free` exactly). The classifiers are conservative allowlists of unambiguous synonyms — an unknown brand string still defaults to "connected", so no existing setup can lose night charging. Applied to the night-charge connect gates, late-arrival detection, and the grid-monitor lifecycle stops; `power_model.is_charging` / `is_plugged_in` now share the same vocabulary.
62+
6163
> **🛠️ New in v2.9.0** — two Night Smart Charge reliability fixes. **(1)** The grid-mode monitor no longer terminates a session because your wallbox reports a brand-specific status string: the old check accepted only the Tuya vocabulary (`charger_charging` / `charger_wait`), so a wallbox reporting e.g. `charging` had its grid session killed ~15 s after the battery→grid fallback — leaving the EV under target for the rest of the night. The check is now the same tolerant blocklist used everywhere since v2.2.0 (only the explicit `charger_free` / `charger_end` lifecycle statuses stop the session; the measured-power blind-spot check keeps protecting against a genuinely idle charger). **(2)** Changing **today's** EV SOC target or turning on **today's** *car ready* switch after a session already ended now **re-arms Night Smart Charge**: the "completed today" latch (and its 1-hour cooldown) is cleared and the session restarts within a minute if the night window is still open. Previously those changes were silently ignored until the next day. **STRONGLY RECOMMENDED for non-Tuya wallboxes.**
6264

6365
---

‎custom_components/ev_smart_charger/const.py‎

Lines changed: 1 addition & 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.9.0"
5+
VERSION = "2.9.1"
66
DEFAULT_NAME = "EV Smart Charger"
77
FRONTEND_URL_BASE = "/api/ev_smart_charger/frontend"
88
FRONTEND_CARD_FILENAME = "ev-smart-charger-dashboard.js"

‎custom_components/ev_smart_charger/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@
1111
"iot_class": "local_push",
1212
"issue_tracker": "https://github.com/antbald/ha-ev-smart-charger/issues",
1313
"requirements": [],
14-
"version": "2.9.0"
14+
"version": "2.9.1"
1515
}

‎custom_components/ev_smart_charger/night_smart_charge.py‎

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@
1818
CONF_EV_CHARGER_CURRENT,
1919
CONF_BATTERY_CAPACITY,
2020
CONF_ENERGY_FORECAST_TARGET,
21-
CHARGER_STATUS_CHARGING,
22-
CHARGER_STATUS_END,
23-
CHARGER_STATUS_FREE,
2421
CHARGER_STATUS_WAIT,
2522
CHARGING_POWER_DRAWING_FLOOR_W,
2623
CHARGING_POWER_GRACE_SECONDS,
@@ -59,7 +56,11 @@
5956
has_home_battery,
6057
)
6158
from .localization import translate_runtime
62-
from .power_model import ChargingModel
59+
from .power_model import (
60+
ChargingModel,
61+
is_charge_complete_status,
62+
is_disconnected_status,
63+
)
6364
from .runtime import EVSCRuntimeData
6465
from .charger_controller import CurrentControlAdapter
6566
from .utils.logging_helper import EVSCLogger
@@ -665,7 +666,13 @@ async def async_try_handover_from_solar_surplus(self, reason: str = "") -> bool:
665666
# power-based monitor stop if the car is not actually charging.
666667
if self._charger_status:
667668
charger_status = state_helper.get_state(self.hass, self._charger_status)
668-
if not charger_status or charger_status in ("unknown", "unavailable", CHARGER_STATUS_FREE):
669+
# v2.9.1: brand-vocabulary aware — 'available' (OCPP) etc. mean
670+
# disconnected just like Tuya's 'charger_free'.
671+
if (
672+
not charger_status
673+
or charger_status in ("unknown", "unavailable")
674+
or is_disconnected_status(charger_status)
675+
):
669676
self.logger.info(f"Handover rejected - charger not ready (status: {charger_status})")
670677
return False
671678

@@ -777,8 +784,12 @@ async def _async_charger_status_changed(self, event) -> None:
777784
if not new_state or not old_state:
778785
return
779786

780-
# Detect car just plugged in (from free to any other state)
781-
if old_state.state == CHARGER_STATUS_FREE and new_state.state != CHARGER_STATUS_FREE:
787+
# Detect car just plugged in (from a disconnected status to any other).
788+
# v2.9.1: brand-vocabulary aware — the old exact 'charger_free'
789+
# comparison never fired on wallboxes reporting e.g. 'available'.
790+
if is_disconnected_status(old_state.state) and not is_disconnected_status(
791+
new_state.state
792+
):
782793
self.logger.info(f"{self.logger.EV} Car plugged in (status: {new_state.state})")
783794

784795
# Check if we're in active window and enabled
@@ -1194,7 +1205,15 @@ async def _evaluate_and_charge(self) -> None:
11941205
self.logger.info(f" Charger status: {charger_status}")
11951206

11961207
# v1.3.22: Check for invalid/unavailable states
1197-
if not charger_status or charger_status in ["unavailable", "unknown", CHARGER_STATUS_FREE]:
1208+
# v2.9.1: brand-vocabulary aware disconnect detection — an
1209+
# OCPP-style 'available' (= no EV plugged) previously passed
1210+
# this gate (only 'charger_free' was rejected) and a session
1211+
# started with no car connected.
1212+
if (
1213+
not charger_status
1214+
or charger_status in ["unavailable", "unknown"]
1215+
or is_disconnected_status(charger_status)
1216+
):
11981217
if charger_status in ["unavailable", "unknown"]:
11991218
self.logger.warning(f"{self.logger.ALERT} Charger status sensor {charger_status} - cannot determine connection")
12001219
reason = f"sensor {charger_status}"
@@ -1885,11 +1904,9 @@ async def _async_monitor_grid_charge(self, now) -> None:
18851904
if (
18861905
not charger_status
18871906
or charger_status in ("unknown", "unavailable")
1888-
or charger_status in (
1889-
CHARGER_STATUS_FREE,
1890-
CHARGER_STATUS_END,
1891-
CHARGER_STATUS_WAIT,
1892-
)
1907+
or charger_status == CHARGER_STATUS_WAIT
1908+
or is_disconnected_status(charger_status)
1909+
or is_charge_complete_status(charger_status)
18931910
):
18941911
self.logger.warning(f"Charger no longer charging (status: {charger_status}) - ending grid mode")
18951912
await self._complete_night_charge(STOP_REASON_CHARGER_NOT_CHARGING, terminal=True)
@@ -1903,8 +1920,12 @@ async def _async_monitor_grid_charge(self, now) -> None:
19031920
# explicit end-of-life statuses stop it; anything else (brand
19041921
# strings, None, unknown/unavailable, 'charger_wait' transitional
19051922
# decrease) falls through to the measured-power draw check below,
1906-
# which is the authoritative signal on this path.
1907-
if charger_status in (CHARGER_STATUS_FREE, CHARGER_STATUS_END):
1923+
# which is the authoritative signal on this path. v2.9.1: the
1924+
# explicit statuses are matched through the brand-vocabulary
1925+
# classifiers ('available' ≙ free, 'charged' ≙ end, …).
1926+
if is_disconnected_status(charger_status) or is_charge_complete_status(
1927+
charger_status
1928+
):
19081929
self.logger.warning(f"Charger no longer charging (status: {charger_status}) - ending grid mode")
19091930
self._grid_drawing_low_since = None
19101931
await self._complete_night_charge(STOP_REASON_CHARGER_NOT_CHARGING, terminal=True)

‎custom_components/ev_smart_charger/power_model.py‎

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,77 @@
5353
# report a brand-specific charging string (e.g. "Charging") still register.
5454
_IDLE_OR_DONE_STATUSES = (CHARGER_STATUS_FREE, CHARGER_STATUS_END, CHARGER_STATUS_WAIT)
5555

56+
# ---------------------------------------------------------------------------
57+
# v2.9.1 — brand-vocabulary status classifiers (SSOT)
58+
#
59+
# Non-Tuya wallboxes report brand-specific status strings. Two lifecycle
60+
# questions keep coming up across components and were previously answered with
61+
# exact Tuya-string comparisons (the root cause of the 2026-07-19 incident,
62+
# where OCPP-style 'available' — which means NO EV connected — passed a
63+
# "connected" gate that only rejected 'charger_free'):
64+
#
65+
# 1. "Is the cable disconnected?" -> is_disconnected_status()
66+
# 2. "Has the charge completed?" -> is_charge_complete_status()
67+
#
68+
# Both are deliberately CONSERVATIVE allowlists of unambiguous synonyms
69+
# (case-insensitive): an unknown brand string classifies as "connected / not
70+
# complete", which is the safe failure mode — a wrong 'disconnected' verdict
71+
# would block night charging entirely, while a wrong 'connected' verdict at
72+
# worst runs a session with no draw (caught by the measured-power checks).
73+
# OCPP reference: 'Available' = connector not occupied; 'Finishing' = session
74+
# ended.
75+
# ---------------------------------------------------------------------------
76+
_DISCONNECTED_STATUSES = frozenset(
77+
{
78+
CHARGER_STATUS_FREE, # Tuya
79+
"available", # OCPP: connector not occupied
80+
"disconnected",
81+
"unplugged",
82+
"not_connected",
83+
"not connected",
84+
"no_car",
85+
"no car",
86+
"no_vehicle",
87+
"car_not_connected",
88+
}
89+
)
90+
91+
_CHARGE_COMPLETE_STATUSES = frozenset(
92+
{
93+
CHARGER_STATUS_END, # Tuya
94+
"charged",
95+
"complete",
96+
"completed",
97+
"finished",
98+
"finishing", # OCPP: session wrap-up
99+
"full",
100+
"charge_complete",
101+
"charging_complete",
102+
}
103+
)
104+
105+
106+
def is_disconnected_status(status: str | None) -> bool:
107+
"""True when the textual charger status unambiguously means 'no EV plugged'.
108+
109+
Tolerant of brand vocabularies (case/whitespace-insensitive). Unknown
110+
strings return False (treated as connected — the safe default).
111+
"""
112+
if not status:
113+
return False
114+
return str(status).strip().lower() in _DISCONNECTED_STATUSES
115+
116+
117+
def is_charge_complete_status(status: str | None) -> bool:
118+
"""True when the textual charger status unambiguously means 'charge done'.
119+
120+
Tolerant of brand vocabularies (case/whitespace-insensitive). Unknown
121+
strings return False.
122+
"""
123+
if not status:
124+
return False
125+
return str(status).strip().lower() in _CHARGE_COMPLETE_STATUSES
126+
56127
# Per-quantity config keys, ordered L1, L2, L3. L1 reuses the existing single-phase
57128
# key so single-phase installs read exactly the same entity as before.
58129
_PRODUCTION_KEYS = (CONF_FV_PRODUCTION, CONF_FV_PRODUCTION_L2, CONF_FV_PRODUCTION_L3)
@@ -252,18 +323,22 @@ def is_charging(self, hass: HomeAssistant) -> bool:
252323
status = get_state(hass, self._charger_status_entity)
253324
if status in _UNAVAILABLE_STATES or status in _IDLE_OR_DONE_STATUSES:
254325
return False
326+
# v2.9.1: brand synonyms (e.g. OCPP 'available', 'charged') are just as
327+
# explicitly not-drawing as the Tuya statuses above.
328+
if is_disconnected_status(status) or is_charge_complete_status(status):
329+
return False
255330
return True
256331

257332
def is_plugged_in(self, hass: HomeAssistant) -> bool:
258333
"""Lifecycle, NOT draw: is a cable plugged in?
259334
260335
Read from the textual status (measured 0 W cannot tell "paused" from
261-
"unplugged"). Status present and not FREE/unknown/unavailable. Offered for
262-
completeness; existing plug gates already read the status string directly
263-
and are intentionally NOT refactored to this in v2.2.
336+
"unplugged"). Status present and not disconnected/unknown/unavailable.
337+
v2.9.1: uses the tolerant is_disconnected_status classifier so brand
338+
vocabularies (OCPP 'available', …) answer correctly.
264339
"""
265340
state = get_state(hass, self._charger_status_entity)
266-
return state not in (*_UNAVAILABLE_STATES, CHARGER_STATUS_FREE)
341+
return state not in _UNAVAILABLE_STATES and not is_disconnected_status(state)
267342

268343
# ----- conversion -----
269344
def watts_to_amps(self, watts: float) -> float:

‎tests/test_night_smart_charge.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,3 +1331,108 @@ async def test_intent_ignores_availability_churn(hass, night_charge):
13311331
)
13321332

13331333
assert night_charge._session_state == "completed_today"
1334+
1335+
1336+
# ============================================================================
1337+
# v2.9.1 — brand-vocabulary status classifiers (OCPP 'available', 'charged', …)
1338+
# ============================================================================
1339+
1340+
from custom_components.ev_smart_charger.power_model import (
1341+
is_charge_complete_status,
1342+
is_disconnected_status,
1343+
)
1344+
1345+
1346+
def test_disconnected_status_classifier():
1347+
"""Tolerant, case-insensitive; unknown strings default to connected."""
1348+
assert is_disconnected_status("charger_free") is True
1349+
assert is_disconnected_status("available") is True # OCPP: not occupied
1350+
assert is_disconnected_status("Available") is True
1351+
assert is_disconnected_status(" unplugged ") is True
1352+
assert is_disconnected_status("charging") is False
1353+
assert is_disconnected_status("charged") is False
1354+
assert is_disconnected_status("SuspendedEV") is False # OCPP: plugged
1355+
assert is_disconnected_status(None) is False
1356+
assert is_disconnected_status("") is False
1357+
1358+
1359+
def test_charge_complete_status_classifier():
1360+
assert is_charge_complete_status("charger_end") is True
1361+
assert is_charge_complete_status("charged") is True
1362+
assert is_charge_complete_status("Finishing") is True # OCPP wrap-up
1363+
assert is_charge_complete_status("charging") is False
1364+
assert is_charge_complete_status("available") is False
1365+
assert is_charge_complete_status(None) is False
1366+
1367+
1368+
async def test_grid_monitor_lifecycle_stop_on_brand_disconnected(hass, night_charge):
1369+
"""'available' (OCPP = unplugged) mid-session → immediate lifecycle stop,
1370+
even with a noisy >floor power reading."""
1371+
await _prime_grid_monitor(
1372+
hass,
1373+
night_charge,
1374+
status="available",
1375+
measured=CHARGING_POWER_DRAWING_FLOOR_W + 500,
1376+
)
1377+
1378+
await night_charge._async_monitor_grid_charge(None)
1379+
1380+
night_charge._complete_night_charge.assert_awaited_once()
1381+
1382+
1383+
async def test_grid_monitor_lifecycle_stop_on_brand_charged(hass, night_charge):
1384+
"""'charged' (brand synonym of charger_end) → immediate lifecycle stop."""
1385+
await _prime_grid_monitor(
1386+
hass,
1387+
night_charge,
1388+
status="charged",
1389+
measured=CHARGING_POWER_DRAWING_FLOOR_W + 500,
1390+
)
1391+
1392+
await night_charge._async_monitor_grid_charge(None)
1393+
1394+
night_charge._complete_night_charge.assert_awaited_once()
1395+
1396+
1397+
async def test_grid_monitor_legacy_brand_disconnected_stops(hass, night_charge):
1398+
"""Legacy path (no power sensor): brand 'available' → lifecycle stop."""
1399+
await _prime_grid_monitor(hass, night_charge, status="available", measured=None)
1400+
1401+
await night_charge._async_monitor_grid_charge(None)
1402+
1403+
night_charge._complete_night_charge.assert_awaited_once()
1404+
1405+
1406+
async def test_late_arrival_fires_on_brand_disconnected_transition(hass, night_charge):
1407+
"""Plug-in detection must fire on 'available' → 'charging', not only on the
1408+
Tuya 'charger_free' transition (the 2026-07-19 wallbox vocabulary)."""
1409+
night_charge._boost_charge = None
1410+
night_charge._is_in_active_window = AsyncMock(return_value=True)
1411+
night_charge.is_enabled = MagicMock(return_value=True)
1412+
night_charge._evaluate_and_charge = AsyncMock()
1413+
1414+
event = MagicMock()
1415+
event.data = {
1416+
"old_state": MagicMock(state="available"),
1417+
"new_state": MagicMock(state="charging"),
1418+
}
1419+
await night_charge._async_charger_status_changed(event)
1420+
1421+
night_charge._evaluate_and_charge.assert_awaited_once()
1422+
1423+
1424+
async def test_late_arrival_ignores_connected_to_connected_transition(
1425+
hass, night_charge
1426+
):
1427+
"""'charging' → 'charged' is not a plug-in event."""
1428+
night_charge._boost_charge = None
1429+
night_charge._evaluate_and_charge = AsyncMock()
1430+
1431+
event = MagicMock()
1432+
event.data = {
1433+
"old_state": MagicMock(state="charging"),
1434+
"new_state": MagicMock(state="charged"),
1435+
}
1436+
await night_charge._async_charger_status_changed(event)
1437+
1438+
night_charge._evaluate_and_charge.assert_not_awaited()

0 commit comments

Comments
 (0)