Skip to content

Commit d139d69

Browse files
authored
Fix DTEK outage ranges parsing (#50)
2 parents 7f47d94 + 23ae48c commit d139d69

File tree

4 files changed

+164
-142
lines changed

4 files changed

+164
-142
lines changed

custom_components/svitlo_yeah/api/dtek/base.py

Lines changed: 95 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,24 @@
1414
LOGGER = logging.getLogger(__name__)
1515

1616

17-
def _parse_group_hours( # noqa: PLR0912
17+
def _parse_group_hours(
1818
group_hours: dict[str, str],
1919
) -> list[tuple[datetime.time, datetime.time]]:
2020
"""
2121
Parse group hours data into a list of outage time ranges.
2222
2323
'GPV1.1': {
24-
'1': 'yes',
25-
...
26-
'12': 'yes',
27-
'13': 'second',
28-
'14': 'no',
29-
'15': 'no',
30-
'16': 'no',
31-
'17': 'first',
32-
'18': 'yes',
33-
...
34-
'24': 'yes',
24+
'1': 'yes',
25+
...
26+
'12': 'yes',
27+
'13': 'second',
28+
'14': 'no',
29+
'15': 'no',
30+
'16': 'no',
31+
'17': 'first',
32+
'18': 'yes',
33+
...
34+
'24': 'yes',
3535
},
3636
Supports two hour formats:
3737
- Hours starting from '1' (corresponding to 0:00) up to '24'
@@ -40,122 +40,101 @@ def _parse_group_hours( # noqa: PLR0912
4040
ranges = []
4141
outage_start = None
4242

43-
# Check if '0' key is present to determine hour format
44-
# If '0' exists, hours are 0-23 (keys '0' to '23')
45-
# If no '0', hours are 1-24 (keys '1' to '24')
46-
if "0" in group_hours:
47-
# Parse 24-hour format starting from 0
48-
start_n, end_n = 0, 24
49-
else:
50-
# Parse 24-hour format starting from 1
51-
start_n, end_n = 1, 25
52-
53-
for n in range(start_n, end_n):
54-
# Calculate actual hour (0-23) from n
55-
if "0" in group_hours:
56-
hour = n # '0' key -> hour 0, '1' key -> hour 1, etc.
57-
key = str(n)
58-
else:
59-
hour = n - 1 # '1' key -> hour 0, '2' key -> hour 1, etc.
60-
key = str(n)
43+
hours_range = range(24)
44+
get_key = lambda h: str(h + 1) # noqa: E731
45+
if "0" in group_hours: # 0-23 or 1-24 hour format
46+
get_key = str
47+
48+
def safe_time(hour: int, minute: int = 0) -> datetime.time:
49+
"""Create datetime.time handling hour 24 as midnight (0:00)."""
50+
if hour >= 24: # noqa: PLR2004
51+
return datetime.time(0, minute)
52+
return datetime.time(hour, minute)
6153

62-
# Get status for this hour slot
54+
for hour in hours_range:
55+
key = get_key(hour)
6356
status = group_hours.get(key, "yes")
6457

58+
prev_key = get_key(hour - 1) if hour > 0 else None
59+
next_key = get_key(hour + 1) if hour < 23 else None # noqa: PLR2004
60+
61+
prev_status = group_hours.get(prev_key, "yes") if prev_key else "yes"
62+
next_status = group_hours.get(next_key, "yes") if next_key else "yes"
63+
6564
if status == "yes":
66-
# Power is on - close any open outage period
6765
if outage_start is not None:
68-
ranges.append((outage_start, datetime.time(hour, 0)))
66+
ranges.append((outage_start, safe_time(hour)))
67+
outage_start = None
68+
elif status in ("second", "msecond"):
69+
if prev_status == "yes" or (
70+
prev_status in ("first", "mfirst") and outage_start is None
71+
):
72+
# Start new outage at 30 minutes
73+
outage_start = safe_time(hour, 30)
74+
elif outage_start is None:
75+
# Continue from previous outage, start at beginning of hour
76+
outage_start = safe_time(hour)
77+
elif status in ("first", "mfirst"):
78+
if outage_start is None:
79+
outage_start = safe_time(hour)
80+
if next_status == "yes" or (next_status in ("second", "msecond")):
81+
# End outage at 30 minutes
82+
ranges.append((outage_start, safe_time(hour, 30)))
6983
outage_start = None
70-
else: # "no", "first", "mfirst", "second", "msecond" - all indicate outages
71-
# Power is out - start or continue outage period
72-
if outage_start is None: # Start new outage at appropriate time
73-
if status in ("second", "msecond"):
74-
outage_start = datetime.time(hour, 30) # Start at half-hour
75-
elif status in ("first", "mfirst", "no"):
76-
outage_start = datetime.time(hour, 0) # Start at top of hour
77-
78-
# Handle end times for 30-minute slots - but only end if next hour is "yes"
79-
if status in ("first", "mfirst"):
80-
next_status = group_hours.get(str(n + 1), "yes")
81-
if next_status == "yes": # Only end if next hour is "yes"
82-
ranges.append((outage_start, datetime.time(hour, 30)))
83-
outage_start = None
84-
85-
# Close any remaining open outage period at end of day
84+
elif status in ("no", "maybe") and outage_start is None:
85+
outage_start = safe_time(hour)
86+
87+
# Close any remaining outage at end of day
8688
if outage_start is not None:
8789
ranges.append((outage_start, datetime.time(23, 59, 59)))
8890

8991
return ranges
9092

9193

92-
def _parse_preset_group_hours( # noqa: PLR0912
93-
group_hours: dict[str, str],
94+
def _merge_ranges(
95+
ranges: list[tuple[datetime.time, datetime.time]],
9496
) -> list[tuple[datetime.time, datetime.time]]:
9597
"""
96-
Parse preset group hours data into a list of scheduled outage time ranges.
97-
98-
Based on time_type mapping:
99-
- "yes": no outage
100-
- "maybe", "no", "first", "second", "mfirst", "msecond": scheduled outages
98+
Merge adjacent or overlapping time ranges.
10199
102-
Handles 30-minute precision for "first"/"second"/"mfirst"/"msecond".
103-
"""
104-
ranges = []
105-
outage_start = None
100+
Args:
101+
ranges: List of time ranges to merge
106102
107-
# Check if '0' key is present to determine hour format
108-
# If '0' exists, hours are 0-23 (keys '0' to '23')
109-
# If no '0', hours are 1-24 (keys '1' to '24')
110-
if "0" in group_hours:
111-
# Parse 24-hour format starting from 0
112-
start_n, end_n = 0, 24
113-
else:
114-
# Parse 24-hour format starting from 1
115-
start_n, end_n = 1, 25
116-
117-
# Hours are 1-24 (keys '1' to '24')
118-
for n in range(start_n, end_n):
119-
hour = n - 1 # '1' key -> hour 0, '2' key -> hour 1, etc.
120-
key = str(n)
121-
122-
# Get status for this hour slot
123-
status = group_hours.get(key, "yes")
103+
Returns:
104+
List of merged time ranges
124105
125-
if status == "yes":
126-
# Power is on - close any open outage period
127-
if outage_start is not None:
128-
ranges.append((outage_start, datetime.time(hour, 0)))
129-
outage_start = None
130-
else: # All non-"yes" values indicate scheduled outages
131-
# Power is scheduled to be out - start or continue outage period
132-
if outage_start is None: # Start new outage at appropriate time
133-
if status in ("second", "msecond"):
134-
outage_start = datetime.time(hour, 30) # Start at half-hour
135-
elif status in ("first", "mfirst"):
136-
outage_start = datetime.time(hour, 0) # Start at top of hour
137-
else: # "maybe", "no"
138-
outage_start = datetime.time(hour, 0) # Start at top of hour
139-
140-
# Handle end times for 30-minute slots
141-
if status in ("first", "mfirst"):
142-
# End at hour:30
143-
ranges.append((outage_start, datetime.time(hour, 30)))
144-
outage_start = None
145-
elif status in ("second", "msecond"):
146-
# End at hour:59:59 (next slot will determine continuation)
147-
if outage_start is None:
148-
# This is just the second half - start was previous slot
149-
ranges.append(
150-
(datetime.time(hour, 30), datetime.time(hour, 59, 59))
151-
)
152-
# If outage_start was set, continue to next slot
106+
"""
107+
if not ranges:
108+
return []
109+
110+
# Sort ranges by start time
111+
sorted_ranges = sorted(ranges, key=lambda x: x[0])
112+
113+
merged = []
114+
current_start, current_end = sorted_ranges[0]
115+
116+
for start, end in sorted_ranges[1:]:
117+
# Check if ranges are adjacent or overlapping
118+
# For time ranges, we consider them adjacent if start <= current_end
119+
if start <= current_end:
120+
# Ranges overlap or are adjacent, merge them
121+
# If end is 59:59, use the next hour boundary
122+
if end.minute == 59 and end.second == 59: # noqa: PLR2004
123+
if end.hour < 23: # noqa: PLR2004
124+
current_end = datetime.time(end.hour + 1)
125+
else:
126+
current_end = datetime.time(23, 59, 59)
127+
else:
128+
current_end = max(current_end, end)
129+
else:
130+
# No overlap, add current range and start a new one
131+
merged.append((current_start, current_end))
132+
current_start, current_end = start, end
153133

154-
# Close any remaining open outage period at end of day
155-
if outage_start is not None:
156-
ranges.append((outage_start, datetime.time(23, 59, 59)))
134+
# Add the last range
135+
merged.append((current_start, current_end))
157136

158-
return ranges
137+
return merged
159138

160139

161140
class DtekAPIBase:
@@ -219,7 +198,9 @@ def get_events(
219198
second=0,
220199
microsecond=0,
221200
)
222-
if end_time.hour == 23 and end_time.minute == 59: # noqa: PLR2004
201+
if (end_time.hour == 23 and end_time.minute == 59) or ( # noqa: PLR2004
202+
end_time.hour == 0 and end_time.minute == 0
203+
):
223204
event_end = (day_dt + datetime.timedelta(days=1)).replace(
224205
hour=0,
225206
minute=0,
@@ -295,7 +276,7 @@ def get_scheduled_events(
295276
if not day_data:
296277
continue
297278

298-
time_ranges = _parse_preset_group_hours(day_data)
279+
time_ranges = _parse_group_hours(day_data)
299280

300281
for start_time, end_time in time_ranges:
301282
event_start = day_start.replace(
@@ -305,7 +286,9 @@ def get_scheduled_events(
305286
microsecond=0,
306287
)
307288

308-
if end_time.hour == 23 and end_time.minute == 59: # noqa: PLR2004
289+
if (end_time.hour == 23 and end_time.minute == 59) or ( # noqa: PLR2004
290+
end_time.hour == 0 and end_time.minute == 0
291+
):
309292
event_end = day_end
310293
else:
311294
event_end = day_start.replace(

custom_components/svitlo_yeah/calendar.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
import datetime
44
import logging
55

6-
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
6+
from homeassistant.components.calendar import (
7+
CalendarEntity,
8+
CalendarEntityDescription,
9+
CalendarEvent,
10+
)
711
from homeassistant.config_entries import ConfigEntry
812
from homeassistant.core import HomeAssistant
9-
from homeassistant.helpers.entity import EntityDescription
1013
from homeassistant.helpers.entity_platform import AddEntitiesCallback
14+
from homeassistant.util import slugify
1115

1216
from .coordinator.coordinator import IntegrationCoordinator
1317
from .entity import IntegrationEntity
@@ -41,14 +45,16 @@ def __init__(
4145
"""Initialize the calendar entity."""
4246
super().__init__(coordinator)
4347

44-
self.entity_id = (
45-
"calendar."
46-
f"_{coordinator.region_name}"
47-
f"_{coordinator.provider_name}"
48-
f"_{coordinator.group}"
49-
"_planned_outages"
50-
)
51-
self.entity_description = EntityDescription(
48+
entity_id_parts = [
49+
f"{coordinator.region_name}" if coordinator.region_name else "",
50+
f"_{coordinator.provider_name}" if coordinator.provider_name else "",
51+
f"_{coordinator.group}",
52+
"_planned_outages",
53+
]
54+
entity_id_base = "".join(entity_id_parts)
55+
entity_id_base = slugify(entity_id_base.strip("_"))
56+
self.entity_id = f"calendar.{entity_id_base}"
57+
self.entity_description = CalendarEntityDescription(
5258
key="calendar",
5359
name="Calendar",
5460
translation_key="calendar",
@@ -82,14 +88,16 @@ def __init__(
8288
"""Initialize the calendar entity."""
8389
super().__init__(coordinator)
8490

85-
self.entity_id = (
86-
"calendar."
87-
f"_{coordinator.region_name}"
88-
f"_{coordinator.provider_name}"
89-
f"_{coordinator.group}"
90-
"_scheduled_outages"
91-
)
92-
self.entity_description = EntityDescription(
91+
entity_id_parts = [
92+
f"{coordinator.region_name}" if coordinator.region_name else "",
93+
f"_{coordinator.provider_name}" if coordinator.provider_name else "",
94+
f"_{coordinator.group}",
95+
"_scheduled_outages",
96+
]
97+
entity_id_base = "".join(entity_id_parts)
98+
entity_id_base = slugify(entity_id_base.strip("_"))
99+
self.entity_id = f"calendar.{entity_id_base}"
100+
self.entity_description = CalendarEntityDescription(
93101
key="scheduled_calendar",
94102
name="Scheduled Calendar",
95103
translation_key="scheduled_calendar",

tests/test_calendar.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_calendar_initialization(self, coordinator):
3636
calendar = PlannedOutagesCalendar(coordinator)
3737

3838
assert calendar.coordinator == coordinator
39-
expected_entity_id = "calendar._kyiv_dtek_1_1_planned_outages"
39+
expected_entity_id = "calendar.kyiv_dtek_1_1_planned_outages"
4040
assert calendar.entity_id == expected_entity_id
4141
assert calendar.entity_description.name == "Calendar"
4242
assert calendar.entity_description.translation_key == "calendar"
@@ -82,7 +82,7 @@ def test_calendar_initialization(self, coordinator):
8282
calendar = ScheduledOutagesCalendar(coordinator)
8383

8484
assert calendar.coordinator == coordinator
85-
expected_entity_id = "calendar._kyiv_dtek_1_1_scheduled_outages"
85+
expected_entity_id = "calendar.kyiv_dtek_1_1_scheduled_outages"
8686
assert calendar.entity_id == expected_entity_id
8787
assert calendar.entity_description.name == "Scheduled Calendar"
8888
assert calendar.entity_description.translation_key == "scheduled_calendar"

0 commit comments

Comments
 (0)