Skip to content

Commit 57ab2ed

Browse files
committed
feat: Added event sensor for exposing the discounts (1.5 hours dev time)
1 parent 99e670d commit 57ab2ed

File tree

13 files changed

+375
-20
lines changed

13 files changed

+375
-20
lines changed

_docs/entities/fan_club.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,25 @@ The next discount for the specified fan club, that differs from the current disc
4141
|-----------|------|-------------|
4242
| `source` | `string` | The source of the discount |
4343
| `start` | `datetime` | The date/time when the discount starts |
44-
| `end` | `datetime` | The date/time when the discount ends |
44+
| `end` | `datetime` | The date/time when the discount ends |
45+
46+
## Discounts
47+
48+
`event.octopus_energy_fan_club_{{ACCOUNT_ID}}_{{FAN_CLUB_ID}}_discounts`
49+
50+
The state of this sensor states when the discounts were last retrieved. The attributes of this sensor exposes the available discounts for a given fan club source.
51+
52+
| Attribute | Type | Description |
53+
|-----------|------|-------------|
54+
| `discounts` | `array` | The list of past, present and future discounts |
55+
| `account_id` | `string` | The id of the account the discounts are for |
56+
| `source` | `string` | The source of the discounts (e.g. Fan #1) |
57+
58+
Each rate item has the following attributes
59+
60+
| Attribute | Type | Description |
61+
|-----------|------|-------------|
62+
| `start` | `datetime` | The date/time when the discount starts/started |
63+
| `end` | `datetime` | The date/time when the discount ends/ended |
64+
| `discount` | `float` | The value of the discount |
65+
| `is_estimated` | `boolean` | Determines if the discount is estimated |

custom_components/octopus_energy/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,8 @@ async def async_setup_dependencies(hass, config):
563563
discounts.append(DiscountSource(source=item.discountSource, discounts=combine_discounts(item)))
564564

565565
if (len(discounts) > 0):
566-
hass.data[DOMAIN][account_id][DATA_FAN_CLUB_DISCOUNTS] = FanClubDiscountCoordinatorResult(now, 1, discounts)
566+
# Make it old so we can force a refresh
567+
hass.data[DOMAIN][account_id][DATA_FAN_CLUB_DISCOUNTS] = FanClubDiscountCoordinatorResult(now - timedelta(days=1), 1, discounts)
567568
await async_setup_fan_club_discounts_coordinator(hass, account_id, mock_fan_club)
568569

569570
async def options_update_listener(hass, entry):

custom_components/octopus_energy/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@
231231
EVENT_NEW_FREE_ELECTRICITY_SESSION = "octopus_energy_new_octoplus_free_electricity_session"
232232
EVENT_ALL_FREE_ELECTRICITY_SESSIONS = "octopus_energy_all_octoplus_free_electricity_sessions"
233233

234+
EVENT_FAN_CLUB_DISCOUNTS = "octopus_energy_fan_club_discounts"
235+
234236
REPAIR_UNIQUE_RATES_CHANGED_KEY = "electricity_unique_rates_updated_{}"
235237
REPAIR_INVALID_API_KEY = "invalid_api_key_{}"
236238
REPAIR_ACCOUNT_NOT_FOUND = "account_not_found_{}"

custom_components/octopus_energy/coordinators/fan_club_discounts.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from datetime import datetime, timedelta
3+
from typing import Any, Callable
34

45
from homeassistant.util.dt import (now)
56
from homeassistant.helpers.update_coordinator import (
@@ -12,6 +13,7 @@
1213
DOMAIN,
1314
DATA_CLIENT,
1415
DATA_FAN_CLUB_DISCOUNTS,
16+
EVENT_FAN_CLUB_DISCOUNTS,
1517
REFRESH_RATE_IN_MINUTES_FAN_CLUB_DISCOUNTS,
1618
)
1719

@@ -34,6 +36,7 @@ async def async_refresh_fan_club_discounts(
3436
account_id: str,
3537
client: OctopusEnergyApiClient,
3638
existing_result: FanClubDiscountCoordinatorResult,
39+
fire_event: Callable[[str, "dict[str, Any]"], None],
3740
mock_fan_club: bool = False
3841
) -> FanClubDiscountCoordinatorResult:
3942
if existing_result is None or current >= existing_result.next_refresh:
@@ -48,6 +51,10 @@ async def async_refresh_fan_club_discounts(
4851
for item in result.fanClubStatus:
4952
discounts.append(DiscountSource(source=item.discountSource, discounts=combine_discounts(item)))
5053

54+
# Fire events
55+
event_data = { "discounts": list(map(lambda x: x.dict(), discounts[-1].discounts)), "account_id": account_id, "source": discounts[-1].source }
56+
fire_event(EVENT_FAN_CLUB_DISCOUNTS, event_data)
57+
5158
return FanClubDiscountCoordinatorResult(current, 1, discounts)
5259
except Exception as e:
5360
if isinstance(e, ApiException) == False:
@@ -84,6 +91,7 @@ async def async_update_data():
8491
account_id,
8592
client,
8693
hass.data[DOMAIN][account_id][DATA_FAN_CLUB_DISCOUNTS] if DATA_FAN_CLUB_DISCOUNTS in hass.data[DOMAIN][account_id] else None,
94+
hass.bus.async_fire,
8795
mock_fan_club
8896
)
8997

custom_components/octopus_energy/event.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@
1515
from .gas.rates_previous_consumption import OctopusEnergyGasPreviousConsumptionRates
1616
from .octoplus.saving_sessions_events import OctopusEnergyOctoplusSavingSessionEvents
1717
from .octoplus.free_electricity_sessions_events import OctopusEnergyOctoplusFreeElectricitySessionEvents
18+
from .coordinators.fan_club_discounts import FanClubDiscountCoordinatorResult
19+
from .fan_club.discounts import OctopusEnergyFanClubDiscounts
1820

1921
from .const import (
2022
CONFIG_ACCOUNT_ID,
2123
DATA_CLIENT,
24+
DATA_FAN_CLUB_DISCOUNTS,
25+
DATA_FAN_CLUB_DISCOUNTS_COORDINATOR,
2226
DOMAIN,
2327

2428
CONFIG_MAIN_API_KEY,
@@ -91,5 +95,11 @@ async def async_setup_main_sensors(hass, entry, async_add_entities):
9195
entities.append(OctopusEnergyGasNextDayRates(hass, meter, point))
9296
entities.append(OctopusEnergyGasPreviousConsumptionRates(hass, meter, point))
9397

98+
if DATA_FAN_CLUB_DISCOUNTS_COORDINATOR in hass.data[DOMAIN][account_id]:
99+
fan_club_response: FanClubDiscountCoordinatorResult = hass.data[DOMAIN][account_id][DATA_FAN_CLUB_DISCOUNTS]
100+
if fan_club_response is not None:
101+
for fan_club in fan_club_response.discounts:
102+
entities.append(OctopusEnergyFanClubDiscounts(hass, account_id, fan_club.source))
103+
94104
if len(entities) > 0:
95105
async_add_entities(entities)

custom_components/octopus_energy/fan_club/__init__.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,43 @@ class Discount(BaseModel):
99
start: datetime
1010
end: datetime
1111
discount: float
12+
is_estimated: bool
1213

1314
class DiscountSource(BaseModel):
1415
source: str
1516
discounts: list[Discount]
1617

17-
def discount_period_to_discount(period: DiscountPeriod) -> Discount:
18-
return Discount(start=period.startAt, end=period.startAt + timedelta(minutes=30), discount=float(period.discount) * 100)
18+
def discount_period_to_discount(period: DiscountPeriod, is_estimated: bool) -> Discount:
19+
return Discount(start=period.startAt, end=period.startAt + timedelta(minutes=30), discount=float(period.discount) * 100, is_estimated=is_estimated)
1920

2021
def combine_discounts(status: FanClubStatusItem) -> list[Discount]:
2122
discounts: list[Discount] = []
2223

23-
discounts.extend(list(map(lambda x: discount_period_to_discount(x), status.historic)))
24-
discounts.append(discount_period_to_discount(status.current))
24+
discounts.extend(list(map(lambda x: discount_period_to_discount(x, False), status.historic)))
25+
26+
# Add current discount period. The current time is basically "now", so depending on where we are will depend on
27+
# the number of current discounts that need to be added
28+
current = discount_period_to_discount(status.current, True)
29+
current.start = current.start.replace(minute=30 if current.start.minute >= 30 else 0, second=0, microsecond=0)
30+
current.end = current.start + timedelta(minutes=30)
31+
discounts.append(current)
32+
if (current.start.minute < 30):
33+
discounts.append(Discount(start=current.end, end=current.end + timedelta(minutes=30), discount=current.discount, is_estimated=True))
2534
for forecast in status.forecast.data:
26-
discounts.append(Discount(start=forecast.validTime, end=forecast.validTime + timedelta(minutes=30), discount=float(forecast.projectedDiscount) * 100))
27-
discounts.append(Discount(start=forecast.validTime + timedelta(minutes=30), end=forecast.validTime + timedelta(minutes=60), discount=float(forecast.projectedDiscount) * 100))
35+
discounts.append(Discount(start=forecast.validTime, end=forecast.validTime + timedelta(minutes=30), discount=float(forecast.projectedDiscount) * 100, is_estimated=True))
36+
discounts.append(Discount(start=forecast.validTime + timedelta(minutes=30), end=forecast.validTime + timedelta(minutes=60), discount=float(forecast.projectedDiscount) * 100, is_estimated=True))
2837

38+
discounts.sort(key=lambda x: x.start)
2939
return discounts
3040

3141
def get_fan_club_number(discountSource: str):
3242
return discountSource.split(":")[0].strip()
3343

3444
def mock_fan_club_forecast() -> FanClubResponse:
3545
now: datetime = utcnow()
36-
now = now.replace(minute=30 if now.minute >= 30 else 0, second=0, microsecond=0)
46+
now_thirty_minute = now.replace(minute=30 if now.minute >= 30 else 0, second=0, microsecond=0)
3747
historic = []
38-
current = now
48+
current = now_thirty_minute
3949
for i in range(0, 48):
4050
current = current - timedelta(minutes=30)
4151
historic.append({
@@ -44,7 +54,7 @@ def mock_fan_club_forecast() -> FanClubResponse:
4454
})
4555

4656
forecast = []
47-
current = now
57+
current = now_thirty_minute
4858
for i in range(0, 48):
4959
current = current + timedelta(minutes=60)
5060
forecast.append({
@@ -58,7 +68,7 @@ def mock_fan_club_forecast() -> FanClubResponse:
5868
"discountSource": "#1 Fan: Market Weighton - Carr Farm",
5969
"current": {
6070
"startAt": now,
61-
"discount": "0.500"
71+
"discount": f"{random.choice([0,0.200,0.500])}"
6272
},
6373
"historic": historic,
6474
"forecast": {

custom_components/octopus_energy/fan_club/current_discount.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ def _handle_coordinator_update(self) -> None:
8888
discount_information = None
8989
if target_discount is not None:
9090
discount_information = get_current_fan_club_discount_information(target_discount.discounts, current)
91+
else:
92+
_LOGGER.warning(f"Unable to find discount information for '{self._discount_source}'")
9193

9294
if discount_information is not None:
9395
self._attributes = {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import logging
2+
3+
from homeassistant.core import HomeAssistant, callback
4+
5+
from homeassistant.components.event import (
6+
EventEntity,
7+
EventExtraStoredData,
8+
)
9+
from homeassistant.helpers.restore_state import RestoreEntity
10+
11+
from .base import (OctopusEnergyFanClubSensor)
12+
from ..utils.attributes import dict_to_typed_dict
13+
from ..const import EVENT_FAN_CLUB_DISCOUNTS
14+
from . import get_fan_club_number
15+
16+
_LOGGER = logging.getLogger(__name__)
17+
18+
class OctopusEnergyFanClubDiscounts(OctopusEnergyFanClubSensor, EventEntity, RestoreEntity):
19+
"""Sensor for displaying the current day's rates."""
20+
21+
def __init__(self, hass: HomeAssistant, account_id: str, discount_source: str):
22+
"""Init sensor."""
23+
# Pass coordinator to base class
24+
25+
self._hass = hass
26+
self._state = None
27+
self._last_updated = None
28+
self._account_id = account_id
29+
self._discount_source = discount_source
30+
31+
self._attr_event_types = [EVENT_FAN_CLUB_DISCOUNTS]
32+
OctopusEnergyFanClubSensor.__init__(self, hass, "event")
33+
34+
@property
35+
def unique_id(self):
36+
"""The id of the sensor."""
37+
return f"octopus_energy_fan_club_{self._account_id}_{get_fan_club_number(self._discount_source)}_discounts"
38+
39+
@property
40+
def name(self):
41+
"""Name of the sensor."""
42+
return f"Discounts Fan Club ({get_fan_club_number(self._discount_source)}/{self._account_id})"
43+
44+
async def async_added_to_hass(self):
45+
"""Call when entity about to be added to hass."""
46+
# If not None, we got an initial value.
47+
await super().async_added_to_hass()
48+
49+
self._hass.bus.async_listen(self._attr_event_types[0], self._async_handle_event)
50+
51+
async def async_get_last_event_data(self):
52+
data = await super().async_get_last_event_data()
53+
return EventExtraStoredData.from_dict({
54+
"last_event_type": data.last_event_type,
55+
"last_event_attributes": dict_to_typed_dict(data.last_event_attributes),
56+
})
57+
58+
@callback
59+
def _async_handle_event(self, event) -> None:
60+
if (event.data is not None and "account_id" in event.data and event.data["account_id"] == self._account_id and "source" in event.data and event.data["source"] == self._discount_source):
61+
self._trigger_event(event.event_type, event.data)
62+
self.async_write_ha_state()

custom_components/octopus_energy/fan_club/next_discount.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ def _handle_coordinator_update(self) -> None:
8585
discount_information = None
8686
if target_discount is not None:
8787
discount_information = get_next_fan_club_discount_information(target_discount.discounts, next)
88+
else:
89+
_LOGGER.warning(f"Unable to find discount information for '{self._discount_source}'")
8890

8991
if discount_information is not None:
9092
self._attributes = {

custom_components/octopus_energy/fan_club/previous_discount.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ def _handle_coordinator_update(self) -> None:
8585
discount_information = None
8686
if target_discount is not None:
8787
discount_information = get_previous_fan_club_discount_information(target_discount.discounts, previous)
88+
else:
89+
_LOGGER.warning(f"Unable to find discount information for '{self._discount_source}'")
8890

8991
if discount_information is not None:
9092
self._attributes = {

0 commit comments

Comments
 (0)