Skip to content

Commit 8224867

Browse files
author
Antonio ...
committed
feat: v2.7.0 — bug-fix wave (issues #43–#46)
#43 — Remove Jinja {{ }} from the battery_power data_description (12 occurrences across strings.json + translations) so HA no longer renders MALFORMED_ARGUMENT; plain-text instruction now points to the README, which gains a sign-inversion template example. #44 — Solar Surplus PRIORITY_HOME branch no longer calls stop_charger() on an already-off charger every tick. 3-branch guard: stop only when charging; if off but still holding the coordinator, release it; otherwise a single DEBUG line. Kills the per-minute log/coordinator churn and the check-counter reset. #45 — Night Smart Charge: run the is_enabled() check BEFORE _is_in_active_window() (which mutates _session_state to "active"). Fixes the phantom "Already active (hysteresis)" + "disabled, skipping" loop on never-enabled installs. #46 — Solar Surplus resets _surplus_stable_since on grid-import detection and after each step-down, so the pre-cloud stability credit can't trigger an immediate full-amperage jump (large home-battery draw) once the cloud passes. Diagnostic hardening (#47/#48): the Solar Surplus diagnostic exposes the raw is_nighttime_computed result, and the "Invalid sensor values" state now names the failing sensor (truncated; full list still in the errors attribute). Backward compatible — no schema/entity/config-flow/dashboard change, counts unchanged (69/55). Tests: tests/test_v270_fixes.py + targeted tests for #44/#45/#46.
1 parent 5921ad9 commit 8224867

13 files changed

Lines changed: 267 additions & 30 deletions

File tree

‎CLAUDE.md‎

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

758758
## Version History
759759

760+
### v2.7.0 (2026-06-10)
761+
**Bug-fix wave — 4 issues (#43–#46)**
762+
763+
A focused bug-fix release closing four reports from DJm00n. Fully backward
764+
compatible — no schema/entity/config-flow change, entity counts unchanged
765+
(69 / 55), no dashboard change.
766+
767+
**#43 — Translation `MALFORMED_ARGUMENT` on the battery-power field (BUG).** The
768+
`battery_power` `data_description` embedded a Jinja example `{{ - states(...) |
769+
float(0) }}`; HA's translation engine treats `{{ }}` as an interpolation
770+
placeholder and replaced the whole string with the error label. The Jinja was
771+
removed from the description (plain-text instruction pointing to the README) in
772+
all 12 occurrences (`strings.json` + `translations/{en,it,nl}.json`, 3 steps
773+
each), and a sign-inversion template example was added to the README. Text only.
774+
775+
**#44 — Solar Surplus stopped an already-off charger every tick on `PRIORITY_HOME`
776+
(BUG, log/coordinator churn).** The `PRIORITY_HOME` branch in [solar_surplus.py](custom_components/ev_smart_charger/solar_surplus.py)
777+
called `stop_charger()` unconditionally, dispatching a no-op `switch.turn_off`,
778+
flapping coordinator ownership and resetting the check counter every 60 s while
779+
the home target was unmet. Now a 3-branch guard: stop only when actually
780+
charging; if off but still holding the coordinator, release it (no phantom
781+
owner); otherwise a single DEBUG line and return.
782+
783+
**#45 — Night Smart Charge phantom "active" loop when the switch is OFF (BUG,
784+
state machine).** In `_async_periodic_check` the `is_enabled()` check ran *after*
785+
`_is_in_active_window()`, which is not a pure predicate — it mutates
786+
`_session_state` to `"active"` at the activation window. A never-enabled install
787+
got stuck logging `Already active (hysteresis)` + `disabled, skipping` every
788+
minute until restart. The enabled check now runs first, so the window check (and
789+
its mutation) is never reached while the switch is off.
790+
791+
**#46 — Solar Surplus jumped to full amperage after a cloud (BUG, battery drain).**
792+
`_handle_grid_import_protection` reset `_last_grid_import_high` but not
793+
`_surplus_stable_since`, so the pre-cloud stability credit survived the grid-import
794+
event; once the cloud passed the system jumped straight to the full target in one
795+
step (a large home-battery draw). `_surplus_stable_since` is now reset both at
796+
first grid-import detection and after each step-down, forcing a fresh 60 s
797+
stability window before any increase.
798+
799+
**Diagnostic hardening (#47/#48).** The Solar Surplus diagnostic now exposes the
800+
raw `is_nighttime_computed` result alongside the already-present sunrise/sunset/
801+
offset attributes (so a daytime "SKIPPED: Nighttime" is fully self-diagnosable),
802+
and the `ERROR: Invalid sensor values` state now names the failing sensor
803+
(truncated, full list still in the `errors` attribute).
804+
805+
**Files**: `solar_surplus.py` (#44, #46, #47/#48), `night_smart_charge.py` (#45),
806+
`strings.json` + `translations/{en,it,nl}.json` (#43), `README.md` (#43),
807+
`const.py` + `manifest.json` (VERSION). `VERSION = "2.7.0"`.
808+
809+
---
810+
760811
### v2.6.1 (2026-06-06)
761812
**Dashboard: grid-lost indicator for issue #36 (frontend-only)**
762813

‎README.md‎

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

423423
**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.
424424

425+
**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.
426+
425427
**Grid available (v2.6.0 — [issue #36](https://github.com/antbald/ha-ev-smart-charger/issues/36)):** optional `binary_sensor` (on = grid present, off = grid lost), typically exposed by hybrid inverter integrations (e.g. `binary_sensor.inverter_grid`). On hybrid "Battery First"/UPS inverters a grid outage is invisible to the integration (every power sensor keeps reporting), so Night Smart Charge **grid mode** would keep drawing — now from the home battery — during the outage. When mapped and OFF (sustained for the grid-import delay, default 30 s), the grid-mode session stops. **Fail-safe:** an `unavailable`/`unknown` state (e.g. at HA boot or an inverter-integration restart) is treated as "unknown" and never triggers a stop. Leave empty to keep the previous behaviour.
426428

427429
### Step 4 — PV Forecast

‎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.6.1"
5+
VERSION = "2.7.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"

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

‎custom_components/ev_smart_charger/night_smart_charge.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -725,16 +725,21 @@ async def _async_periodic_check(self, now) -> None:
725725
self.logger.debug("Already active and within valid window, skipping re-evaluation")
726726
return
727727

728+
# Check if enabled FIRST (issue #45): _is_in_active_window() is not a
729+
# pure predicate — it mutates _session_state to "active" at the
730+
# activation window. If the enabled check ran after it, a never-enabled
731+
# install would get stuck logging "Already active (hysteresis)" +
732+
# "disabled, skipping" every minute. Gating on enabled first means the
733+
# window check (and its mutation) is never reached while the switch is off.
734+
if not self.is_enabled():
735+
self.logger.debug("Night Smart Charge disabled, skipping")
736+
return
737+
728738
# Check if we're in active window
729739
if not await self._is_in_active_window(current_time):
730740
self.logger.debug("Not in active window, skipping")
731741
return
732742

733-
# Check if enabled
734-
if not self.is_enabled():
735-
self.logger.debug("Night Smart Charge disabled, skipping")
736-
return
737-
738743
# Run evaluation
739744
self.logger.separator()
740745
self.logger.start(f"Night charge evaluation at {current_time.strftime('%H:%M:%S')}")

‎custom_components/ev_smart_charger/solar_surplus.py‎

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -712,17 +712,34 @@ async def _async_periodic_check(self, now=None, ignore_rate_limit: bool = False)
712712
self.logger.debug(f"Priority Balancer: {priority}")
713713

714714
if priority == PRIORITY_HOME:
715-
self.logger.warning("Priority = HOME - Stopping EV charger")
716-
if await self._acquire_control(
717-
"turn_off",
718-
"Home battery needs charging (Priority = HOME)",
719-
):
720-
await self.charger_controller.stop_charger(
721-
"Home battery needs charging (Priority = HOME)"
715+
# issue #44: only act when there is something to do. Calling
716+
# stop_charger() every tick on an already-off charger floods the
717+
# log, churns coordinator ownership and dispatches a no-op
718+
# switch.turn_off. Guard on the real charger state.
719+
if await self.charger_controller.is_charging():
720+
self.logger.warning("Priority = HOME - Stopping EV charger")
721+
if await self._acquire_control(
722+
"turn_off",
723+
"Home battery needs charging (Priority = HOME)",
724+
):
725+
await self.charger_controller.stop_charger(
726+
"Home battery needs charging (Priority = HOME)"
727+
)
728+
self._release_control("Priority = HOME")
729+
self._handle_control_loss("Priority = HOME")
730+
self.logger.separator()
731+
elif self._has_control():
732+
# Charger already off but we still hold the coordinator —
733+
# release it so we don't leave a phantom owner.
734+
self._release_control("Priority = HOME (charger already off)")
735+
self._handle_control_loss("Priority = HOME (charger already off)")
736+
self.logger.debug(
737+
"Priority = HOME, charger already off — released stale control"
738+
)
739+
else:
740+
self.logger.debug(
741+
"Priority = HOME, charger already off — skipping stop"
722742
)
723-
self._release_control("Priority = HOME")
724-
self._handle_control_loss("Priority = HOME")
725-
self.logger.separator()
726743
return
727744

728745
if priority == PRIORITY_EV_FREE:
@@ -741,6 +758,7 @@ async def _async_periodic_check(self, now=None, ignore_rate_limit: bool = False)
741758
# BEFORE summing — a single unavailable phase would otherwise be read as
742759
# 0 W by get_float and silently halve production/consumption.
743760
sensor_errors = []
761+
failed_entities = [] # issue #48: which sensors failed (for the state string)
744762
sensors_to_validate = self._power_model.labelled_power_entities()
745763

746764
# Check each sensor and track error state changes
@@ -750,6 +768,7 @@ async def _async_periodic_check(self, now=None, ignore_rate_limit: bool = False)
750768

751769
if not is_valid:
752770
sensor_errors.append(error_msg)
771+
failed_entities.append(entity_id)
753772
# Only log if this is a NEW error or error message changed
754773
if entity_id not in self._sensor_error_state or self._sensor_error_state[entity_id] != error_msg:
755774
self._sensor_error_state[entity_id] = error_msg
@@ -770,8 +789,18 @@ async def _async_periodic_check(self, now=None, ignore_rate_limit: bool = False)
770789
# Existing errors - just update diagnostic sensor quietly
771790
self.logger.debug(f"Sensor errors still present ({len(sensor_errors)} sensors unavailable)")
772791

792+
# issue #48: name the failing sensor(s) in the state string so the
793+
# user can see which one at a glance, not just an opaque "ERROR".
794+
# The HA state value is capped at 255 chars — keep it well under.
795+
if len(failed_entities) == 1:
796+
state_str = f"ERROR: Invalid sensor ({failed_entities[0]})"
797+
else:
798+
state_str = (
799+
f"ERROR: Invalid sensor ({failed_entities[0]} +"
800+
f"{len(failed_entities) - 1} more)"
801+
)
773802
await self._update_diagnostic_sensor(
774-
"ERROR: Invalid sensor values",
803+
state_str[:200],
775804
{"errors": sensor_errors, "last_check": dt_util.now().isoformat()}
776805
)
777806
self.logger.separator()
@@ -1203,6 +1232,12 @@ def _build_nighttime_debug_attributes(self, now: datetime) -> dict:
12031232
"sunset_today": sunset.isoformat() if sunset else None,
12041233
"nighttime_sunset_offset_min": sunset_offset,
12051234
"nighttime_sunrise_offset_min": sunrise_offset,
1235+
# issue #47: expose the raw computed result so a "SKIPPED: Nighttime"
1236+
# in daytime is fully self-diagnosable (right times + True here =>
1237+
# genuine logic input; otherwise location/tz misconfig or stale).
1238+
"is_nighttime_computed": self._astral_service.is_nighttime(
1239+
now, sunset_offset, sunrise_offset
1240+
),
12061241
}
12071242

12081243
async def _handle_nighttime_transition(self, now: datetime, current_profile: str | None) -> None:
@@ -1495,6 +1530,12 @@ async def _handle_grid_import_protection(
14951530

14961531
if self._last_grid_import_high is None:
14971532
self._last_grid_import_high = current_time
1533+
# issue #46: invalidate any pre-cloud stability credit the moment
1534+
# grid import is first detected. Otherwise _surplus_stable_since
1535+
# keeps accumulating through the cloud and, once it passes, the
1536+
# system jumps straight to full target amperage in one step (large
1537+
# battery draw) instead of re-earning a fresh 60s stability window.
1538+
self._surplus_stable_since = None
14981539
self.logger.warning(
14991540
f"Grid import ({grid_import}W) > threshold ({grid_threshold}W) - Starting {grid_import_delay}s delay"
15001541
)
@@ -1533,6 +1574,9 @@ async def _handle_grid_import_protection(
15331574

15341575
self.logger.warning("Grid import delay ELAPSED - Reducing charging")
15351576
self._last_grid_import_high = None
1577+
# issue #46: belt-and-suspenders — require a fresh stability window
1578+
# after each step-down too, so the ramp can't jump back up in one step.
1579+
self._surplus_stable_since = None
15361580

15371581
# Gradual ramp down: one level at a time via AmperageCalculator
15381582
next_amps = AmperageCalculator.get_next_level_down(current_amps, self._amp_levels)

‎custom_components/ev_smart_charger/strings.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
},
7373
"data_description": {
7474
"hybrid_inverter_mode": "Turns on the empirical curtailment probe for zero-export hybrid inverters. You can toggle it any time later from the dashboard — this only sets the initial state.",
75-
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor uses the opposite sign, create a template sensor: {{ - states('sensor.your_battery_power') | float(0) }}."
75+
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor reports the opposite sign, invert it with a template sensor that negates the value — see the README for an example."
7676
}
7777
},
7878
"pv_forecast": {
@@ -176,7 +176,7 @@
176176
"battery_power": "Battery power sensor (W, signed)"
177177
},
178178
"data_description": {
179-
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor uses the opposite sign, create a template sensor: {{ - states('sensor.your_battery_power') | float(0) }}."
179+
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor reports the opposite sign, invert it with a template sensor that negates the value — see the README for an example."
180180
}
181181
},
182182
"reconfigure_pv_forecast": {
@@ -339,7 +339,7 @@
339339
"battery_power": "Battery power sensor (W, signed)"
340340
},
341341
"data_description": {
342-
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor uses the opposite sign, create a template sensor: {{ - states('sensor.your_battery_power') | float(0) }}."
342+
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor reports the opposite sign, invert it with a template sensor that negates the value — see the README for an example."
343343
}
344344
},
345345
"pv_forecast": {

‎custom_components/ev_smart_charger/translations/en.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
},
7373
"data_description": {
7474
"hybrid_inverter_mode": "Turns on the empirical curtailment probe for zero-export hybrid inverters. You can toggle it any time later from the dashboard — this only sets the initial state.",
75-
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor uses the opposite sign, create a template sensor: {{ - states('sensor.your_battery_power') | float(0) }}."
75+
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor reports the opposite sign, invert it with a template sensor that negates the value — see the README for an example."
7676
}
7777
},
7878
"pv_forecast": {
@@ -176,7 +176,7 @@
176176
"battery_power": "Battery power sensor (W, signed)"
177177
},
178178
"data_description": {
179-
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor uses the opposite sign, create a template sensor: {{ - states('sensor.your_battery_power') | float(0) }}."
179+
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor reports the opposite sign, invert it with a template sensor that negates the value — see the README for an example."
180180
}
181181
},
182182
"reconfigure_pv_forecast": {
@@ -339,7 +339,7 @@
339339
"battery_power": "Battery power sensor (W, signed)"
340340
},
341341
"data_description": {
342-
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor uses the opposite sign, create a template sensor: {{ - states('sensor.your_battery_power') | float(0) }}."
342+
"battery_power": "Optional. Signed battery power in watts — NEGATIVE = discharging, POSITIVE = charging. Lets the probe detect when the home battery (not solar) is covering the EV, preventing false 'headroom found' results. If your sensor reports the opposite sign, invert it with a template sensor that negates the value — see the README for an example."
343343
}
344344
},
345345
"pv_forecast": {

0 commit comments

Comments
 (0)