Skip to content

Commit cd26b58

Browse files
Markus Rebbertclaude
andcommitted
feat: tomorrow charging-window sensor + negative-price bus events
Backlog items B and C — both folded into the v0.1.47 release that's scheduled to ship via the cadence workflow on Sunday 2026-07-05. **(B) `sensor.<sys>_cheapest_charging_window_tomorrow`** — twin of the existing today-sensor, but evaluated against tomorrow's forecast slots. Uses the same options-flow duration. No lock-in (tomorrow can't be acted on before midnight, so re-pick on every refresh). State `unknown` until tomorrow's prices arrive (~13:00 CET). Translations added in all seven shipped locales. **(C) Bus events `onekommafive_negative_price_started` / `_ended`** — fired by `OneKomma5PriceCoordinator._async_update_data` on positive↔negative edge transitions of the active 15-min slot. The first refresh after HA start primes the tracker but emits no event (no spurious notifications on restart). Payload: `system_id`, `price`, `negative_price_slots_remaining`. Pair blueprint `notify_negative_price_started.yaml` shipped alongside. Tests: 183 → 190 (+3 for B in `test_cheapest_charging_window_sensor.py`, +4 for C in new `test_negative_price_events.py`). Bug surfaced during testing: HA's default test timezone is Pacific, not UTC. The new tomorrow-window tests pin `hass.config.async_set_time_zone("UTC")` to keep the day-boundary math deterministic — same pattern as the existing today-twin's clip-at-end-of-today test. Updates: - `[0.1.47]` CHANGELOG section extended with three new bullets. - `release-notes/v0.1.47.md` gained a bus-events section and a tomorrow-window paragraph; total user-visible additions now reads "eight". - `README.md`: sensor table row, new bus-events section, blueprint bullet. - `blueprints/automation/onekommafive/README.md`: count 6 → 7, new entry for `notify_negative_price_started.yaml`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 04cd26e commit cd26b58

19 files changed

Lines changed: 592 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1616
- Translations for all three new entities in all seven shipped locales (de/en/nl/es/da/sv/fi).
1717
- **`onekommafive.refresh_now` service** — force an immediate refresh of one (or all) data coordinators. Schema: `coordinator: live | price | optimization | weather | system_status | all` (default `live`), optional `config_entry_id` for multi-system setups. Returns `{"refreshed": [...], "failed": [...]}` so automations can gate on per-coordinator success. Useful after a power outage, for debugging, and as a reset hook in automations.
1818
- **Configurable charging-window duration** — the *Cheapest charging window today* sensor's window length is now a configurable option (default 60 min, multiples of 15 between 15 and 240 min). Set it via **Settings → Devices & Services → 1KOMMA5° → Configure**. Users with longer flexible loads (90-min wash cycles, 2-h EV bulk charges) no longer need to fall back to the `onekommafive.get_cheapest_window` service to get a longer window; the sensor now keeps its lock-in semantic at the chosen duration. Mid-day duration changes invalidate the existing lock-in so the next refresh re-picks with the new length. Existing installs see no behaviour change — the default stays at 60 min.
19-
- **Two new automation blueprints** under `blueprints/automation/onekommafive/`:
19+
- **`sensor.<sys>_cheapest_charging_window_tomorrow`** — twin of the today-sensor for **tomorrow's** forecast. State is the start-timestamp of the cheapest N-min window in tomorrow's slots (same configurable duration as today via the options flow). Stays `unknown` until the price coordinator picks up tomorrow's prices (typically around 13:00 CET). Fills the gap left by the today-sensor after 23:00 — plan flex loads for the next morning the evening before.
20+
- **Bus events for negative-price transitions** — the price coordinator now fires `onekommafive_negative_price_started` when the active 15-min slot turns from a positive price to ≤ 0, and `onekommafive_negative_price_ended` on the reverse. Payload includes `system_id`, `price`, `negative_price_slots_remaining`. Lets automations trigger on `platform: event` with one line instead of building edge detection on top of `numeric_state`. Granularity is the coordinator refresh interval (1 h by default), so an edge may be reported up to one refresh-cycle late.
21+
- **Three new automation blueprints** under `blueprints/automation/onekommafive/`:
2022
- `notify_negative_prices_tomorrow.yaml` — heads-up notification when tomorrow's price forecast contains at least N negative-price slots. The price coordinator picks up tomorrow's prices around 13:00 CET, so that's when the notification usually fires. Use it to schedule flexible loads (washing machine, EV bulk charge) into the paid hours.
2123
- `ev_charge_on_pv_surplus.yaml` — toggles a target switch ON when the home battery is full AND PV power exceeds a threshold, OFF when either condition fails. Deactivation delay smooths out brief cloud cover. Pair with the wallbox's charging-mode select for SOLAR_CHARGE / SMART_CHARGE hand-off.
24+
- `notify_negative_price_started.yaml` — companion to the bus event above. Fires a notification the moment the active slot price turns negative — useful as a "the grid is paying you right now" alert.
2225

2326
### Fixed
2427
- Per-asset connectivity binary sensors no longer duplicate the asset type in their display name. Since the v0.1.44 sub-device split, HA composed `<device-name> + <entity-name>` — so a sensor on the "Wechselrichter" sub-device whose translation said "Wechselrichter verbunden" rendered as "Wechselrichter Wechselrichter verbunden". The four asset-type translations (`inverter_connected` / `heat_pump_connected` / `meter_connected` / `wallbox_connected`) are now just the past-participle word ("Verbunden" / "Connected" / "Verbonden" / "Conectado" / "Forbundet" / "Ansluten" / "Yhdistetty") across all seven locales — the device name carries the noun. `site_connected` stays unchanged (lives on the parent device, not duplicated). `entity_id`s and `unique_id`s are unchanged; existing automations and dashboards keep working.

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,13 @@ The [`dashboard/`](dashboard/) directory contains a ready-to-use Home Assistant
131131

132132
## Home Assistant Automation Blueprints
133133

134-
Six ready-to-import blueprints in [`blueprints/automation/onekommafive/`](blueprints/automation/onekommafive/):
134+
Seven ready-to-import blueprints in [`blueprints/automation/onekommafive/`](blueprints/automation/onekommafive/):
135135

136136
- **Run during cheapest window** — schedules a switch for the cheapest N-minute window each day (dishwasher, washing machine, EV)
137137
- **Follow cheap electricity** — mirrors a switch to `binary_sensor…_cheap_electricity` for opportunistic loads (water heater, pool pump)
138138
- **Notify on AI grid-charge decision** — pings you whenever the Heartbeat AI starts charging the battery from the grid
139139
- **Notify on negative prices tomorrow** — heads-up when tomorrow's forecast contains at least N negative-price slots (fires around 13:00 CET when tomorrow's prices arrive)
140+
- **Notify when the grid pays you** — fires the moment the active slot turns negative, built on the new bus event
140141
- **EV charge on PV surplus** — toggles a switch ON when the home battery is full AND PV power exceeds a threshold, OFF when either condition fails
141142
- **Notify when a device goes offline** — alerts you when site, inverter, heat pump, meter or wallbox connectivity sensor stays OFF for a configurable debounce
142143

@@ -203,7 +204,8 @@ Installations missing one of the four asset types (e.g. no heat pump, or a grid-
203204
| Average Electricity Price Tomorrow | `tomorrow_average_price` | Tomorrow's average all-in price (available after ~13:00 CET) | EUR/kWh | 1 h |
204205
| Lowest Electricity Price Tomorrow | `tomorrow_lowest_price` | Tomorrow's lowest all-in price | EUR/kWh | 1 h |
205206
| Highest Electricity Price Tomorrow | `tomorrow_highest_price` | Tomorrow's highest all-in price | EUR/kWh | 1 h |
206-
| Cheapest Charging Window Today | `cheapest_charging_window_today` | Start timestamp of the cheapest contiguous 60-min window remaining today. Attributes: `start`, `end`, `average_price`, `duration_minutes`, `slot_count`. Returns "unknown" once less than 60 min remain today. | timestamp | 15 min |
207+
| Cheapest Charging Window Today | `cheapest_charging_window_today` | Start timestamp of the cheapest contiguous N-min window remaining today (N = configurable via options flow, default 60). Attributes: `start`, `end`, `average_price`, `duration_minutes`, `slot_count`. Returns "unknown" once less than N min remain today. | timestamp | 15 min |
208+
| Cheapest Charging Window Tomorrow | `cheapest_charging_window_tomorrow` | Twin of the today-sensor for tomorrow's forecast. Same attributes, same configurable duration. Stays "unknown" until tomorrow's prices arrive (typically around 13:00 CET). Fills the evening-planning gap. | timestamp | 15 min |
207209

208210
All price sensors use `state_class: measurement`, so Home Assistant automatically records **long-term statistics** (hourly min/max/mean). Price history is visible in the History panel and can be used for trend analysis.
209211

@@ -544,6 +546,32 @@ action:
544546
entity_id: switch.dishwasher
545547
```
546548

549+
### Bus events: `onekommafive_negative_price_started` / `onekommafive_negative_price_ended`
550+
551+
The price coordinator fires these edge events when the active 15-min slot transitions across zero — `_started` on a positive→negative flip, `_ended` on the reverse. Lets automations subscribe via `platform: event` with one line instead of building edge detection on top of `numeric_state`.
552+
553+
The first refresh after a HA start primes the tracker but emits no event (to avoid spurious notifications on restart). Granularity is the coordinator refresh interval (1 h by default) — an edge may be reported up to one refresh-cycle late.
554+
555+
**Event data (both events):**
556+
557+
| Field | Type | Description |
558+
|-------|------|-------------|
559+
| `system_id` | string | The 1KOMMA5° system UUID |
560+
| `price` | float | Current active-slot price in EUR/kWh (negative on `_started`, positive on `_ended`) |
561+
| `negative_price_slots_remaining` | int | Count of remaining negative 15-min slots in today's forecast |
562+
563+
**Example automation** — pre-cool the freezer while the grid pays you:
564+
565+
```yaml
566+
trigger:
567+
- platform: event
568+
event_type: onekommafive_negative_price_started
569+
action:
570+
- service: switch.turn_on
571+
target:
572+
entity_id: switch.freezer_pre_cool
573+
```
574+
547575
---
548576

549577
## Requirements

blueprints/automation/onekommafive/README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 1KOMMA5° automation blueprints
22

3-
Six ready-to-import [Home Assistant blueprints][ha-blueprints] for the most
3+
Seven ready-to-import [Home Assistant blueprints][ha-blueprints] for the most
44
common automations on top of this integration.
55

66
## Importing
@@ -75,6 +75,20 @@ Inputs: battery SoC sensor, battery-full threshold (default 95 %), PV
7575
power sensor, PV surplus threshold (default 5 000 W), target switch,
7676
deactivation delay (default 5 min).
7777

78+
### `notify_negative_price_started.yaml` — Notify when the grid pays you
79+
Fires a notification the moment the active 15-min electricity price
80+
becomes ≤ 0. Listens to the
81+
`onekommafive_negative_price_started` bus event — granularity is the
82+
price-coordinator refresh interval (default 1 h), so the alert may
83+
arrive up to a refresh-cycle late.
84+
85+
Companion to `notify_negative_prices_tomorrow.yaml` (forecast-side
86+
heads-up about *tomorrow's* negative slots).
87+
88+
Inputs: notify service, title, message (Jinja-templatable with
89+
`trigger.event.data.price` and
90+
`trigger.event.data.negative_price_slots_remaining`).
91+
7892
### `notify_connectivity_lost.yaml` — Notify when a device goes offline
7993
Sends a notification when one of the v0.1.38 connectivity sensors (site,
8094
inverter, heat pump, meter, wallbox) stays OFF for a debounce duration
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
blueprint:
2+
name: 1KOMMA5° — Notify when the grid pays you
3+
description: >
4+
Send a notification the moment the active 15-min electricity price
5+
becomes ≤ 0 — i.e. the spot market is paying you to consume.
6+
7+
8+
Listens to the `onekommafive_negative_price_started` bus event
9+
fired by the price coordinator on every positive→negative
10+
transition of the active slot. The mirror
11+
`onekommafive_negative_price_ended` event is also fired but
12+
typically less interesting; build a second instance off this
13+
blueprint with the alternate `event_type` if you want both.
14+
15+
16+
Companion to `notify_negative_prices_tomorrow.yaml`, which is the
17+
forecast-side (heads-up about *tomorrow* containing negative
18+
slots). This blueprint reacts to the *now*-side.
19+
20+
domain: automation
21+
source_url: https://github.com/mrebbert/1komma5-ha/blob/main/blueprints/automation/onekommafive/notify_negative_price_started.yaml
22+
23+
input:
24+
notify_target:
25+
name: Notification target
26+
description: >
27+
The `notify` service to call (e.g. `notify.mobile_app_pixel`,
28+
`notify.persistent_notification`, `notify.notify`).
29+
default: notify.persistent_notification
30+
selector:
31+
text: {}
32+
title:
33+
name: Notification title
34+
default: "1KOMMA5°: The grid is paying you"
35+
selector:
36+
text: {}
37+
message:
38+
name: Notification message
39+
description: >
40+
Supports Jinja templates. Available variable:
41+
`trigger.event.data.price` (current EUR/kWh, negative number),
42+
`trigger.event.data.negative_price_slots_remaining` (count of
43+
remaining negative 15-min slots today).
44+
default: >
45+
Active electricity price just turned negative: {{ trigger.event.data.price }}
46+
EUR/kWh. Negative slots remaining today:
47+
{{ trigger.event.data.negative_price_slots_remaining }}.
48+
selector:
49+
text:
50+
multiline: true
51+
52+
mode: single
53+
max_exceeded: silent
54+
55+
trigger:
56+
- platform: event
57+
event_type: onekommafive_negative_price_started
58+
59+
action:
60+
- service: !input notify_target
61+
data:
62+
title: !input title
63+
message: !input message

custom_components/onekommafive/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@
2323

2424
# Bus event fired whenever a new optimization decision is observed.
2525
EVENT_OPTIMIZATION_DECISION = "onekommafive_optimization_decision"
26+
27+
# Bus events fired on edge transitions of the active electricity price.
28+
# `_started` → previous active slot had price > 0, new slot has price ≤ 0.
29+
# `_ended` → previous active slot had price ≤ 0, new slot has price > 0.
30+
EVENT_NEGATIVE_PRICE_STARTED = "onekommafive_negative_price_started"
31+
EVENT_NEGATIVE_PRICE_ENDED = "onekommafive_negative_price_ended"

custom_components/onekommafive/coordinator.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1212

1313
from .const import (
14+
EVENT_NEGATIVE_PRICE_ENDED,
15+
EVENT_NEGATIVE_PRICE_STARTED,
1416
EVENT_OPTIMIZATION_DECISION,
1517
LIVE_UPDATE_INTERVAL_SECONDS,
1618
OPTIMIZATION_UPDATE_INTERVAL_SECONDS,
@@ -194,6 +196,11 @@ class OneKomma5PriceCoordinator(OneKomma5BaseCoordinator[PriceData]):
194196
_coordinator_name = "1KOMMA5° Prices"
195197
_interval_seconds = PRICE_UPDATE_INTERVAL_SECONDS
196198

199+
# Tracks the active-slot price sign across refreshes so we can detect
200+
# negative ↔ positive edges and fire bus events on transitions.
201+
# None on the first refresh — no event then.
202+
_last_active_was_negative: bool | None = None
203+
197204
def _fetch(self) -> PriceData:
198205
"""Fetch price data synchronously.
199206
@@ -262,6 +269,37 @@ def _fetch(self) -> PriceData:
262269
tomorrow_highest_price=tomorrow_highest,
263270
)
264271

272+
async def _async_update_data(self) -> PriceData:
273+
"""Fetch + fire HA bus events for negative-price edge transitions."""
274+
data = await super()._async_update_data()
275+
self._fire_negative_price_edge_events(data)
276+
return data
277+
278+
def _fire_negative_price_edge_events(self, data: PriceData) -> None:
279+
"""Detect a negative ↔ positive transition on the active slot and fire events.
280+
281+
Granularity is the coordinator refresh interval (1 h by default), so an
282+
edge may be reported up to one refresh-cycle late. The first refresh
283+
after HA start primes ``_last_active_was_negative`` but fires no event.
284+
"""
285+
if data.current_price is None:
286+
return
287+
is_negative_now = data.current_price <= 0
288+
previous = self._last_active_was_negative
289+
self._last_active_was_negative = is_negative_now
290+
291+
if previous is None or previous == is_negative_now:
292+
return
293+
294+
payload = {
295+
"system_id": self._system.id(),
296+
"price": data.current_price,
297+
"negative_price_slots_remaining": data.negative_price_slots_today,
298+
}
299+
event_type = EVENT_NEGATIVE_PRICE_STARTED if is_negative_now else EVENT_NEGATIVE_PRICE_ENDED
300+
_LOGGER.debug("Firing %s: %s", event_type, payload)
301+
self.hass.bus.async_fire(event_type, payload)
302+
265303

266304
class OneKomma5OptimizationCoordinator(OneKomma5BaseCoordinator[OptimizationData]):
267305
"""Coordinator for AI optimization event data."""

custom_components/onekommafive/sensor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
CURRENCY_EUR_PER_KWH,
4242
OneKomma5ActiveFeaturesSensor,
4343
OneKomma5CheapestChargingWindowSensor,
44+
OneKomma5CheapestChargingWindowTomorrowSensor,
4445
OneKomma5ConsumerCostSensor,
4546
OneKomma5CostSensor,
4647
OneKomma5DiagnosticSensor,
@@ -520,6 +521,11 @@ def _resolve_asset(device_key: str | None) -> Any | None:
520521
price_coordinator, system_id, system_name, charging_window_duration
521522
)
522523
)
524+
entities.append(
525+
OneKomma5CheapestChargingWindowTomorrowSensor(
526+
price_coordinator, system_id, system_name, charging_window_duration
527+
)
528+
)
523529

524530
# Accumulated electricity cost sensor
525531
entities.append(

custom_components/onekommafive/sensor_entities.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import datetime as _dt
910
import logging
1011
from datetime import datetime
1112
from typing import Any
@@ -267,6 +268,86 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
267268
}
268269

269270

271+
class OneKomma5CheapestChargingWindowTomorrowSensor(OneKomma5PriceEntity, SensorEntity):
272+
"""Cheapest N-min charging window in **tomorrow's** forecast (HA-local time).
273+
274+
Twin of `OneKomma5CheapestChargingWindowSensor` but for tomorrow. Uses the
275+
same option-driven duration. No lock-in semantic — tomorrow's window
276+
can't be acted on before midnight, so re-picking on every refresh is
277+
safe (and necessary as prices update). State is `unknown` until the
278+
price coordinator picks up tomorrow's prices, typically around 13:00
279+
CET on the previous day.
280+
"""
281+
282+
_attr_translation_key = "cheapest_charging_window_tomorrow"
283+
_attr_device_class = SensorDeviceClass.TIMESTAMP
284+
_attr_icon = "mdi:ev-station"
285+
286+
def __init__(
287+
self,
288+
coordinator: Any,
289+
system_id: str,
290+
system_name: str,
291+
duration_minutes: int,
292+
) -> None:
293+
super().__init__(coordinator, system_id, system_name, "cheapest_charging_window_tomorrow")
294+
self._duration_minutes = duration_minutes
295+
self._slot_count = duration_minutes // 15
296+
self._window: dict[str, Any] | None = None
297+
298+
@callback
299+
def _handle_coordinator_update(self) -> None:
300+
self._refresh_window()
301+
super()._handle_coordinator_update()
302+
303+
def _refresh_window(self) -> None:
304+
self._window = None
305+
if self.coordinator.data is None or not self.coordinator.data.forecast:
306+
return
307+
now_local = dt_util.now()
308+
tomorrow_local = now_local.date() + _dt.timedelta(days=1)
309+
start_of_tomorrow = now_local.replace(
310+
year=tomorrow_local.year,
311+
month=tomorrow_local.month,
312+
day=tomorrow_local.day,
313+
hour=0,
314+
minute=0,
315+
second=0,
316+
microsecond=0,
317+
)
318+
end_of_tomorrow = start_of_tomorrow.replace(
319+
hour=23, minute=59, second=59, microsecond=999999
320+
)
321+
self._window = find_cheapest_window(
322+
self.coordinator.data.forecast,
323+
self._slot_count,
324+
earliest_start=dt_util.as_utc(start_of_tomorrow),
325+
latest_end=dt_util.as_utc(end_of_tomorrow),
326+
)
327+
328+
async def async_added_to_hass(self) -> None:
329+
await super().async_added_to_hass()
330+
self._refresh_window()
331+
332+
@property
333+
def native_value(self) -> datetime | None:
334+
if self._window is None:
335+
return None
336+
return datetime.fromisoformat(self._window["start"])
337+
338+
@property
339+
def extra_state_attributes(self) -> dict[str, Any] | None:
340+
if self._window is None:
341+
return None
342+
return {
343+
"start": self._window["start"],
344+
"end": self._window["end"],
345+
"average_price": self._window["average_price"],
346+
"duration_minutes": self._duration_minutes,
347+
"slot_count": self._window["slot_count"],
348+
}
349+
350+
270351
class OneKomma5EVSensor(OneKomma5EVEntity, SensorEntity):
271352
"""Sensor for EV vehicle data."""
272353

custom_components/onekommafive/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@
250250
"cheapest_charging_window_today": {
251251
"name": "Günstigstes Ladefenster heute"
252252
},
253+
"cheapest_charging_window_tomorrow": {
254+
"name": "Günstigstes Ladefenster morgen"
255+
},
253256
"electricity_cost": {
254257
"name": "Stromkosten"
255258
},

custom_components/onekommafive/translations/da.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@
250250
"cheapest_charging_window_today": {
251251
"name": "Billigste ladevindue i dag"
252252
},
253+
"cheapest_charging_window_tomorrow": {
254+
"name": "Billigste ladevindue i morgen"
255+
},
253256
"electricity_cost": {
254257
"name": "Strømomkostninger"
255258
},

0 commit comments

Comments
 (0)