Skip to content

Commit c9173b8

Browse files
Antonio ...claude
andcommitted
fix: v2.9.2 — armed night window disarms past its stop condition (no phantom daylight session / 1h freeze) + Solar Surplus brand-disconnect gate
Incident 2026-07-21: window armed at 00:01 with no EV connected stayed 'active' via hysteresis all day; the 08:13 plug-in launched a daylight battery session killed 3s later, latching completed_today + 1h cooldown and freezing Solar Surplus under full sun. The armed-but-idle window now disarms to 'ready' (no latch, no cooldown) once sunrise/deadline/PV handoff has passed. Solar Surplus plug gate now uses is_disconnected_status() so OCPP 'available' skips the tick (was a 125-tick 20A start loop against an empty plug). +6 regression tests, suite 284 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 689b85d commit c9173b8

8 files changed

Lines changed: 234 additions & 7 deletions

File tree

‎CLAUDE.md‎

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

758758
## Version History
759759

760+
### v2.9.2 (2026-07-21)
761+
**FIX: Armed night window without a session survives past sunrise → phantom daylight session + 1h freeze; Solar Surplus 'available' start loop**
762+
763+
Root-caused from a maintainer's live incident log (2026-07-21, non-Tuya wallbox,
764+
OCPP vocabulary). Chain of events: the night window armed `_session_state =
765+
"active"` at 00:01 with **no EV connected**; the v2.9.1 connect gate correctly
766+
rejected `available` every tick, but the hysteresis in `_is_in_active_window`
767+
kept the state armed all night and past sunrise (03:37). When the car was
768+
plugged in at **08:13**, the connect gate passed, the armed state bypassed any
769+
window re-check, and a **battery session started in broad daylight** (with a
770+
start notification). Three seconds later the periodic check detected "past stop
771+
condition", stopped it terminally → `completed_today` + **1-hour cooldown** —
772+
which also blocked Solar Surplus ("respecting cooldown period") with full sun
773+
and ~12 A of surplus available. Net effect: notification of charge start, then
774+
nothing for an hour.
775+
776+
**Fix A — armed-window disarm ([night_smart_charge.py](custom_components/ev_smart_charger/night_smart_charge.py), STEP 2 of `_is_in_active_window`).**
777+
When the state is `"active"` but **no session is running** (`is_active()` is
778+
False), the check now consults `_should_stop_for_deadline()`: if the window's
779+
own stop condition (sunrise on car_ready=OFF, deadline/target on car_ready=ON,
780+
PV handoff when enabled) has passed, the state disarms back to `"ready"` —
781+
deliberately **without** the `completed_today` latch and **without** the
782+
cooldown, because no session ever ran and there is nothing to anti-loop
783+
against. Control then falls through to the normal STEP 3 window computation
784+
(False past the stop condition), so a morning plug-in is handled by Solar
785+
Surplus. A genuinely running session keeps the legacy hysteresis byte-for-byte
786+
(its stop conditions are enforced by the monitors / periodic check, which run
787+
first). On car_ready=ON days the armed state intentionally survives past
788+
sunrise until the deadline — that is the "car ready by deadline" semantics.
789+
790+
**Fix B — Solar Surplus disconnect gate ([solar_surplus.py](custom_components/ev_smart_charger/solar_surplus.py), Section 7).**
791+
The same incident log showed a **125-tick loop** (05:38–07:46) of "Starting
792+
charger with 20A" battery support against an **empty plug**: the plug gate
793+
compared the status against the literal `charger_free` only, so `available`
794+
passed as "connected". This was the call site deliberately left untouched in
795+
v2.9.1 ("no observed defect") — the defect is now observed. The gate uses the
796+
centralized `is_disconnected_status()` classifier; unknown strings still
797+
default to "connected" (safe failure mode), Tuya installs unchanged.
798+
799+
**Files**: `night_smart_charge.py` (STEP 2 guard), `solar_surplus.py` (gate +
800+
import), `const.py` + `manifest.json` (VERSION), `README.md`, this file;
801+
tests: `tests/test_night_smart_charge.py` (+4: disarm past stop condition
802+
with no latch/cooldown, hysteresis kept before stop, running session never
803+
disarms via window check, end-to-end late-arrival-past-sunrise starts no
804+
phantom session), `tests/test_solar_surplus.py` (+2: `available` skips the
805+
tick, `charger_free` regression). `VERSION = "2.9.2"`. Full suite green:
806+
**284 passed / 0 failed**.
807+
808+
**Upgrade priority**: 🔴 STRONGLY RECOMMENDED for non-Tuya wallboxes using
809+
Night Smart Charge (completes the v2.9.0/v2.9.1 series — without it a morning
810+
plug-in can freeze all charging for an hour). ⚪ Low impact for Tuya installs
811+
(the phantom-session path required a brand `available` status, but the
812+
armed-window disarm also protects Tuya setups where the car is plugged in
813+
after sunrise).
814+
815+
---
816+
760817
### v2.9.1 (2026-07-19)
761818
**FIX: Brand-vocabulary status classifiers — 'available' no longer passes as "connected", 'charged' recognized as charge-complete**
762819

‎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.2** — **armed-night-window disarm + Solar Surplus disconnect gate**. Two fixes from a live incident (2026-07-21): (1) a Night Smart Charge window armed at its scheduled time with **no EV connected** stayed "active" all day via hysteresis — a morning plug-in then launched a phantom daylight battery session that was killed 3 seconds later, latching `completed_today` + the 1-hour cooldown and **freezing Solar Surplus with full sun available**. The armed-but-idle window now disarms cleanly (back to `ready`, no completion latch, no cooldown) once its own stop condition — sunrise, deadline, or PV handoff — has passed, so a morning plug-in is handled by Solar Surplus as it should be. A genuinely running session keeps the legacy hysteresis untouched. (2) Solar Surplus's plug gate now uses the v2.9.1 `is_disconnected_status()` classifier, so OCPP `available` (= no EV connected) skips the tick like `charger_free` instead of driving an endless battery-support start loop against an empty plug.
62+
6163
> **🔌 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.
6264

6365
> **🛠️ 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.**

‎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.1"
5+
VERSION = "2.9.2"
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.1"
14+
"version": "2.9.2"
1515
}

‎custom_components/ev_smart_charger/night_smart_charge.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -917,8 +917,36 @@ async def _is_in_active_window(self, now: datetime) -> bool:
917917

918918
# === STEP 2: Hysteresis - stay active once activated ===
919919
if self._session_state == "active":
920-
self.logger.debug("Already active - maintaining state (hysteresis)")
921-
return True
920+
# v2.9.2: an ARMED window with NO running session must not survive
921+
# past its own stop conditions. Before this guard, a window armed
922+
# at 00:01 with no EV connected stayed "active" all day via
923+
# hysteresis; a morning plug-in (2026-07-21 incident, 08:13) then
924+
# launched a battery session in broad daylight that the periodic
925+
# check killed 3 seconds later — latching completed_today + the
926+
# 1-hour cooldown, which also froze Solar Surplus with full sun
927+
# available. Disarming back to "ready" (NOT completed_today: no
928+
# session ever ran, there is nothing to anti-loop against) lets
929+
# STEP 3 re-evaluate the real window, which is False past the stop
930+
# condition, so the plug-in is handled by Solar Surplus instead.
931+
# A genuinely RUNNING session keeps the legacy hysteresis — its
932+
# stop conditions are enforced by the monitors / periodic check.
933+
if not self.is_active():
934+
should_stop, stop_reason = await self._should_stop_for_deadline(now)
935+
if should_stop:
936+
self.logger.info(
937+
"🔄 Armed window expired with no session started "
938+
f"({stop_reason}) - disarming without completion latch"
939+
)
940+
self._session_state = "ready"
941+
# Fall through to STEP 3: recompute the real window.
942+
else:
943+
self.logger.debug(
944+
"Already active - maintaining state (hysteresis)"
945+
)
946+
return True
947+
else:
948+
self.logger.debug("Already active - maintaining state (hysteresis)")
949+
return True
922950

923951
# === STEP 3: Time range validation with grace period ===
924952
if not self._night_charge_time_entity:

‎custom_components/ev_smart_charger/solar_surplus.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from homeassistant.util import dt as dt_util
1414

1515
from .const import (
16-
CHARGER_STATUS_FREE,
1716
CONF_EV_CHARGER_STATUS,
1817
CONF_FV_PRODUCTION,
1918
CONF_HOME_CONSUMPTION,
@@ -51,7 +50,7 @@
5150
has_home_battery,
5251
)
5352
from .localization import translate_runtime
54-
from .power_model import ChargingModel
53+
from .power_model import ChargingModel, is_disconnected_status
5554
from .runtime import EVSCRuntimeData
5655
from .utils.logging_helper import EVSCLogger
5756
from .utils.notification_service import NotificationService
@@ -920,7 +919,14 @@ async def _async_periodic_check(self, now=None, ignore_rate_limit: bool = False)
920919
self.logger.warning("Charger status unavailable")
921920
return
922921

923-
if charger_status == CHARGER_STATUS_FREE:
922+
# v2.9.2: brand-vocabulary aware (companion to v2.9.1). The exact
923+
# CHARGER_STATUS_FREE comparison let OCPP 'available' (= no EV
924+
# connected) pass as "connected": on 2026-07-21 Solar Surplus ran a
925+
# 125-tick battery-support start loop (05:38-07:46) against an
926+
# empty plug. is_disconnected_status() covers 'charger_free' plus
927+
# the unambiguous disconnected synonyms; unknown strings still
928+
# default to "connected" (safe failure mode, unchanged).
929+
if is_disconnected_status(charger_status):
924930
if self._hybrid_mode is not None:
925931
await self._hybrid_mode.async_force_exit("Charger disconnected")
926932
if self._has_control():

‎tests/test_night_smart_charge.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,3 +1436,104 @@ async def test_late_arrival_ignores_connected_to_connected_transition(
14361436
await night_charge._async_charger_status_changed(event)
14371437

14381438
night_charge._evaluate_and_charge.assert_not_awaited()
1439+
1440+
1441+
# ============================================================================
1442+
# v2.9.2: Armed-window-without-session disarm (2026-07-21 incident)
1443+
# ============================================================================
1444+
1445+
async def test_armed_window_without_session_disarms_past_stop_condition(
1446+
hass, night_charge
1447+
):
1448+
"""v2.9.2: a window armed with NO running session must disarm once its
1449+
stop condition (sunrise/deadline) has passed — back to 'ready', with NO
1450+
completed_today latch and NO cooldown. Regression for the 2026-07-21
1451+
incident: state stayed 'active' via hysteresis all night with no EV
1452+
connected, so the 08:13 plug-in launched a daylight battery session."""
1453+
now = dt_util.now()
1454+
night_charge._session_state = "active"
1455+
assert night_charge.is_active() is False # no session running
1456+
night_charge._should_stop_for_deadline = AsyncMock(
1457+
return_value=(True, "Sunrise reached (car not needed urgently)")
1458+
)
1459+
# Past-window geometry: scheduled 7h ago, sunrise 4h ago.
1460+
night_charge._get_scheduled_time_for_today = MagicMock(
1461+
return_value=now - timedelta(hours=7)
1462+
)
1463+
night_charge._astral_service.get_next_sunrise_after = MagicMock(
1464+
return_value=now - timedelta(hours=4)
1465+
)
1466+
1467+
result = await night_charge._is_in_active_window(now)
1468+
1469+
assert result is False
1470+
assert night_charge._session_state == "ready"
1471+
assert night_charge._last_completion_date is None # no completion latch
1472+
assert night_charge._last_completion_time is None # no cooldown armed
1473+
1474+
1475+
async def test_armed_window_without_session_keeps_hysteresis_before_stop(
1476+
hass, night_charge
1477+
):
1478+
"""Before the stop condition, the armed window keeps legacy hysteresis."""
1479+
now = dt_util.now()
1480+
night_charge._session_state = "active"
1481+
night_charge._should_stop_for_deadline = AsyncMock(return_value=(False, ""))
1482+
1483+
result = await night_charge._is_in_active_window(now)
1484+
1485+
assert result is True
1486+
assert night_charge._session_state == "active"
1487+
1488+
1489+
async def test_armed_window_with_running_session_keeps_hysteresis(
1490+
hass, night_charge
1491+
):
1492+
"""A genuinely RUNNING session never disarms via the window check — its
1493+
stop conditions are enforced by the monitors / periodic check."""
1494+
now = dt_util.now()
1495+
night_charge._session_state = "active"
1496+
night_charge._night_charge_active = True
1497+
night_charge._active_mode = NIGHT_CHARGE_MODE_BATTERY
1498+
night_charge._should_stop_for_deadline = AsyncMock(
1499+
return_value=(True, "Sunrise reached")
1500+
)
1501+
1502+
result = await night_charge._is_in_active_window(now)
1503+
1504+
assert result is True
1505+
assert night_charge._session_state == "active"
1506+
night_charge._should_stop_for_deadline.assert_not_awaited()
1507+
1508+
1509+
async def test_late_arrival_past_sunrise_does_not_start_phantom_session(
1510+
hass, night_charge
1511+
):
1512+
"""v2.9.2 end-to-end regression for 2026-07-21: plug-in at 08:13 with the
1513+
window still armed from 00:01 must NOT start a session (no notification,
1514+
no completed_today, no cooldown) — Solar Surplus handles the morning."""
1515+
now = dt_util.now()
1516+
night_charge._boost_charge = None
1517+
night_charge._session_state = "active" # armed since 00:01, no session
1518+
night_charge._should_stop_for_deadline = AsyncMock(
1519+
return_value=(True, "Sunrise reached (car not needed urgently)")
1520+
)
1521+
night_charge._get_scheduled_time_for_today = MagicMock(
1522+
return_value=now - timedelta(hours=7)
1523+
)
1524+
night_charge._astral_service.get_next_sunrise_after = MagicMock(
1525+
return_value=now - timedelta(hours=4)
1526+
)
1527+
night_charge._evaluate_and_charge = AsyncMock()
1528+
1529+
event = MagicMock()
1530+
event.data = {
1531+
"old_state": MagicMock(state="available"),
1532+
"new_state": MagicMock(state="charging"),
1533+
}
1534+
await night_charge._async_charger_status_changed(event)
1535+
1536+
night_charge._evaluate_and_charge.assert_not_awaited()
1537+
night_charge.charger_controller.start_charger.assert_not_called()
1538+
assert night_charge._session_state == "ready"
1539+
assert night_charge._last_completion_date is None

‎tests/test_solar_surplus.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,3 +703,36 @@ async def test_sensor_error_counter_resets_on_recovery(hass, automation):
703703
await automation._async_periodic_check(ignore_rate_limit=True)
704704

705705
assert automation._sensor_error_consecutive == 0
706+
707+
708+
async def test_periodic_check_skips_on_brand_disconnected_status(hass, automation):
709+
"""v2.9.2: OCPP 'available' (= no EV connected) must skip the tick like
710+
'charger_free'. Regression for 2026-07-21: the exact-match gate let
711+
'available' pass as connected, producing a 125-tick battery-support
712+
start loop (05:38-07:46) against an empty plug."""
713+
hass.states.async_set("switch.force", "off")
714+
hass.states.async_set("select.profile", "solar_surplus")
715+
hass.states.async_set("sensor.charger_status", "available")
716+
717+
automation.priority_balancer.is_enabled.return_value = True
718+
automation._has_control = MagicMock(return_value=False)
719+
720+
await automation._async_periodic_check()
721+
722+
automation.charger_controller.start_charger.assert_not_called()
723+
automation.charger_controller.set_amperage.assert_not_called()
724+
725+
726+
async def test_periodic_check_still_skips_on_charger_free(hass, automation):
727+
"""Tuya vocabulary unchanged: 'charger_free' still skips the tick."""
728+
hass.states.async_set("switch.force", "off")
729+
hass.states.async_set("select.profile", "solar_surplus")
730+
hass.states.async_set("sensor.charger_status", CHARGER_STATUS_FREE)
731+
732+
automation.priority_balancer.is_enabled.return_value = True
733+
automation._has_control = MagicMock(return_value=False)
734+
735+
await automation._async_periodic_check()
736+
737+
automation.charger_controller.start_charger.assert_not_called()
738+
automation.charger_controller.set_amperage.assert_not_called()

0 commit comments

Comments
 (0)