Skip to content

Commit 3d3bfe0

Browse files
Antonio ...claude
andcommitted
fix: v2.13.1 — Boost Charge no longer aborts on an already-charging vehicle (#60)
Some charger integrations (Tesla Fleet) reject switch.turn_on when the vehicle is already charging ("Command was unsuccessful: is_charging"). ChargerController.start_charger() treated that exception as fatal, so Boost Charge enabled on top of a running session tore itself down within seconds, even though the charger was on and drawing ~11 kW. Every charger turn_on now goes through _turn_on_charger_switch(): a rejected call is tolerated only on strong evidence the charger is running (switch reports ON, or mapped charging power above the drawing floor). The tolerant status-string fallback is deliberately not used, so an unknown brand status can never mask a real failure. Tolerated starts emit reason_code=already_charging and log a WARNING instead of an ERROR. README: Home consumption guidance — exclude EV and home-battery charging/discharging (answers #61). Full suite green: 364 passed / 0 failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cd622f6 commit 3d3bfe0

6 files changed

Lines changed: 268 additions & 22 deletions

File tree

‎CLAUDE.md‎

Lines changed: 47 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.0
10+
**Current Version:** 2.13.1
1111
**Installation:** HACS custom repository or manual installation to `custom_components/ev_smart_charger`
1212

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

758758
## Version History
759759

760+
### v2.13.1 (2026-09-25)
761+
**FIX: Boost Charge aborted when enabled on an already-charging vehicle (issue #60)**
762+
763+
Reported with a Tesla Wall Connector driven through Tesla Fleet: enabling
764+
`evsc_boost_charge_enabled` while the car was already charging (Force Charge ON,
765+
~11 kW drawn) bounced the switch OFF within 1-3 s. Tesla Fleet's `switch.charge`
766+
**rejects `turn_on` when the vehicle is already charging** (`HomeAssistantError:
767+
Command was unsuccessful: is_charging`). `ChargerController.start_charger()`
768+
treated any exception from the service call as fatal, so Boost ran its
769+
`start_failed` teardown (coordinator released → Boost stopped → Boost failed)
770+
even though the diagnostic itself recorded `charger_on: True`,
771+
`drawing_now: True`, `measured_power_w: 11224`. Boosting a running session is a
772+
legitimate use (raise a solar-limited amperage, exceed today's EV target).
773+
774+
Fix: every charger `switch.turn_on` in the controller (`start_charger`,
775+
`_start_charger_unlocked`, the Tuya decrease sequence restart) now goes through
776+
`_turn_on_charger_switch()`. On a rejected call it refreshes state and tolerates
777+
the error **only on strong evidence** the charger is running
778+
(`_charger_already_running()`): the switch reports ON, or a mapped
779+
charging-power sensor reads above `CHARGING_POWER_DRAWING_FLOOR_W`. The tolerant
780+
status-string fallback of `power_model.is_charging` is deliberately *not* used,
781+
so an unknown brand status can never mask a real failure. A tolerated start
782+
emits `reason_code="already_charging"` and logs a WARNING instead of the ERROR
783+
(`_call_service` gained `log_failure=False`); a genuine failure still logs the
784+
ERROR and returns `success=False`. The amperage set before `turn_on` is
785+
unchanged, so the Boost amperage is applied to the running session.
786+
787+
Also documented in the README (answering issue #61): the Home consumption sensor
788+
must exclude both EV charging and home-battery charging/discharging — surplus is
789+
`production − consumption`, and priority between EV and home battery belongs to
790+
the Priority Balancer, not to the shape of the consumption sensor.
791+
792+
**Files**: `charger_controller.py`, `const.py` + `manifest.json` (VERSION),
793+
`README.md`, this file; tests: NEW `tests/test_v2131_redundant_start.py` (5:
794+
switch-ON rejection succeeds with amperage applied, measured draw with a stale
795+
OFF switch succeeds, genuinely-off rejection fails, standby power below the
796+
floor never masks a failure, tolerant turn_on in the unlocked start path).
797+
`VERSION = "2.13.1"`. No schema / entity / config-flow change, entity counts
798+
unchanged (74 / 60). Full suite green: **364 passed / 0 failed**.
799+
800+
**Upgrade priority**: 🟢 RECOMMENDED for Tesla (Fleet) and any charger whose
801+
integration rejects a redundant start — Boost now works on a running session.
802+
⚪ NO-OP otherwise.
803+
804+
---
805+
760806
### v2.13.0 (2026-09-07)
761807
**FEATURE: opt-in off-grid amperage ceiling for Solar Surplus (issue #57) + two
762808
fixes: empty file-logging output (issue #59) and SOC fields that rejected a

‎README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,13 @@ The charger switch entity is used as the unique ID for the config entry. Adding
436436

437437
**EV battery SOC (v2.13.0 — [issue #58](https://github.com/antbald/ha-ev-smart-charger/issues/58)):** the two SOC fields accept a `sensor`, a `number` **or an `input_number`**. EV SOC is architecturally required — the Priority Balancer's daily targets, Night Smart Charge's stop conditions and Boost's target all key off it — but many EVs expose no SOC at all over any Home Assistant integration. Those owners can create an `input_number` helper, map it directly here, and keep it up to date manually or from their own automation. Before v2.13.0 the field was a sensor-only selector, forcing a template-sensor wrapper around the helper.
438438

439+
**Home consumption — what to include:** map a sensor that reports the **household load only**. Solar Surplus computes `surplus = solar production − home consumption` and sizes the EV amperage from it, so:
440+
441+
- **Exclude EV charging.** If the wallbox load is part of the reading, every amp the car draws is subtracted from the surplus that sized it, and the charger ramps itself down.
442+
- **Exclude home-battery charging (and discharging).** If battery charging counts as consumption, all PV flowing into the battery reads as "used", surplus sits near zero while the battery charges, and the EV only ever gets what the battery leaves over — the battery wins by accident, regardless of your settings. Which of the two should come first is decided explicitly by the **Priority Balancer** (daily EV and home SOC targets); battery discharge toward the EV is governed by battery support and `evsc_max_battery_discharge_for_ev` (with the optional battery-power sensor).
443+
444+
Most inverter integrations expose a "load" / "house consumption" sensor that already has this shape. If yours only reports a total, build a template sensor that subtracts the EV and battery-charging terms.
445+
439446
**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.
440447

441448
**Battery power (v2.1.0):** optional signed power sensor for the home battery, used by Hybrid Inverter Mode and Night Smart Charge grid mode to tell real solar headroom apart from the home battery silently covering the EV. **Sign convention: NEGATIVE = discharging, POSITIVE = charging.** If your sensor reports the opposite sign, invert it with a template sensor that negates the value, e.g. `{{ states('sensor.your_battery_power') | float(0) * -1 }}` — the diagnostic sensor's `battery_discharge_w` will read a flat 0 W if the sign is reversed.
@@ -925,6 +932,8 @@ If the car is plugged in *after* the start time but while still within the sched
925932

926933
The session ends at the configured end time even if the SOC target has not been reached. Disabling the schedule toggle mid-session stops the charger immediately.
927934

935+
**Boosting a session that is already charging (v2.13.1 — [issue #60](https://github.com/antbald/ha-ev-smart-charger/issues/60)):** Boost can be enabled on top of a charge that is already running — for example to push past today's EV target, or to lift a solar-limited amperage. Some charger integrations (Tesla Fleet, for one) reject `switch.turn_on` when the vehicle is already charging (`Command was unsuccessful: is_charging`). Such a rejection is no longer treated as a failure: when the charger switch reports ON, or a mapped charging-power sensor shows real draw, the start counts as successful, the Boost amperage stays applied and the session continues. A rejection while the charger is genuinely off is still reported as a failure.
936+
928937
**Key entities:** `evsc_boost_charge_enabled`, `evsc_boost_schedule_enabled`, `evsc_boost_charge_amperage`, `evsc_boost_target_soc`, `evsc_boost_schedule_start_time`, `evsc_boost_schedule_end_time`
929938

930939
---

‎custom_components/ev_smart_charger/charger_controller.py‎

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
CHARGER_MODEL_TUYA,
1919
CHARGER_START_SEQUENCE_DELAY,
2020
CHARGER_STOP_SEQUENCE_DELAY,
21+
CHARGING_POWER_DRAWING_FLOOR_W,
2122
CONF_EV_CHARGER_CURRENT,
2223
CONF_EV_CHARGER_SWITCH,
2324
SERVICE_CALL_TIMEOUT,
@@ -262,19 +263,19 @@ async def start_charger(
262263
await self._set_amperage_internal(normalized_target)
263264
await asyncio.sleep(CHARGER_AMPERAGE_STABILIZATION_DELAY)
264265

265-
await self._call_service(
266-
"switch",
267-
"turn_on",
268-
{"entity_id": self._charger_switch},
269-
)
266+
rejected_while_running = await self._turn_on_charger_switch()
270267
await asyncio.sleep(CHARGER_START_SEQUENCE_DELAY)
271268

272269
self._record_operation_time()
273270
await self._refresh_state()
274271
await self._emit_operation_diagnostic(
275272
"charger_start",
276273
"succeeded",
277-
reason_code="command_executed",
274+
reason_code=(
275+
"already_charging"
276+
if rejected_while_running
277+
else "command_executed"
278+
),
278279
reason_detail=reason or "No reason provided",
279280
target_amps=self._current_amperage,
280281
)
@@ -522,15 +523,56 @@ async def _start_charger_unlocked(self, target_amps: Optional[int]) -> None:
522523
await self._set_amperage_internal(target_amps)
523524
await asyncio.sleep(CHARGER_AMPERAGE_STABILIZATION_DELAY)
524525

525-
await self._call_service(
526-
"switch",
527-
"turn_on",
528-
{"entity_id": self._charger_switch},
529-
)
526+
await self._turn_on_charger_switch()
530527
await asyncio.sleep(CHARGER_START_SEQUENCE_DELAY)
531528
self._record_operation_time()
532529
await self._refresh_state()
533530

531+
async def _turn_on_charger_switch(self) -> bool:
532+
"""Turn the charger switch on, tolerating a redundant-command rejection.
533+
534+
Some charger integrations reject ``switch.turn_on`` when the vehicle is
535+
already charging (e.g. Tesla Fleet raises "Command was unsuccessful:
536+
is_charging"). The desired outcome is already achieved, so a rejection
537+
is only treated as fatal when the charger is not actually running
538+
afterwards (issue #60).
539+
540+
Returns True when a rejection was tolerated, False on a clean call.
541+
"""
542+
try:
543+
await self._call_service(
544+
"switch",
545+
"turn_on",
546+
{"entity_id": self._charger_switch},
547+
log_failure=False,
548+
)
549+
return False
550+
except Exception as ex:
551+
await self._refresh_state()
552+
if self._charger_already_running():
553+
self.logger.warning(
554+
"Charger rejected switch.turn_on (%s) but is already "
555+
"charging - treating the start as successful",
556+
ex,
557+
)
558+
return True
559+
self.logger.error("Service call failed: switch.turn_on - %s", ex)
560+
raise
561+
562+
def _charger_already_running(self) -> bool:
563+
"""Return True when the charger is on, by switch echo or measured draw.
564+
565+
Only strong evidence counts: the switch reporting ON, or a mapped power
566+
sensor above the drawing floor. The tolerant status fallback is not
567+
used here, so an unknown brand status can never mask a real failure.
568+
"""
569+
if self._is_on:
570+
return True
571+
return (
572+
self._measured_power_w is not None
573+
and self._measured_power_w > CHARGING_POWER_DRAWING_FLOOR_W
574+
)
575+
534576
async def _stop_charger_unlocked(self) -> None:
535577
"""Stop the charger without reacquiring the controller lock."""
536578
await self._call_service(
@@ -577,11 +619,7 @@ async def _decrease_amperage_unlocked(self, target_amps: int) -> None:
577619
await asyncio.sleep(CHARGER_STOP_SEQUENCE_DELAY)
578620
await self._set_amperage_internal(target_amps)
579621
await asyncio.sleep(CHARGER_AMPERAGE_STABILIZATION_DELAY)
580-
await self._call_service(
581-
"switch",
582-
"turn_on",
583-
{"entity_id": self._charger_switch},
584-
)
622+
await self._turn_on_charger_switch()
585623
await asyncio.sleep(CHARGER_START_SEQUENCE_DELAY)
586624
self._record_operation_time()
587625
await self._refresh_state()
@@ -614,7 +652,14 @@ def _normalize_target_amps(self, target_amps: int | float | None) -> int:
614652
return self._amp_levels[0]
615653
return min(self._amp_levels, key=lambda value: abs(value - int(target_amps)))
616654

617-
async def _call_service(self, domain: str, service: str, data: dict):
655+
async def _call_service(
656+
self,
657+
domain: str,
658+
service: str,
659+
data: dict,
660+
*,
661+
log_failure: bool = True,
662+
):
618663
"""Call a Home Assistant service with timeout and error handling."""
619664
try:
620665
async with async_timeout.timeout(SERVICE_CALL_TIMEOUT):
@@ -625,10 +670,14 @@ async def _call_service(self, domain: str, service: str, data: dict):
625670
blocking=True,
626671
)
627672
except asyncio.TimeoutError:
628-
self.logger.error("Service call timeout: %s.%s", domain, service)
673+
if log_failure:
674+
self.logger.error("Service call timeout: %s.%s", domain, service)
629675
raise
630676
except Exception as ex:
631-
self.logger.error("Service call failed: %s.%s - %s", domain, service, ex)
677+
if log_failure:
678+
self.logger.error(
679+
"Service call failed: %s.%s - %s", domain, service, ex
680+
)
632681
raise
633682

634683
async def is_charging(self) -> bool:

‎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.0"
5+
VERSION = "2.13.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.13.0"
14+
"version": "2.13.1"
1515
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""v2.13.1 — a redundant switch.turn_on rejection is not a start failure (issue #60).
2+
3+
Some charger integrations (Tesla Fleet) reject ``switch.turn_on`` when the
4+
vehicle is already charging ("Command was unsuccessful: is_charging"). Boost
5+
Charge enabled on top of an already-running session must not abort because of
6+
it: the desired outcome is already achieved.
7+
"""
8+
from __future__ import annotations
9+
10+
from types import SimpleNamespace
11+
from unittest.mock import AsyncMock, MagicMock, patch
12+
13+
import pytest
14+
from homeassistant.exceptions import HomeAssistantError
15+
16+
from custom_components.ev_smart_charger.charger_controller import ChargerController
17+
from custom_components.ev_smart_charger.const import (
18+
CONF_EV_CHARGER_CURRENT,
19+
CONF_EV_CHARGER_SWITCH,
20+
)
21+
22+
CONFIG = {
23+
CONF_EV_CHARGER_SWITCH: "switch.charger",
24+
CONF_EV_CHARGER_CURRENT: "number.charger_current",
25+
}
26+
27+
28+
@pytest.fixture
29+
def rejecting_services(hass):
30+
"""Register a switch.turn_on that always rejects, like Tesla Fleet does."""
31+
calls: list[tuple[str, str, dict]] = []
32+
33+
async def switch_turn_on(call):
34+
calls.append(("switch", "turn_on", dict(call.data)))
35+
raise HomeAssistantError("Command was unsuccessful: is_charging")
36+
37+
async def switch_turn_off(call):
38+
calls.append(("switch", "turn_off", dict(call.data)))
39+
hass.states.async_set(call.data["entity_id"], "off")
40+
41+
async def number_set_value(call):
42+
calls.append(("number", "set_value", dict(call.data)))
43+
hass.states.async_set(call.data["entity_id"], str(call.data["value"]))
44+
45+
hass.services.async_register("switch", "turn_on", switch_turn_on)
46+
hass.services.async_register("switch", "turn_off", switch_turn_off)
47+
hass.services.async_register("number", "set_value", number_set_value)
48+
return calls
49+
50+
51+
def _runtime_with_power(watts: float | None):
52+
power_model = MagicMock()
53+
power_model.read_charging_power.return_value = watts
54+
power_model.is_charging.return_value = bool(watts and watts > 200)
55+
return SimpleNamespace(power_model=power_model, diagnostic_manager=None)
56+
57+
58+
async def test_rejected_start_while_switch_on_is_success(
59+
hass, rejecting_services
60+
):
61+
"""Switch already ON + rejected turn_on → start succeeds, amperage applied."""
62+
hass.states.async_set("switch.charger", "on")
63+
hass.states.async_set("number.charger_current", "6")
64+
controller = ChargerController(hass, "test_entry", CONFIG)
65+
await controller.async_setup()
66+
67+
with patch("asyncio.sleep", new=AsyncMock()):
68+
result = await controller.start_charger(target_amps=16, reason="Boost charge")
69+
70+
assert result.success is True
71+
assert result.amperage == 16
72+
assert (
73+
"number",
74+
"set_value",
75+
{"entity_id": "number.charger_current", "value": 16},
76+
) in rejecting_services
77+
assert ("switch", "turn_on", {"entity_id": "switch.charger"}) in rejecting_services
78+
79+
80+
async def test_rejected_start_with_measured_draw_is_success(
81+
hass, rejecting_services
82+
):
83+
"""Stale OFF switch but the car measurably draws power → still a success."""
84+
hass.states.async_set("switch.charger", "off")
85+
hass.states.async_set("number.charger_current", "16")
86+
controller = ChargerController(
87+
hass, "test_entry", CONFIG, runtime_data=_runtime_with_power(11224.0)
88+
)
89+
await controller.async_setup()
90+
91+
with patch("asyncio.sleep", new=AsyncMock()):
92+
result = await controller.start_charger(target_amps=16, reason="Boost charge")
93+
94+
assert result.success is True
95+
96+
97+
async def test_rejected_start_while_really_off_is_failure(
98+
hass, rejecting_services
99+
):
100+
"""Switch OFF and no power draw → the rejection is a genuine failure."""
101+
hass.states.async_set("switch.charger", "off")
102+
hass.states.async_set("number.charger_current", "6")
103+
controller = ChargerController(
104+
hass, "test_entry", CONFIG, runtime_data=_runtime_with_power(0.0)
105+
)
106+
await controller.async_setup()
107+
108+
with patch("asyncio.sleep", new=AsyncMock()):
109+
result = await controller.start_charger(target_amps=16, reason="Boost charge")
110+
111+
assert result.success is False
112+
assert "is_charging" in (result.error_message or "")
113+
114+
115+
async def test_power_below_floor_does_not_mask_failure(hass, rejecting_services):
116+
"""Standby power below the drawing floor is not evidence of charging."""
117+
hass.states.async_set("switch.charger", "off")
118+
hass.states.async_set("number.charger_current", "6")
119+
controller = ChargerController(
120+
hass, "test_entry", CONFIG, runtime_data=_runtime_with_power(40.0)
121+
)
122+
await controller.async_setup()
123+
124+
with patch("asyncio.sleep", new=AsyncMock()):
125+
result = await controller.start_charger(target_amps=16, reason="Boost charge")
126+
127+
assert result.success is False
128+
129+
130+
async def test_rejected_turn_on_is_tolerated_in_recover_path(
131+
hass, rejecting_services
132+
):
133+
"""recover_to_target from OFF uses the same tolerant turn_on."""
134+
hass.states.async_set("switch.charger", "on")
135+
hass.states.async_set("number.charger_current", "6")
136+
controller = ChargerController(hass, "test_entry", CONFIG)
137+
await controller.async_setup()
138+
139+
with patch("asyncio.sleep", new=AsyncMock()):
140+
await controller._start_charger_unlocked(16)
141+
142+
assert hass.states.get("number.charger_current").state == "16"

0 commit comments

Comments
 (0)