Skip to content

Commit c04a53e

Browse files
Antonio ...claude
andcommitted
fix: v2.13.3 — Hybrid Inverter Mode adopts a running session (10 A freeze)
Hybrid Mode only engaged with the charger OFF. A session already running when the home battery filled and the inverter started curtailing (started by Solar Surplus, battery support, or surviving an HA restart) stayed with Solar Surplus, whose measured surplus is pinned to the EV's own draw under curtailment — so the amperage froze at its current level forever. With the charger ON, Hybrid Mode now adopts the session when every other entry guard holds and Solar Surplus cannot reach the next level, going straight to RIDING_EDGE from the current amperage. Also: the -500 W surplus plausibility exit now applies to PROBING only. With a consumption sensor that includes the EV it fired at the real PV limit and restarted the ramp from 6 A instead of stepping down one level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 91e58cc commit c04a53e

7 files changed

Lines changed: 344 additions & 16 deletions

File tree

‎CLAUDE.md‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
77
This is a **Home Assistant custom integration** for intelligent EV charging control. It manages EV charger automation based on solar production, time of day, battery levels, grid import protection, and intelligent priority balancing between EV and home battery charging.
88

99
**Domain:** `ev_smart_charger`
10-
**Current Version:** 2.13.2
10+
**Current Version:** 2.13.3
1111
**Installation:** HACS custom repository or manual installation to `custom_components/ev_smart_charger`
1212

1313
## Development Commands
@@ -757,6 +757,60 @@ async def _set_amperage(self, target_amperage: int):
757757

758758
## Version History
759759

760+
### v2.13.3 (2026-09-28)
761+
**FIX: charging frozen at 10 A under curtailment — Hybrid Inverter Mode now adopts a running session**
762+
763+
v2.13.2 did not solve the maintainer's report ("stuck at 10 A, the inverter can
764+
deliver more"). An end-to-end simulation — the real Solar Surplus tick driving
765+
the real Hybrid state machine against a simulated zero-export inverter
766+
(`production = min(pv_available, house + ev)`) — showed the RIDING_EDGE ramp
767+
itself climbs fine, and located the real, deterministic freeze elsewhere:
768+
769+
**Bug A — Hybrid Mode never engaged on a running session.** `is_relevant()`
770+
returned False whenever `charger_is_on`, so Hybrid Mode could only start from
771+
its own 6 A probe. A session already running when the home battery fills and
772+
the inverter starts curtailing — started by Solar Surplus, battery support, or
773+
surviving an HA restart (Hybrid always boots IDLE) — stayed with normal Solar
774+
Surplus. Under curtailment production follows the load, so the measured
775+
surplus equals the EV's own draw (or ≈0 with a consumption sensor that
776+
includes the EV): `calculate_from_surplus` never returns more than the current
777+
level and the amperage froze forever (simulated: 10 A for 40 ticks with 7 kW
778+
available).
779+
780+
Fix: with the charger ON, `is_relevant()` (new `current_amps` argument, passed
781+
by Solar Surplus) keeps every other entry guard (toggle, daytime, sunset
782+
buffer, battery full, cooldown/HARD_EXIT, priority, smoothed low grid import)
783+
and replaces the "surplus < 5.5 A" curtailment signature with "Solar Surplus
784+
cannot reach the next level": `surplus_amps < get_next_level_up(current)` and
785+
`current < solar_max_amperage`. `tick()` then calls the new `_adopt_session()`:
786+
acquires control through Solar Surplus, sets `_current_target_amps` and goes
787+
straight to RIDING_EDGE (no probe — the running charger already proves the
788+
load is served with grid import low). Real, uncurtailed surplus (≥ next level)
789+
is left to Solar Surplus.
790+
791+
**Bug B — at the real PV limit the edge restarted from 6 A.** With a home
792+
consumption sensor that includes the EV, `surplus_watts ≈ -grid_import`, so the
793+
`-500 W` plausibility exit in `_check_active_exit_conditions` fired before the
794+
RIDING_EDGE step-down could, stopping the charger and re-probing from 6 A in a
795+
loop. The floor now applies to PROBING only; in RIDING_EDGE a deficit is grid
796+
import and is handled by the step-down path.
797+
798+
**Files**: `hybrid_inverter_mode.py` (`is_relevant` adoption branch,
799+
`_adopt_session`, PROBING-only surplus floor, docstring), `solar_surplus.py`
800+
(passes `current_amps` to `is_relevant`), `const.py` + `manifest.json`
801+
(VERSION), `README.md`, this file; tests: NEW
802+
`tests/test_v2133_hybrid_adoption.py` (5, end-to-end with a simulated
803+
zero-export inverter: a running 10 A session is adopted and settles on the real
804+
24 A edge for both consumption-sensor conventions, a probe from OFF rides the
805+
edge without restarting for both conventions, real uncurtailed surplus is left
806+
to Solar Surplus). 3 of the 5 fail on v2.13.2. `VERSION = "2.13.3"`. No schema
807+
/ entity / config-flow change, entity counts unchanged (74 / 60). Full suite
808+
green: **373 passed / 0 failed**.
809+
810+
**Upgrade priority**: 🔴 STRONGLY RECOMMENDED for every Hybrid Inverter Mode user.
811+
812+
---
813+
760814
### v2.13.2 (2026-09-28)
761815
**FIX: Hybrid Inverter Mode RIDING_EDGE ramp froze mid-way (~10 A) despite PV headroom**
762816

‎README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ Most projects target one charger brand or assume a single-phase home. EV Smart C
5656
5757
> **🆕 New in v2.6.0** — mass bug-fix & improvement release (issues #36–#42). Highlights: an optional **`grid_available` binary sensor** ([issue #36](https://github.com/antbald/ha-ev-smart-charger/issues/36)) stops Night Smart Charge grid mode during a **grid outage** so it doesn't drain the home battery on hybrid Battery First / UPS inverters (fail-safe: an unavailable sensor never triggers a false stop); a **customizable nighttime window** ([issue #42](https://github.com/antbald/ha-ev-smart-charger/issues/42)) via two opt-in offset numbers (start before sunset / end after sunrise); and a big **INFO log-noise reduction** ([issue #40](https://github.com/antbald/ha-ev-smart-charger/issues/40)) — idle ticks no longer flood the HA log. Also: valid day icons (#37), faster Generic-charger ramp-up (#38), a consistent Hybrid full-battery threshold (#39), and a cleaner telemetry log (#41). **All backward compatible** — the two features are opt-in.
5858
59-
> **⚡ New in v2.13.2** — **Hybrid Inverter Mode no longer stalls mid-ramp.** In RIDING_EDGE the amperage only stepped up while grid import stayed below *half* of `evsc_grid_import_threshold`, and was held forever between half and the full threshold. Zero-export inverters regulate to a small residual import that grows with the load, so on many installs the ramp froze around 10 A even with kilowatts of PV headroom left. The ramp now climbs whenever import is within the threshold; anti-oscillation moved to the amperage domain — a level that forced a step-down is not re-tried for 10 minutes, after which the edge is probed again. **RECOMMENDED for every Hybrid Inverter Mode user.**
59+
> **⚡ New in v2.13.3** — **Hybrid Inverter Mode takes over a session that is already charging.** Hybrid Mode used to engage only with the charger OFF. When a session was already running as the home battery filled up and the inverter started curtailing (started by Solar Surplus, by battery support, or still running after a Home Assistant restart), the amperage froze wherever it was — typically 10 A — because the measured surplus becomes exactly the EV's own draw and Solar Surplus never sees room for the next level. Hybrid Mode now adopts that session and rides the edge from its current amperage. Also, at the real PV limit the edge now steps down one level instead of stopping the charger and restarting from 6 A when the home consumption sensor includes the EV. **STRONGLY RECOMMENDED for every Hybrid Inverter Mode user.**
60+
61+
> **⚡ New in v2.13.2** — **Hybrid Inverter Mode no longer stalls mid-ramp.** In RIDING_EDGE the amperage only stepped up while grid import stayed below *half* of `evsc_grid_import_threshold`, and was held forever between half and the full threshold. Zero-export inverters regulate to a small residual import that grows with the load, so the ramp could freeze mid-way with PV headroom left. The ramp now climbs whenever import is within the threshold; anti-oscillation moved to the amperage domain — a level that forced a step-down is not re-tried for 10 minutes, after which the edge is probed again. **RECOMMENDED for every Hybrid Inverter Mode user.**
6062
6163
> **📱 New in v2.12.0 — full iOS Live Activities support.** Now that the Companion App renders Live Activities on the iPhone Lock Screen and Dynamic Island, EV charging Live Activities are **ON by default** and their update policy has been rewritten around Apple's real constraints: a single `evsc_ev_charging` activity is pushed only on discrete state changes (mode switch, EV target change, every 5 points of EV SOC), never on the continuously moving charging power, amperage or wallbox status — so the card moves when something actually happens instead of once a minute. Covers Boost Charge, Night Smart Charge, Solar Surplus, Force Charge, and normal charging. Requires HA Core 2026.7+ and iOS 17.2+ (Android 16+ for Live Updates), plus allowing Live Activities in **iOS Settings → Home Assistant**.
6264
@@ -1040,6 +1042,7 @@ stateDiagram-v2
10401042
[*] --> IDLE
10411043
10421044
IDLE --> PROBING: Entry conditions met\n(battery full, grid≈0, daytime, etc.)
1045+
IDLE --> RIDING_EDGE: charger already ON,\nsurplus pinned to EV draw\n→ session adopted
10431046
PROBING --> RIDING_EDGE: probe completed\nwithout grid violation
10441047
PROBING --> COOLDOWN_SHORT: import sustained\n→ probe FAILED
10451048
@@ -1055,6 +1058,8 @@ stateDiagram-v2
10551058
HARD_EXIT --> IDLE: next sunrise
10561059
```
10571060

1061+
If the charger is **already charging** when those conditions hold (a session started by Solar Surplus or battery support, or still running after a restart) and Solar Surplus cannot justify the next amperage level — under curtailment the surplus equals the EV's own draw — Hybrid Mode **adopts the session** and goes straight to RIDING_EDGE from the current amperage (v2.13.3). No probe is needed: the running charger already proves the load is served with grid import near zero.
1062+
10581063
Two-phase probing protects against inverter slow-ramp:
10591064

10601065
1. **Phase A (0–20 s, "transient grace"):** charger at 6 A, `grid_import` is **ignored**. This is the window where the inverter detects the new load and starts ramping PV up.
@@ -1095,7 +1100,7 @@ You will receive **one push notification per day** (filtered by car-owner presen
10951100
| Every probe fails (`COOLDOWN_SHORT` then `COOLDOWN_LONG`) | Raise `evsc_hybrid_max_import_duration` to 90–120 s. Slow-ramp inverters (Solis, Growatt) need more time to react. |
10961101
| Module enters `HARD_EXIT` too often | Your inverter probably has genuine PV constraints (small system, partial shading). Raise `evsc_hybrid_battery_full_threshold` to 100 % so probing only happens when truly full. |
10971102
| RIDING_EDGE oscillates a lot | Raise `evsc_grid_import_threshold` slightly (from 50 W default to 100 W) for more headroom. |
1098-
| RIDING_EDGE stops climbing at a low amperage (e.g. 10 A) | Fixed in v2.13.2. If it still holds, check the diagnostic: `holding below …A import ceiling` means that level really imported from the grid within the last 10 minutes. |
1103+
| Charging stops climbing at a low amperage (e.g. 10 A) with the battery full | Fixed in v2.13.3 (running sessions are now adopted by Hybrid Mode). Make sure the Hybrid Mode toggle is ON and the charging profile is `solar_surplus`. If it still holds, check the diagnostic: `holding below …A import ceiling` means that level really imported from the grid within the last 10 minutes. |
10991104

11001105
#### Interaction with other automations
11011106

‎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.13.2"
5+
VERSION = "2.13.3"
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/hybrid_inverter_mode.py‎

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
State machine:
1717
1818
IDLE
19-
└─ entry conditions met → [start_charger(6A) + notify_once] → PROBING
19+
├─ entry conditions met, charger OFF → [start_charger(6A) + notify_once] → PROBING
20+
└─ entry conditions met, charger already ON and Solar Surplus cannot
21+
reach the next level (surplus pinned to the EV draw) → adopt the
22+
running session → RIDING_EDGE (v2.13.3)
2023
2124
PROBING (probe_duration s at 6A, two-phase observation)
2225
├─ Phase A (0-20s "transient grace"): ignore grid_import (inverter ramp)
@@ -324,6 +327,7 @@ async def is_relevant(
324327
charger_is_on: bool,
325328
priority: str | None,
326329
now: datetime,
330+
current_amps: int = 0,
327331
) -> bool:
328332
"""Return True if Solar Surplus must delegate this tick to hybrid mode.
329333
@@ -381,9 +385,31 @@ async def is_relevant(
381385
self._grid_import_below_threshold_since = None
382386
return False
383387

384-
# Surplus must look near-zero (curtailment signature). If surplus is real
385-
# and above start threshold, normal Solar Surplus path handles it.
386-
if surplus_amps >= SURPLUS_STOP_THRESHOLD:
388+
if charger_is_on:
389+
# v2.13.3: adopt a session Solar Surplus is already running. Once
390+
# the inverter curtails, the measured surplus is pinned to the EV's
391+
# own draw (production follows the load), so Solar Surplus can never
392+
# justify the next level and the amperage freezes wherever the
393+
# session happened to be (e.g. 10 A). Adopt only when Solar Surplus
394+
# cannot climb on its own (surplus below the next level) and the
395+
# charger is not already at its ceiling.
396+
solar_max_amperage = get_int(
397+
self.hass,
398+
self._solar_max_amperage_entity,
399+
default=DEFAULT_SOLAR_MAX_AMPERAGE,
400+
)
401+
if current_amps <= 0 or current_amps >= solar_max_amperage:
402+
self._grid_import_below_threshold_since = None
403+
return False
404+
next_up = AmperageCalculator.get_next_level_up(
405+
current_amps, solar_max_amperage, self._amp_levels
406+
)
407+
if next_up <= current_amps or surplus_amps >= next_up:
408+
self._grid_import_below_threshold_since = None
409+
return False
410+
elif surplus_amps >= SURPLUS_STOP_THRESHOLD:
411+
# Surplus must look near-zero (curtailment signature). If surplus is
412+
# real and above start threshold, normal Solar Surplus handles it.
387413
self._grid_import_below_threshold_since = None
388414
return False
389415

@@ -393,11 +419,12 @@ async def is_relevant(
393419
self._grid_import_below_threshold_since = None
394420
return False
395421

396-
# Charger must currently be off AND plugged in (not free, not finished).
397-
if charger_is_on:
398-
return False
422+
# Charger must be plugged in (not free, not finished). A charger that is
423+
# OFF also needs a readable status; a running one is its own evidence.
399424
status = get_state(self.hass, self._charger_status)
400-
if status in (CHARGER_STATUS_FREE, CHARGER_STATUS_END, None, "unavailable", "unknown"):
425+
if status in (CHARGER_STATUS_FREE, CHARGER_STATUS_END):
426+
return False
427+
if not charger_is_on and status in (None, "unavailable", "unknown"):
401428
return False
402429

403430
# Priority Balancer must not say HOME. EV_FREE override is allowed only
@@ -478,7 +505,10 @@ async def tick(
478505
# State-specific handlers
479506
if self._state == HYBRID_STATE_IDLE:
480507
# Caller guaranteed entry conditions are met (is_relevant returned True)
481-
await self._start_probe(now)
508+
if charger_is_on and current_amps > 0:
509+
await self._adopt_session(now, current_amps)
510+
else:
511+
await self._start_probe(now)
482512
return True
483513

484514
if self._state == HYBRID_STATE_PROBING:
@@ -609,8 +639,15 @@ async def _check_active_exit_conditions(
609639
if status in (CHARGER_STATUS_FREE, CHARGER_STATUS_END):
610640
return f"charger status now {status}"
611641

612-
# 6A floor + plausibility
613-
if surplus_watts < self._max_negative_surplus_w:
642+
# 6A floor + plausibility. PROBING only: in RIDING_EDGE a real deficit
643+
# shows up as grid import and is handled by the step-down path. With a
644+
# consumption sensor that includes the EV, surplus ≈ -grid_import, so
645+
# this check would stop the charger (and restart from 6 A) instead of
646+
# stepping down one level at the edge (v2.13.3).
647+
if (
648+
self._state == HYBRID_STATE_PROBING
649+
and surplus_watts < self._max_negative_surplus_w
650+
):
614651
return f"surplus dropped below {self._max_negative_surplus_w}W (no plausible headroom)"
615652

616653
# PRIORITY checks
@@ -666,6 +703,38 @@ async def _start_probe(self, now: datetime) -> None:
666703
await self._mobile_notifier.send_hybrid_mode_started_notification()
667704
self._notification_sent_on_date = today
668705

706+
async def _adopt_session(self, now: datetime, current_amps: int) -> None:
707+
"""Transition IDLE → RIDING_EDGE on a session that is already charging.
708+
709+
v2.13.3: no probe is needed — the running charger already proves the
710+
load is being served with grid import low (smoothed by is_relevant), so
711+
we go straight to riding the edge from the current amperage.
712+
"""
713+
if self._solar_surplus is not None:
714+
acquired = await self._solar_surplus._acquire_control(
715+
"turn_on", "Hybrid Mode: adopting running session"
716+
)
717+
if not acquired:
718+
self.logger.info("Coordinator denied adoption — staying IDLE")
719+
return
720+
721+
self.logger.separator()
722+
self.logger.start(
723+
f"{self.logger.SOLAR} Hybrid Mode: adopting running session at "
724+
f"{current_amps}A (surplus pinned to EV load — curtailment)"
725+
)
726+
self._current_target_amps = current_amps
727+
self._import_violation_since = None
728+
self._battery_violation_since = None
729+
self._headroom_ok_since = None
730+
self._riding_edge_entered_at = now
731+
await self._transition(HYBRID_STATE_RIDING_EDGE, reason="adopted running session")
732+
733+
today = now.date()
734+
if self._notification_sent_on_date != today:
735+
await self._mobile_notifier.send_hybrid_mode_started_notification()
736+
self._notification_sent_on_date = today
737+
669738
async def _handle_probing(self, now: datetime, grid_import: float) -> None:
670739
"""During PROBING: two-phase observation of grid_import."""
671740
probe_duration = get_int(

‎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.13.2"
14+
"version": "2.13.3"
1515
}

‎custom_components/ev_smart_charger/solar_surplus.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,7 @@ async def _async_periodic_check(self, now=None, ignore_rate_limit: bool = False)
11661166
charger_is_on=charger_is_on,
11671167
priority=priority,
11681168
now=now,
1169+
current_amps=current_amps_for_hybrid,
11691170
):
11701171
handled = await self._hybrid_mode.tick(
11711172
surplus_amps=surplus_amps,

0 commit comments

Comments
 (0)