Skip to content

Commit 66126d8

Browse files
xchillxxclaude
andcommitted
Keep log, readings and countdowns across restarts (v3.1.3)
- The action log, the last 30 minutes of readings and pending device switch countdowns are stored and restored. After a restart the car is decided on the first cycle instead of after 10 minutes of fresh readings, and the dashboard log keeps its history. - With a departure less than 2 h away the car is decided even without 10 minutes of readings (e.g. after a longer outage). - While the car power sensor has no value (an MQTT sensor right after a restart), the draw is estimated from the set current instead of 0 kW, which put the car's power into the base load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ef230bb commit 66126d8

5 files changed

Lines changed: 97 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ The car is re-planned every 15 minutes on a 30-minute average (or immediately
6060
on plug-in, which is acted on within seconds — a car that starts charging by
6161
itself is stopped right away); devices are re-planned every minute on a 5-minute median and
6262
switch only after the decision held for 10 minutes (20 for thermostats).
63+
The action log, the readings of the last 30 minutes and running switch
64+
countdowns survive a Home Assistant restart, so decisions continue right
65+
away instead of starting from scratch.
6366

6467
### Validated on recorded data
6568

‎custom_components/surplus_pilot/coordinator.py‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ async def device_switch(hass: HomeAssistant, dev: dict, on: bool) -> None:
140140

141141

142142
_UNSET = object()
143+
URGENT_DEPARTURE_H = 2
143144

144145

145146
class PilotCoordinator(DataUpdateCoordinator):
@@ -191,10 +192,39 @@ def _changed(event: Event) -> None:
191192

192193
async def async_setup(self) -> None:
193194
await self.store.async_load()
195+
self._restore_runtime_state()
194196
await self.calibrator.async_load()
195197
await self._backfill_runtime()
196198
await self._seed_price_archive()
197199

200+
def _restore_runtime_state(self) -> None:
201+
"""Log, recent readings and switch countdowns from before a restart.
202+
Readings older than the car's averaging window are dropped, so a long
203+
outage still starts fresh."""
204+
st = self.store.data
205+
self.log.extend(e for e in st["log"][:LOG_LENGTH] if isinstance(e, dict))
206+
now = dt_util.now()
207+
keep = timedelta(minutes=CAR_AVERAGE_MINUTES + 2)
208+
for row in st["samples"]:
209+
try:
210+
ts = dt_util.parse_datetime(row[0])
211+
vals = tuple(float(x) for x in row[1:4])
212+
except (TypeError, ValueError, IndexError):
213+
continue
214+
if ts is not None and timedelta(0) <= now - ts <= keep:
215+
self.samples.append((ts, *vals))
216+
ids = {d[CONF_DEV_ID] for d in self.devices}
217+
for did, (want, since) in st["pending"].items():
218+
ts = dt_util.parse_datetime(since) if isinstance(since, str) else None
219+
if did in ids and ts is not None and now - ts <= timedelta(hours=1):
220+
self._pending[did] = (bool(want), ts)
221+
222+
def _persist_runtime_state(self) -> None:
223+
st = self.store.data
224+
st["samples"] = [[s[0].isoformat(), round(s[1], 3), round(s[2], 3), round(s[3], 3)] for s in self.samples]
225+
st["pending"] = {did: [want, ts.isoformat()] for did, (want, ts) in self._pending.items()}
226+
st["log"] = list(self.log)
227+
198228
async def _seed_price_archive(self) -> None:
199229
"""Fill the price archive from the recorder's hourly statistics of a
200230
current-price sensor, so the cheap threshold works from day one."""
@@ -295,6 +325,8 @@ def _query():
295325

296326
def _log(self, text: str) -> None:
297327
self.log.appendleft({"zeit": dt_util.now().strftime("%d.%m. %H:%M"), "text": text})
328+
self.store.data["log"] = list(self.log)
329+
self.store.save()
298330

299331
@property
300332
def mode(self) -> str:
@@ -452,10 +484,14 @@ async def _async_update_data(self) -> dict:
452484
plugged = None
453485
if self.car_cfg:
454486
c = self.car_cfg
455-
car_kw = power_kw(self.hass, c.get(CONF_CAR_POWER_SENSOR)) or 0.0
487+
meas = power_kw(self.hass, c.get(CONF_CAR_POWER_SENSOR))
456488
ch = is_on(self.hass, c.get(CONF_CAR_CHARGING_SENSOR))
457-
charging = bool(ch) if ch is not None else (car_kw > 0.3 or self.car.switch_on())
458-
if c.get(CONF_CAR_POWER_SENSOR) is None and charging and c.get(CONF_CAR_CURRENT_ENTITY):
489+
charging = bool(ch) if ch is not None else ((meas or 0.0) > 0.3 or self.car.switch_on())
490+
car_kw = meas if meas is not None else 0.0
491+
# no power reading (none configured, or e.g. an MQTT sensor still
492+
# empty after a restart): estimate it from the set current, so the
493+
# car's draw doesn't end up in the base load
494+
if meas is None and charging and c.get(CONF_CAR_CURRENT_ENTITY):
459495
car_kw = (number(self.hass, c[CONF_CAR_CURRENT_ENTITY]) or 0) * self.car.kw_per_a
460496
plugged = plugged_state(self.hass, c.get(CONF_CAR_PLUGGED_SENSOR))
461497
if plugged is None:
@@ -558,7 +594,9 @@ def inputs(pv_kw: float, base_kw: float, car_fixed: float | None) -> P.Inputs:
558594
if forced or replan or slot != self._car_slot:
559595
win = [s for s in self.samples if (now - s[0]).total_seconds() <= CAR_AVERAGE_MINUTES * 60]
560596
covered = (win[-1][0] - win[0][0]).total_seconds() / 60 if len(win) > 1 else 0
561-
if covered >= CAR_MIN_COVERAGE_MINUTES or forced or (replan and win):
597+
# a departure within 2 h doesn't wait for 10 min of readings
598+
urgent = nd is not None and nd.start - now <= timedelta(hours=URGENT_DEPARTURE_H)
599+
if covered >= CAR_MIN_COVERAGE_MINUTES or forced or ((replan or urgent) and win):
562600
pv_m = sum(s[1] for s in win) / len(win)
563601
base_m = statistics.median(s[2] for s in win)
564602
self.car_plan = P.make_plan(inputs(pv_m, base_m, None))
@@ -591,6 +629,8 @@ def inputs(pv_kw: float, base_kw: float, car_fixed: float | None) -> P.Inputs:
591629
car_fixed = min(car5, cp.car_must_kw)
592630
self.device_plan = P.make_plan(inputs(pv5, base5, car_fixed))
593631
await self._apply_devices(now, dev_on)
632+
self._persist_runtime_state()
633+
self.store.save()
594634

595635
self.status = self._build_status(now, pv, load, base, soc, export, car_in, car_kw, charging, sun,
596636
load_stale, paused)

‎custom_components/surplus_pilot/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "local_polling",
1010
"issue_tracker": "https://github.com/xchillxx/surplus-pilot/issues",
1111
"requirements": [],
12-
"version": "3.1.2"
12+
"version": "3.1.3"
1313
}

‎custom_components/surplus_pilot/store.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ async def async_load(self) -> None:
5252
data.setdefault("cheap_percentile", 10)
5353
data.setdefault("cheap_target", 80)
5454
data.setdefault("commands", {"date": dt_util.now().date().isoformat(), "count": 0})
55+
# survive a restart: action log, the last 30 min of readings (car
56+
# decision needs 10 min of them) and running switch countdowns
57+
data.setdefault("log", [])
58+
data.setdefault("samples", [])
59+
data.setdefault("pending", {})
5560
self.data = data
5661

5762
def save(self) -> None:

‎tests/test_integration.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,47 @@ async def test_new_departure_is_planned_at_once(hass: HomeAssistant, freezer):
208208
await co.async_refresh()
209209
assert co.car_plan.need_kwh > 5
210210
assert co.car.decision.time == dt_util.now()
211+
212+
213+
async def test_log_readings_and_countdowns_survive_a_restart(hass: HomeAssistant, freezer):
214+
"""07.10. live: after each HA restart the action log was empty and the
215+
car stayed 'unknown' for 10 min while readings were collected again."""
216+
freezer.move_to("2026-10-07 09:01:00+00:00")
217+
set_world(hass, pv=9.0, load=0.6, soc=96, car_soc=50, export=2.0)
218+
for e in ("switch.car_charge", "switch.miner", "switch.pump", "climate.pool"):
219+
hass.states.async_set(e, "off")
220+
entry = MockConfigEntry(domain=DOMAIN, data=DATA)
221+
entry.add_to_hass(hass)
222+
assert await hass.config_entries.async_setup(entry.entry_id)
223+
await hass.async_block_till_done()
224+
225+
async def fake(call):
226+
ent = call.data.get("entity_id")
227+
if call.domain == "switch":
228+
hass.states.async_set(ent, "on" if call.service == "turn_on" else "off")
229+
elif call.domain == "climate":
230+
hass.states.async_set(ent, call.data["hvac_mode"])
231+
elif call.domain == "number":
232+
hass.states.async_set(ent, call.data["value"], {"min": 5, "max": 16})
233+
234+
for dom, srv in (("switch", "turn_on"), ("switch", "turn_off"), ("number", "set_value"),
235+
("climate", "set_hvac_mode")):
236+
hass.services.async_register(dom, srv, fake)
237+
for _ in range(14):
238+
freezer.tick(timedelta(minutes=1))
239+
set_world(hass, pv=9.0, load=0.6, soc=96, car_soc=50, export=2.0)
240+
async_fire_time_changed(hass, dt_util.utcnow())
241+
await hass.async_block_till_done()
242+
co = hass.data[DOMAIN][entry.entry_id]
243+
log_before = list(co.log)
244+
assert log_before and co.car.decision is not None
245+
pending_before = dict(co._pending)
246+
247+
assert await hass.config_entries.async_reload(entry.entry_id) # unload saves, setup loads
248+
await hass.async_block_till_done()
249+
co = hass.data[DOMAIN][entry.entry_id]
250+
assert list(co.log)[: len(log_before)] == log_before
251+
assert len(co.samples) >= 14
252+
assert co.car.decision is not None # decided on the first cycle, no 10 min wait
253+
for did, (want, since) in pending_before.items():
254+
assert co._pending.get(did, (None, None))[1] in (since, None)

0 commit comments

Comments
 (0)