Skip to content

Commit df2bda3

Browse files
Antonio ...claude
andcommitted
fix: v2.5.1 — stale 'SKIPPED: Nighttime' on Solar Surplus diagnostic + self-diagnosable astral times (issue #34)
- _start_timer runs an immediate check after registering the interval timer so the diagnostic sensor reflects real day/night state right after setup instead of carrying a stale (e.g. previous night's) value until the first tick. Wrapped in try/except so a first-check failure never blocks setup. - New _build_nighttime_debug_attributes attaches now/sunrise_today/sunset_today to SKIPPED: Nighttime (+ profile mismatch) events so users can tell a location/timezone misconfig from a stale value at a glance. - Detection/robustness only; is_nighttime() and charging control untouched. Entity counts unchanged (67/53). - Tests: test_nighttime_skip_exposes_astral_times, test_start_timer_runs_initial_check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8ba9955 commit df2bda3

5 files changed

Lines changed: 91 additions & 3 deletions

File tree

‎CLAUDE.md‎

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

758758
## Version History
759759

760+
### v2.5.1 (2026-06-04)
761+
**FIX: Stale "SKIPPED: Nighttime" on the Solar Surplus diagnostic + self-diagnosable astral times ([issue #34](https://github.com/antbald/ha-ev-smart-charger/issues/34), xion2000)**
762+
763+
**Problem**: a user (no overnight charging, no hybrid inverter, no forecast) saw the `evsc_solar_surplus_diagnostic` sensor stuck on `SKIPPED: Nighttime` in the middle of the day, then it "fixed itself at ~14:38" — an hour with no astronomical meaning. His `sun.sun` data (UK/BST: sunrise 04:33, sunset 21:33 local, `elevation 22.71`, `above_horizon`) proved the location/timezone were **correct**, and `is_nighttime()` ([astral_time_service.py:122](custom_components/ev_smart_charger/utils/astral_time_service.py#L122)) would have correctly returned "day" all afternoon. So this was **not** an astral-logic bug — the diagnostic sensor was simply **stale** (carrying the previous night's value), almost certainly because the post-update file swap (2.2.2 → 2.4.0) left the running instance in a partial state until a clean HA restart around 14:38 re-ran the loop.
764+
765+
**Two real weaknesses fixed (detection/robustness only — no charging-control change)**:
766+
1. **No immediate first check.** `_start_timer` ([solar_surplus.py](custom_components/ev_smart_charger/solar_surplus.py)) registered `async_track_time_interval` (which does **not** fire an initial tick), so the sensor kept whatever value it had until the first interval elapsed. Now `_start_timer` runs one `await self._async_periodic_check(ignore_rate_limit=True)` right after registering the timer, wrapped in try/except so a first-check failure never blocks setup. The sensor reflects the true day/night state seconds after setup.
767+
2. **`SKIPPED: Nighttime` was not self-diagnosable.** The event exposed no astral context. New `_build_nighttime_debug_attributes(now)` attaches `now` / `sunrise_today` / `sunset_today` to both `SKIPPED: Nighttime` and `SKIPPED: Nighttime (profile mismatch)` events. A user can now tell at a glance: wrong sunrise/sunset → HA location/timezone misconfig; correct times but daytime → stale value (and the immediate-tick fix removes that window).
768+
769+
**Scope / safety**: no entity/config-flow/schema change, entity counts unchanged (67/53), `is_nighttime()` untouched. Tests ([tests/test_solar_surplus.py](tests/test_solar_surplus.py)): `test_nighttime_skip_exposes_astral_times`, `test_start_timer_runs_initial_check`. Full suite green except the pre-existing environment-only baseline failures (grid-import / surplus-stability timing tests, identical on clean master). `VERSION = "2.5.1"`.
770+
771+
**Upgrade priority**: 🟢 RECOMMENDED if you ever see a stale `SKIPPED: Nighttime` in daytime — the sensor now self-corrects on setup and exposes the sunrise/sunset it computed. ⚪ NO-OP otherwise.
772+
773+
---
774+
760775
### v2.5.0 (2026-06-04)
761776
**FEATURE: Surface the silent "Priority Balancer disabled" battery-protection bypass ([issue #35](https://github.com/antbald/ha-ev-smart-charger/issues/35), DJm00n)**
762777

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

‎custom_components/ev_smart_charger/solar_surplus.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,15 @@ async def _start_timer(self) -> None:
446446

447447
self.logger.info(f"Timer started with {interval_minutes} minute interval")
448448

449+
# Run one check immediately so the diagnostic sensor reflects the
450+
# current day/night state right after setup instead of carrying a
451+
# stale value (e.g. last night's "SKIPPED: Nighttime") until the
452+
# first timer tick fires `interval_minutes` later (issue #34).
453+
try:
454+
await self._async_periodic_check(ignore_rate_limit=True)
455+
except Exception as err: # noqa: BLE001 - never block setup on first check
456+
self.logger.warning(f"Initial periodic check failed: {err}")
457+
449458
@callback
450459
async def _async_home_battery_soc_changed(self, event) -> None:
451460
"""Handle home battery SOC state changes for immediate battery protection.
@@ -1132,9 +1141,25 @@ async def _enforce_ev_target_hard_cap(self, context: str, charger_is_on: bool |
11321141
)
11331142
return True
11341143

1144+
def _build_nighttime_debug_attributes(self, now: datetime) -> dict:
1145+
"""Expose the astral times behind a nighttime decision.
1146+
1147+
Lets users self-diagnose a "SKIPPED: Nighttime" shown in daytime:
1148+
if these sunrise/sunset times look wrong, the HA location/timezone is
1149+
misconfigured; if they look right, the value is stale (issue #34).
1150+
"""
1151+
sunset = self._astral_service.get_sunset(now)
1152+
sunrise = self._astral_service.get_sunrise(now)
1153+
return {
1154+
"now": now.isoformat(),
1155+
"sunrise_today": sunrise.isoformat() if sunrise else None,
1156+
"sunset_today": sunset.isoformat() if sunset else None,
1157+
}
1158+
11351159
async def _handle_nighttime_transition(self, now: datetime, current_profile: str | None) -> None:
11361160
"""Handle sunset transition when Solar Surplus is no longer allowed to run."""
11371161
charger_is_on = await self.charger_controller.is_charging()
1162+
nighttime_debug = self._build_nighttime_debug_attributes(now)
11381163

11391164
if not charger_is_on:
11401165
if self._has_control():
@@ -1143,7 +1168,11 @@ async def _handle_nighttime_transition(self, now: datetime, current_profile: str
11431168
self.logger.skip("Nighttime - Solar Surplus only operates during daytime (sunrise to sunset)")
11441169
await self._update_diagnostic_sensor(
11451170
"SKIPPED: Nighttime",
1146-
{"reason": "Solar production unavailable at night", "last_check": dt_util.now().isoformat()},
1171+
{
1172+
"reason": "Solar production unavailable at night",
1173+
"last_check": dt_util.now().isoformat(),
1174+
**nighttime_debug,
1175+
},
11471176
)
11481177
return
11491178

@@ -1157,6 +1186,7 @@ async def _handle_nighttime_transition(self, now: datetime, current_profile: str
11571186
"reason": "Charger active but profile is not solar_surplus",
11581187
"profile": current_profile,
11591188
"last_check": dt_util.now().isoformat(),
1189+
**nighttime_debug,
11601190
},
11611191
)
11621192
return

‎tests/test_solar_surplus.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,46 @@ async def test_balancer_clear_after_warn_resets_guard(hass, automation):
503503
await automation._clear_balancer_disabled_warning()
504504
automation._notification_service.dismiss.assert_awaited_once()
505505
assert automation._balancer_disabled_warned_date is None
506+
507+
508+
async def test_nighttime_skip_exposes_astral_times(hass, automation):
509+
"""SKIPPED: Nighttime must expose now/sunrise/sunset for self-diagnosis (issue #34)."""
510+
hass.states.async_set("switch.force", "off")
511+
hass.states.async_set("select.profile", "manual")
512+
513+
automation._astral_service.is_nighttime.return_value = True
514+
automation.charger_controller.is_charging.return_value = False
515+
516+
sunrise = datetime(2026, 6, 4, 4, 33)
517+
sunset = datetime(2026, 6, 4, 21, 33)
518+
automation._astral_service.get_sunrise.return_value = sunrise
519+
automation._astral_service.get_sunset.return_value = sunset
520+
521+
captured = {}
522+
523+
async def _capture(state, attributes):
524+
captured["state"] = state
525+
captured["attributes"] = attributes
526+
527+
automation._update_diagnostic_sensor = _capture
528+
529+
await automation._async_periodic_check()
530+
531+
assert captured["state"] == "SKIPPED: Nighttime"
532+
attrs = captured["attributes"]
533+
assert attrs["sunrise_today"] == sunrise.isoformat()
534+
assert attrs["sunset_today"] == sunset.isoformat()
535+
assert "now" in attrs
536+
537+
538+
async def test_start_timer_runs_initial_check(hass, automation):
539+
"""_start_timer must run one immediate check so the sensor is never stale (issue #34)."""
540+
automation._async_periodic_check = AsyncMock()
541+
542+
with patch(
543+
"custom_components.ev_smart_charger.solar_surplus.async_track_time_interval"
544+
) as mock_track:
545+
mock_track.return_value = MagicMock()
546+
await automation._start_timer()
547+
548+
automation._async_periodic_check.assert_awaited_once_with(ignore_rate_limit=True)

0 commit comments

Comments
 (0)