Skip to content

Commit a7fb153

Browse files
committed
Add config options to set min event duration
1 parent b6e146c commit a7fb153

File tree

14 files changed

+166
-136
lines changed

14 files changed

+166
-136
lines changed

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Sign-up for a free Luno wallet using [this invite link](http://www.luno.com/invi
3030

3131
### Bitcoin
3232
`3EGnQKKbF6AijqW9unyBuW8YeEscY5wMSE`
33-
<img width="200" alt="image" src="img_9.png">
33+
<img width="200" alt="Bitcoin address: 3EGnQKKbF6AijqW9unyBuW8YeEscY5wMSE" src="img_9.png">
3434

3535

3636
# Manual Install
@@ -149,6 +149,5 @@ rest_command:
149149
content_type: "application/json; charset=utf-8"
150150
verify_ssl: true
151151
```
152-
- [Load Shedding (Start)](examples/automation3.yaml)
153-
- [Load Shedding (End)](examples/automation4.yaml)
152+
- [Load Shedding (Start/End)](examples/automation3.yaml)
154153
</details>

custom_components/load_shedding/__init__.py

Lines changed: 42 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
ATTR_STAGE,
4545
ATTR_START_TIME,
4646
CONF_AREAS,
47+
CONF_MIN_EVENT_DURATION,
4748
DEFAULT_SCAN_INTERVAL,
4849
DOMAIN,
4950
MANUFACTURER,
@@ -61,29 +62,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
6162
return True
6263

6364

64-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
65+
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
6566
"""Set up LoadShedding as config entry."""
6667
if not hass.data.get(DOMAIN):
6768
hass.data.setdefault(DOMAIN, {})
6869

6970
sepush: SePush = None
70-
if api_key := entry.options.get(CONF_API_KEY):
71+
if api_key := config_entry.options.get(CONF_API_KEY):
7172
sepush: SePush = SePush(token=api_key)
7273
if not sepush:
7374
return False
7475

7576
stage_coordinator = LoadSheddingStageCoordinator(hass, sepush)
7677
stage_coordinator.update_interval = timedelta(
77-
seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
78+
seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
7879
)
7980

8081
area_coordinator = LoadSheddingAreaCoordinator(
8182
hass, sepush, stage_coordinator=stage_coordinator
8283
)
8384
area_coordinator.update_interval = timedelta(
84-
seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
85+
seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
8586
)
86-
for conf in entry.options.get(CONF_AREAS, {}).values():
87+
for conf in config_entry.options.get(CONF_AREAS, {}).values():
8788
area = Area(
8889
id=conf.get(CONF_ID),
8990
name=conf.get(CONF_NAME),
@@ -95,18 +96,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
9596
quota_coordinator = LoadSheddingQuotaCoordinator(hass, sepush)
9697
quota_coordinator.update_interval = timedelta(seconds=QUOTA_UPDATE_INTERVAL)
9798

98-
hass.data[DOMAIN][entry.entry_id] = {
99+
hass.data[DOMAIN][config_entry.entry_id] = {
99100
ATTR_STAGE: stage_coordinator,
100101
ATTR_AREA: area_coordinator,
101102
ATTR_QUOTA: quota_coordinator,
102103
}
103104

104-
entry.async_on_unload(entry.add_update_listener(update_listener))
105+
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
105106

106107
await stage_coordinator.async_config_entry_first_refresh()
107108
await area_coordinator.async_config_entry_first_refresh()
108109
await quota_coordinator.async_config_entry_first_refresh()
109-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
110+
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
110111

111112
return True
112113

@@ -119,14 +120,14 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
119120
return unload_ok
120121

121122

122-
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry):
123+
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
123124
"""Reload config entry."""
124-
await hass.config_entries.async_reload(entry.entry_id)
125+
await hass.config_entries.async_reload(config_entry.entry_id)
125126

126127

127-
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> bool:
128+
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
128129
"""Update listener."""
129-
return await hass.config_entries.async_reload(entry.entry_id)
130+
return await hass.config_entries.async_reload(config_entry.entry_id)
130131

131132

132133
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
@@ -288,17 +289,17 @@ async def _async_update_data(self) -> dict:
288289
return self.data
289290

290291
async def async_update_area(self) -> dict:
291-
"""Retrieve schedule data."""
292-
areas_stage_schedules: dict = {}
292+
"""Retrieve area data."""
293+
area_id_data: dict = {}
293294

294295
for area in self.areas:
295-
# Get forecast for area
296-
events = []
297296
try:
298297
esp = await self.hass.async_add_executor_job(self.sepush.area, area.id)
299298
except SePushError as err:
300299
raise UpdateFailed(err) from err
301300

301+
# Get events for area
302+
events = []
302303
for event in esp.get("events", {}):
303304
note = event.get("note")
304305
parts = str(note).split(" ")
@@ -318,7 +319,6 @@ async def async_update_area(self) -> dict:
318319

319320
# Get schedule for area
320321
stage_schedule = {}
321-
sast = timezone(timedelta(hours=+2), "SAST")
322322
for day in esp.get("schedule", {}).get("days", []):
323323
date = datetime.strptime(day.get("date"), "%Y-%m-%d")
324324
stage_timeslots = day.get("stages", [])
@@ -328,30 +328,8 @@ async def async_update_area(self) -> dict:
328328
stage_schedule[stage] = []
329329
for timeslot in timeslots:
330330
start_str, end_str = timeslot.strip().split("-")
331-
start = (
332-
datetime.strptime(start_str, "%H:%M")
333-
.replace(
334-
year=date.year,
335-
month=date.month,
336-
day=date.day,
337-
second=0,
338-
microsecond=0,
339-
tzinfo=sast,
340-
)
341-
.astimezone(timezone.utc)
342-
)
343-
end = (
344-
datetime.strptime(end_str, "%H:%M")
345-
.replace(
346-
year=date.year,
347-
month=date.month,
348-
day=date.day,
349-
second=0,
350-
microsecond=0,
351-
tzinfo=sast,
352-
)
353-
.astimezone(timezone.utc)
354-
)
331+
start = utc_dt(date, datetime.strptime(start_str, "%H:%M"))
332+
end = utc_dt(date, datetime.strptime(end_str, "%H:%M"))
355333
if end < start:
356334
end = end + timedelta(days=1)
357335
stage_schedule[stage].append(
@@ -362,12 +340,12 @@ async def async_update_area(self) -> dict:
362340
}
363341
)
364342

365-
areas_stage_schedules[area.id] = {
343+
area_id_data[area.id] = {
366344
ATTR_EVENTS: events,
367345
ATTR_SCHEDULE: stage_schedule,
368346
}
369347

370-
return areas_stage_schedules
348+
return area_id_data
371349

372350
async def async_area_forecast(self) -> None:
373351
"""Derive area forecast from planned stages and area schedule."""
@@ -379,8 +357,6 @@ async def async_area_forecast(self) -> None:
379357
eskom_stages = stages.get(eskom, {}).get(ATTR_PLANNED, [])
380358
cape_town_stages = stages.get(cape_town, {}).get(ATTR_PLANNED, [])
381359

382-
now = datetime.now(timezone.utc)
383-
384360
for area_id, data in self.data.items():
385361
stage_schedules = data.get(ATTR_SCHEDULE)
386362

@@ -402,9 +378,6 @@ async def async_area_forecast(self) -> None:
402378
start_time = timeslot.get(ATTR_START_TIME)
403379
end_time = timeslot.get(ATTR_END_TIME)
404380

405-
# if end_time < now:
406-
# continue
407-
408381
if start_time >= planned_end_time:
409382
continue
410383
if end_time <= planned_start_time:
@@ -425,6 +398,13 @@ async def async_area_forecast(self) -> None:
425398
if start_time == end_time:
426399
continue
427400

401+
# Minimum event duration
402+
min_event_dur = self.stage_coordinator.config_entry.options.get(
403+
CONF_MIN_EVENT_DURATION, 30
404+
) # minutes
405+
if end_time - start_time < timedelta(minutes=min_event_dur):
406+
continue
407+
428408
forecast.append(
429409
{
430410
ATTR_STAGE: planned_stage,
@@ -436,6 +416,20 @@ async def async_area_forecast(self) -> None:
436416
data[ATTR_FORECAST] = forecast
437417

438418

419+
def utc_dt(date: datetime, time: datetime) -> datetime:
420+
"""Given a date and time in SAST, this function returns a datetime object in UTC"""
421+
sast = timezone(timedelta(hours=+2), "SAST")
422+
423+
return time.replace(
424+
year=date.year,
425+
month=date.month,
426+
day=date.day,
427+
second=0,
428+
microsecond=0,
429+
tzinfo=sast,
430+
).astimezone(timezone.utc)
431+
432+
439433
class LoadSheddingQuotaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
440434
"""Class to manage fetching LoadShedding Quota."""
441435

custom_components/load_shedding/config_flow.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
CONF_ADD_AREA,
2626
CONF_DELETE_AREA,
2727
CONF_MULTI_STAGE_EVENTS,
28+
CONF_MIN_EVENT_DURATION,
2829
CONF_SETUP_API,
2930
)
3031

@@ -271,6 +272,10 @@ async def async_step_init(
271272
CONF_MULTI_STAGE_EVENTS,
272273
default=self.opts.get(CONF_MULTI_STAGE_EVENTS, False),
273274
): bool,
275+
vol.Optional(
276+
CONF_MIN_EVENT_DURATION,
277+
default=self.opts.get(CONF_MIN_EVENT_DURATION, 30),
278+
): int,
274279
}
275280
)
276281

@@ -282,6 +287,7 @@ async def async_step_init(
282287
# if user_input.get(CONF_ACTION) == CONF_DELETE_AREA:
283288
# return await self.async_step_delete_area()
284289
self.opts[CONF_MULTI_STAGE_EVENTS] = user_input.get(CONF_MULTI_STAGE_EVENTS)
290+
self.opts[CONF_MIN_EVENT_DURATION] = user_input.get(CONF_MIN_EVENT_DURATION)
285291
return self.async_create_entry(title=NAME, data=self.opts)
286292

287293
return self.async_show_form(

custom_components/load_shedding/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
CONF_DELETE_AREA = "delete_area"
3030
CONF_SETUP_API = "setup_api"
3131
CONF_MULTI_STAGE_EVENTS = "multi_stage_events"
32+
CONF_MIN_EVENT_DURATION = "min_event_duration"
3233
CONF_API_KEY: Final = "api_key"
3334
CONF_AREA: Final = "area"
3435
CONF_AREAS: Final = "areas"

custom_components/load_shedding/sensor.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,14 @@ def extra_state_attributes(self) -> dict[str, list, Any]:
176176
data[ATTR_PLANNED].append(planned)
177177

178178
cur_stage = Stage.NO_LOAD_SHEDDING
179-
if planned := data[ATTR_PLANNED]:
179+
180+
planned = []
181+
if ATTR_PLANNED in data:
182+
planned = data[ATTR_PLANNED]
180183
cur_stage = planned[0].get(ATTR_STAGE, Stage.UNKNOWN)
181184

182-
attrs = get_sensor_attrs(data[ATTR_PLANNED], cur_stage)
183-
attrs[ATTR_PLANNED] = data[ATTR_PLANNED]
185+
attrs = get_sensor_attrs(planned, cur_stage)
186+
attrs[ATTR_PLANNED] = planned
184187
attrs[ATTR_LAST_UPDATE] = self.coordinator.last_update
185188
attrs = clean(attrs)
186189

@@ -238,7 +241,7 @@ def native_value(self) -> StateType:
238241
events = self.data.get(ATTR_FORECAST, [])
239242

240243
if not events:
241-
return self._attr_native_value
244+
return STATE_OFF
242245

243246
now = datetime.now(timezone.utc)
244247

@@ -285,8 +288,12 @@ def extra_state_attributes(self) -> dict[str, list, Any]:
285288

286289
data[ATTR_FORECAST].append(forecast)
287290

288-
attrs = get_sensor_attrs(data[ATTR_FORECAST])
289-
attrs[ATTR_FORECAST] = data[ATTR_FORECAST]
291+
forecast = []
292+
if ATTR_FORECAST in data:
293+
forecast = data[ATTR_FORECAST]
294+
295+
attrs = get_sensor_attrs(forecast)
296+
attrs[ATTR_FORECAST] = forecast
290297
attrs[ATTR_LAST_UPDATE] = self.coordinator.last_update
291298
attrs = clean(attrs)
292299

custom_components/load_shedding/strings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@
4949
"data": {
5050
"add_area": "Add area",
5151
"delete_area": "Remove area",
52-
"setup_api": "Configure API"
52+
"setup_api": "Configure API",
53+
"multi_stage_events": "Multi-stage events",
54+
"min_event_duration": "Min. event duration (mins)"
5355
}
5456
},
5557
"sepush": {

custom_components/load_shedding/translations/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"data": {
5858
"add_area": "Add area",
5959
"delete_area": "Remove area",
60+
"min_event_duration": "Min. event duration (mins)",
6061
"multi_stage_events": "Multi-stage events",
6162
"setup_api": "Configure API"
6263
},

examples/automation1.yaml

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
alias: Load Shedding (Stage)
2-
description: ''
2+
description: ""
33
trigger:
44
- platform: state
55
entity_id:
6-
- sensor.load_shedding_stage
7-
condition:
8-
- condition: template
9-
value_template: >-
10-
{{ trigger.from_state.state != 'unavailable' and trigger.to_state.state != 'unavailable' }}
6+
- sensor.load_shedding_stage_eskom
7+
attribute: stage
8+
condition: []
119
action:
10+
- service: notify.mobile_app_nokia_8_sirocco
11+
data:
12+
title: Load Shedding
13+
message: |-
14+
{% if is_state_attr(trigger.entity_id, "stage", 0) %}
15+
Suspended
16+
{% else %}
17+
{{ states(trigger.entity_id) }}
18+
{% endif %}
19+
enabled: true
1220
- choose:
1321
- conditions:
1422
- condition: or
@@ -39,17 +47,29 @@ action:
3947
at: input_datetime.wake
4048
continue_on_timeout: false
4149
default: []
42-
- service: notify.mobile_app_nokia_8_sirocco
43-
data:
44-
title: Load Shedding
45-
message: '{{ states.sensor.load_shedding_stage.state }}'
4650
- service: tts.home_assistant_say
4751
data:
4852
entity_id: media_player.assistant_speakers
4953
cache: true
50-
message: >-
51-
{% if is_state("sensor.load_shedding_stage", "No Load Shedding") %} Load
52-
Shedding suspended {% else %} Load Shedding {{
53-
states.sensor.load_shedding_stage.state }} {% endif %}
54-
enabled: false
55-
mode: single
54+
message: |-
55+
Load Shedding {% if is_state_attr(trigger.entity_id, "stage", 0) %}
56+
Suspended
57+
{% else %}
58+
{{ states(trigger.entity_id) }}
59+
{% endif %}
60+
- delay:
61+
hours: 0
62+
minutes: 0
63+
seconds: 5
64+
milliseconds: 0
65+
- if:
66+
- condition: state
67+
entity_id: sensor.load_shedding_area_eskde_14_milnertoncityofcapetownwesterncape
68+
state: "on"
69+
then:
70+
- service: tts.home_assistant_say
71+
data:
72+
message: Load shedding imminent!
73+
entity_id: media_player.assistant_speakers
74+
cache: true
75+
mode: single

0 commit comments

Comments
 (0)