From 443232b7811ccf3db0587d2ebeb6611665112951 Mon Sep 17 00:00:00 2001
From: yurii <jurkash@gmail.com>
Date: Sun, 14 Jul 2024 10:44:02 +0300
Subject: [PATCH] Fix API integration

---
 .devcontainer.json                            |  34 ++++++
 README.md                                     |  35 +-----
 custom_components/loe_outages/__init__.py     |   4 +-
 custom_components/loe_outages/api.py          | 104 +++++++++++-------
 custom_components/loe_outages/calendar.py     |  25 ++++-
 custom_components/loe_outages/config_flow.py  |   8 +-
 custom_components/loe_outages/const.py        |   9 +-
 custom_components/loe_outages/coordinator.py  | 103 +++++++++--------
 custom_components/loe_outages/entity.py       |   8 +-
 custom_components/loe_outages/manifest.json   |  10 +-
 custom_components/loe_outages/models.py       |  94 ++++++++++++++++
 custom_components/loe_outages/sensor.py       |   8 +-
 .../loe_outages/translations/en.json          |   6 +-
 .../loe_outages/translations/uk.json          |  12 +-
 icons/icon.png                                | Bin 0 -> 6202 bytes
 requirements.txt                              |   2 -
 16 files changed, 303 insertions(+), 159 deletions(-)
 create mode 100644 .devcontainer.json
 create mode 100644 custom_components/loe_outages/models.py
 create mode 100644 icons/icon.png

diff --git a/.devcontainer.json b/.devcontainer.json
new file mode 100644
index 0000000..0cd0cb1
--- /dev/null
+++ b/.devcontainer.json
@@ -0,0 +1,34 @@
+{
+    "name": "ha-loe-outages",
+    "image": "mcr.microsoft.com/devcontainers/python:3.12-bullseye",
+    "postCreateCommand": "scripts/setup",
+    "forwardPorts": [
+        8123
+    ],
+    "portsAttributes": {
+        "8123": {
+            "label": "Home Assistant",
+            "onAutoForward": "notify"
+        }
+    },
+    "customizations": {
+        "vscode": {
+            "extensions": [
+                "ms-python.python",
+                "github.vscode-pull-request-github",
+                "ryanluker.vscode-coverage-gutters",
+                "ms-python.vscode-pylance",
+                "charliermarsh.ruff",
+                "tamasfe.even-better-toml"
+            ],
+            "settings": {
+                "python.pythonPath": "/usr/bin/python3",
+                "[python]": {
+                    "editor.formatOnSave": true,
+                    "editor.defaultFormatter": "charliermarsh.ruff"
+                }
+            }
+        }
+    },
+    "remoteUser": "vscode"
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index ef4e766..c163303 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,3 @@
-<!-- ![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua/)
--->
 ![HA LOE Outages Logo](./icons/logo.svg) 
 
 # ⚡️ HA LOE Outages
@@ -36,45 +34,15 @@ If the button doesn't work, follow these steps to add the repository manually:
 
 1. Go to **HACS** → **Integrations** → **...** (top right) → **Custom repositories**
 2. Click **Add**
-3. Enter `https://github.com/jurakash/ha-loe-outages` in the **URL** field
+3. Enter `https://github.com/jurkash/ha-loe-outages` in the **URL** field
 4. Choose **Integration** as the **Category**
 5. **LOE Outages** will appear in the list of available integrations. Install it as usual.
 
-<!-- 
-## Usage
-
-This integration can be configured via the UI. On the **Devices and Services** page, click **Add Integration** and search for **LOE Outages**.
-
-Find your group by visiting the [LOE][loe] website and entering your address in the search bar. Select your group in the configuration.
-
-![Configuration flow](https://github.com/jurkash/ha-loe-outages/assets/3459374/e8bfde50-fcbe-45c3-b448-b451b0ac3bcd)
-
-After configuring, add the integration to your dashboard to view the next planned outages.
-
-![Device page](https://github.com/jurkash/ha-loe-outages/assets/3459374/df628647-fd2a-455d-9d08-0d1542b67e41)
-
-The integration also provides a calendar view of planned outages, which can be added to your dashboard via the [Calendar card][calendar-card].
-
-![Calendar view](https://github.com/jurkash/ha-loe-outages/assets/3459374/b09c4db3-d0a0-4e06-8dd9-3f4a59f1d63e)
-
-Here’s an example of a dashboard using this integration:
-
-
-![Dashboard example](https://github.com/jurkash/ha-loe-outages/assets/3459374/26c75595-8984-4a9f-893a-e4b6d838b7f2) -->
-
-<!-- ## Development
-
-Interested in contributing to the project?
-
-First, thank you! Check out the [contributing guideline](./CONTRIBUTING.md) for more information. -->
-
-
 ## License
 
 MIT © [Yurii Shunkin][jurkash]
 
 <!-- Badges -->
-
 [gh-release-url]: https://github.com/jurkash/ha-loe-outages/releases/latest
 [gh-release-image]: https://img.shields.io/github/v/release/jurkash/ha-loe-outages?style=flat-square
 [gh-downloads-url]: https://github.com/jurkash/ha-loe-outages/releases
@@ -91,7 +59,6 @@ MIT © [Yurii Shunkin][jurkash]
 [twitter-image]: https://img.shields.io/badge/twitter-%40jurkashok-00ACEE.svg?style=flat-square
 
 <!-- References -->
-
 [loe]: https://poweron.loe.lviv.ua/
 [home-assistant]: https://www.home-assistant.io/
 [jurkash]: https://github.com/jurkash
diff --git a/custom_components/loe_outages/__init__.py b/custom_components/loe_outages/__init__.py
index a11a18e..774fbe2 100644
--- a/custom_components/loe_outages/__init__.py
+++ b/custom_components/loe_outages/__init__.py
@@ -1,4 +1,4 @@
-"""Init file for LOE Outages integration."""
+"""Init file for Loe Outages integration."""
 
 from __future__ import annotations
 
@@ -38,4 +38,4 @@ async def async_unload_entry(
     """Handle removal of an entry."""
     LOGGER.info("Unload entry: %s", entry)
     """Unload a config entry."""
-    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
\ No newline at end of file
diff --git a/custom_components/loe_outages/api.py b/custom_components/loe_outages/api.py
index d48ef6b..2ddf33c 100644
--- a/custom_components/loe_outages/api.py
+++ b/custom_components/loe_outages/api.py
@@ -1,56 +1,82 @@
-"""API for LOE outages."""
+"""API for Loe outages."""
 
-import datetime
 import logging
-import requests
-
-from .const import API_BASE_URL
+import aiohttp
+from .models import OutageSchedule
+import datetime
+import pytz
 
 LOGGER = logging.getLogger(__name__)
 
 
 class LoeOutagesApi:
-    """Class to interact with the API for LOE outages."""
+    """Class to interact with API for Loe outages."""
+
+    schedule: list[OutageSchedule]
 
     def __init__(self, group: str) -> None:
-        """Initialize the LOE OutagesApi."""
+        """Initialize the LoeOutagesApi."""
         self.group = group
-        self.api_base_url = API_BASE_URL
-
-    def fetch_schedule(self) -> list[dict]:
-        """Fetch outages from the API."""
-        url = f"{self.api_base_url}/Schedule/latest"
-        response = requests.get(url)
-        response.raise_for_status()
-        data = response.json()
-        for group in data["groups"]:
-            if group["id"] == self.group:
-                return group["intervals"]
-        return []
-
-    def get_current_event(self, at: datetime.datetime) -> dict:
+        self.schedule = []
+
+    async def async_fetch_json_from_endpoint(self) -> dict:
+        """Fetch outages from the async API endpoint."""
+        url = "https://lps.yuriishunkin.com/api/Schedule/latest"
+        async with aiohttp.ClientSession() as session:
+            async with session.get(url) as response:
+                if response.status == 200:
+                    data = await response.json()
+                    return data
+                else:
+                    LOGGER.error(f"Failed to fetch schedule: {response.status}")
+                    return None
+
+    async def async_fetch_schedule(self) -> None:
+        """Fetch outages from the JSON response."""
+        schedule_data = await self.async_fetch_json_from_endpoint()
+        schedule = OutageSchedule.from_dict(schedule_data)
+        if len(self.schedule) == 0:
+            self.schedule.append(schedule)
+            return
+
+        if self.schedule[-1].id != schedule.id:
+            if self.schedule[-1].dateString == schedule.dateString:
+                self.schedule.remove(self.schedule[-1])
+            self.schedule.append(schedule)
+
+    def get_current_event(self, at: datetime) -> dict:
         """Get the current event."""
-        schedule = self.fetch_schedule()
-        current_event = None
-        for event in schedule:
-            start = datetime.datetime.fromisoformat(event["startTime"])
-            end = datetime.datetime.fromisoformat(event["endTime"])
-            if start <= at <= end:
-                current_event = event
-                break
-        return current_event
+        if not self.schedule:
+            return None
+        
+        twoDaysBefore = datetime.datetime.now() + datetime.timedelta(days=-2)
+        for schedule in reversed(self.schedule):
+            if schedule.date < twoDaysBefore.astimezone(pytz.UTC):
+                return None
+            
+            events_at = schedule.get_current_event(self.group, at)
+            if not events_at:
+                return None
+            return events_at  # return only the first event
 
     def get_events(
         self,
         start_date: datetime.datetime,
         end_date: datetime.datetime,
     ) -> list[dict]:
-        """Get all events between start_date and end_date."""
-        schedule = self.fetch_schedule()
-        events = []
-        for event in schedule:
-            start = datetime.datetime.fromisoformat(event["startTime"])
-            end = datetime.datetime.fromisoformat(event["endTime"])
-            if start_date <= start <= end_date or start_date <= end <= end_date:
-                events.append(event)
-        return events
+        """Get all events."""
+        if not self.schedule:
+            return []
+        
+        result = []
+        twoDaysBeforeStart = start_date + datetime.timedelta(days=-2)
+        for schedule in reversed(self.schedule):
+            if schedule.date < twoDaysBeforeStart:
+                break
+
+            for interval in schedule.between(self.group, start_date, end_date):
+                result.append(interval)
+        
+        return result
+    
+   
\ No newline at end of file
diff --git a/custom_components/loe_outages/calendar.py b/custom_components/loe_outages/calendar.py
index 5182c28..a685fa0 100644
--- a/custom_components/loe_outages/calendar.py
+++ b/custom_components/loe_outages/calendar.py
@@ -1,4 +1,4 @@
-"""Calendar platform for LOE outages integration."""
+"""Calendar platform for Loe outages integration."""
 
 import datetime
 import logging
@@ -21,7 +21,7 @@ async def async_setup_entry(
     config_entry: ConfigEntry,
     async_add_entities: AddEntitiesCallback,
 ) -> None:
-    """Set up the LOE outages calendar platform."""
+    """Set up the Loe outages calendar platform."""
     LOGGER.debug("Setup new entry: %s", config_entry)
     coordinator: LoeOutagesCoordinator = config_entry.runtime_data
     async_add_entities([LoeOutagesCalendar(coordinator)])
@@ -52,7 +52,13 @@ def event(self) -> CalendarEvent | None:
         """Return the current or next upcoming event or None."""
         now = dt_utils.now()
         LOGGER.debug("Getting current event for %s", now)
-        return self.coordinator.get_event_at(now)
+        interval = self.coordinator.get_event_at(now)
+        return CalendarEvent(
+            summary=interval.state,
+            start=interval.startTime,
+            end=interval.endTime,
+            description=interval.state,
+        )
 
     async def async_get_events(
         self,
@@ -62,4 +68,15 @@ async def async_get_events(
     ) -> list[CalendarEvent]:
         """Return calendar events within a datetime range."""
         LOGGER.debug('Getting all events between "%s" -> "%s"', start_date, end_date)
-        return self.coordinator.get_events_between(start_date, end_date)
+        intervals = self.coordinator.get_events_between(start_date, end_date)
+        events = [] 
+        
+        for interval in intervals:
+            event =  CalendarEvent(
+                summary=interval.state,
+                start=interval.startTime,
+                end=interval.endTime,
+                description=interval.state,
+            )
+            events.append(event)
+        return events
\ No newline at end of file
diff --git a/custom_components/loe_outages/config_flow.py b/custom_components/loe_outages/config_flow.py
index 6e12dc1..c93caf1 100644
--- a/custom_components/loe_outages/config_flow.py
+++ b/custom_components/loe_outages/config_flow.py
@@ -1,4 +1,4 @@
-"""Config flow for LOE Outages integration."""
+"""Config flow for Loe Outages integration."""
 
 import logging
 from typing import Any
@@ -52,7 +52,7 @@ def build_schema(config_entry: ConfigEntry) -> vol.Schema:
 
 
 class LoeOutagesOptionsFlow(OptionsFlow):
-    """Handle options flow for LOE Outages."""
+    """Handle options flow for Loe Outages."""
 
     def __init__(self, config_entry: ConfigEntry) -> None:
         """Initialize options flow."""
@@ -71,7 +71,7 @@ async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowRes
 
 
 class LoeOutagesConfigFlow(ConfigFlow, domain=DOMAIN):
-    """Handle a config flow for LOE Outages."""
+    """Handle a config flow for Loe Outages."""
 
     VERSION = 1
 
@@ -90,4 +90,4 @@ async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowRes
         return self.async_show_form(
             step_id="user",
             data_schema=build_schema(config_entry=None),
-        )
+        )
\ No newline at end of file
diff --git a/custom_components/loe_outages/const.py b/custom_components/loe_outages/const.py
index af434a2..403097f 100644
--- a/custom_components/loe_outages/const.py
+++ b/custom_components/loe_outages/const.py
@@ -1,9 +1,9 @@
-"""Constants for the LOE Outages integration."""
+"""Constants for the Loe Outages integration."""
 
 from typing import Final
 
 DOMAIN: Final = "loe_outages"
-NAME: Final = "LOE Outages"
+NAME: Final = "Loe Outages"
 
 # Configuration option
 CONF_GROUP: Final = "group"
@@ -17,10 +17,7 @@
 # Values
 STATE_ON: Final = "PowerOn"
 STATE_OFF: Final = "PowerOff"
-# Endpoint paths
-SCHEDULE_PATH = "https://lps.yuriishunkin.com/api/schedule/latest/{group}"
-API_BASE_URL = "https://lps.yuriishunkin.com/api"
-
 
 # Keys
 TRANSLATION_KEY_EVENT_OFF: Final = f"component.{DOMAIN}.common.electricity_off"
+TRANSLATION_KEY_EVENT_ON: Final = f"component.{DOMAIN}.common.electricity_on"
diff --git a/custom_components/loe_outages/coordinator.py b/custom_components/loe_outages/coordinator.py
index dda98e2..e03e537 100644
--- a/custom_components/loe_outages/coordinator.py
+++ b/custom_components/loe_outages/coordinator.py
@@ -1,10 +1,9 @@
-"""Coordinator for LOE outages integration."""
+"""Coordinator for Loe outages integration."""
 
 import datetime
 import logging
-import requests
 
-from homeassistant.components.calendar import CalendarEvent
+from .models import Interval
 from homeassistant.config_entries import ConfigEntry
 from homeassistant.core import HomeAssistant
 from homeassistant.helpers.translation import async_get_translations
@@ -18,14 +17,17 @@
     STATE_OFF,
     STATE_ON,
     TRANSLATION_KEY_EVENT_OFF,
+    TRANSLATION_KEY_EVENT_ON,
     UPDATE_INTERVAL,
 )
 
 LOGGER = logging.getLogger(__name__)
 
+TIMEFRAME_TO_CHECK = datetime.timedelta(hours=24)
+
 
 class LoeOutagesCoordinator(DataUpdateCoordinator):
-    """Class to manage fetching LOE outages data."""
+    """Class to manage fetching Loe outages data."""
 
     config_entry: ConfigEntry
 
@@ -51,6 +53,7 @@ def event_name_map(self) -> dict:
         """Return a mapping of event names to translations."""
         return {
             STATE_OFF: self.translations.get(TRANSLATION_KEY_EVENT_OFF),
+            STATE_ON: self.translations.get(TRANSLATION_KEY_EVENT_ON)
         }
 
     async def update_config(
@@ -69,13 +72,13 @@ async def update_config(
             LOGGER.debug("No group update necessary.")
 
     async def _async_update_data(self) -> None:
-        """Fetch data from the API."""
+        """Fetch data from API."""
         try:
-            # Fetch the schedule data from the API
-            return await self.hass.async_add_executor_job(self.api.fetch_schedule)
-        except requests.RequestException as err:
-            LOGGER.exception("Error fetching data for group %s", self.group)
-            msg = f"Error fetching data from API: {err}"
+            await self.async_fetch_translations()
+            return await self.api.async_fetch_schedule()
+        except FileNotFoundError as err:
+            LOGGER.exception("Cannot read file for group %s", self.group)
+            msg = f"File not found: {err}"
             raise UpdateFailed(msg) from err
 
     async def async_fetch_translations(self) -> None:
@@ -89,14 +92,30 @@ async def async_fetch_translations(self) -> None:
         )
         LOGGER.debug("Translations loaded: %s", self.translations)
 
+    def _get_next_event_of_type(self, state_type: str) -> Interval | None:
+        """Get the next event of a specific type."""
+        now = dt_utils.now()
+        # Sort events to handle multi-day spanning events correctly
+        next_events = sorted(
+            self.get_events_between(
+                now,
+                now + TIMEFRAME_TO_CHECK,
+                translate=False,
+            ),
+            key=lambda event: event.startTime,
+        )
+        LOGGER.debug("Next events: %s", next_events)
+        for event in next_events:
+            if self._event_to_state(event) == state_type and event.startTime > now:
+                return event
+        return None
+
     @property
     def next_outage(self) -> datetime.datetime | None:
         """Get the next outage time."""
-        next_events = self.get_next_events()
-        for event in next_events:
-            if self._event_to_state(event) == STATE_OFF:
-                return event.start
-        return None
+        event = self._get_next_event_of_type(STATE_OFF)
+        LOGGER.debug("Next outage: %s", event)
+        return event.startTime if event else None
 
     @property
     def next_connectivity(self) -> datetime.datetime | None:
@@ -105,14 +124,12 @@ def next_connectivity(self) -> datetime.datetime | None:
         current_event = self.get_event_at(now)
         # If current event is OFF, return the end time
         if self._event_to_state(current_event) == STATE_OFF:
-            return current_event.end
+            return current_event.endTime
 
-        # Otherwise, return the next OFF event's end
-        next_events = self.get_next_events()
-        for event in next_events:
-            if self._event_to_state(event) == STATE_OFF:
-                return event.end
-        return None
+        # Otherwise, return the next on event's end
+        event = self._get_next_event_of_type(STATE_ON)
+        LOGGER.debug("Next connectivity: %s", event)
+        return event.startTime if event else None
 
     @property
     def current_state(self) -> str:
@@ -121,7 +138,7 @@ def current_state(self) -> str:
         event = self.get_event_at(now)
         return self._event_to_state(event)
 
-    def get_event_at(self, at: datetime.datetime) -> CalendarEvent:
+    def get_event_at(self, at: datetime.datetime) -> Interval:
         """Get the current event."""
         event = self.api.get_current_event(at)
         return self._get_calendar_event(event, translate=False)
@@ -132,37 +149,27 @@ def get_events_between(
         end_date: datetime.datetime,
         *,
         translate: bool = True,
-    ) -> list[CalendarEvent]:
+    ) -> list[Interval]:
         """Get all events."""
         events = self.api.get_events(start_date, end_date)
         return [
             self._get_calendar_event(event, translate=translate) for event in events
         ]
 
-    def get_next_events(self) -> CalendarEvent:
-        """Get the next event of a specific type."""
-        now = dt_utils.now()
-        current_event = self.get_event_at(now)
-        start = current_event.end if current_event else now
-        end = start + datetime.timedelta(days=1)
-        return self.get_events_between(start, end, translate=False)
-
     def _get_calendar_event(
         self,
         event: dict | None,
         *,
         translate: bool = True,
-    ) -> CalendarEvent:
-        """Transform an event into a CalendarEvent."""
+    ) -> Interval:
+        """Transform an event into a Inteval."""
         if not event:
             return None
 
-        event_summary = event.get("state")
-        translated_summary = (
-            self.event_name_map.get(event_summary) if translate else event_summary
-        )
-        event_start = datetime.datetime.fromisoformat(event["startTime"])
-        event_end = datetime.datetime.fromisoformat(event["endTime"])
+        event_summary = event['state']
+        translated_summary = self.event_name_map.get(event_summary)
+        event_start = event['startTime']
+        event_end = event['endTime']
 
         LOGGER.debug(
             "Transforming event: %s (%s -> %s)",
@@ -171,16 +178,16 @@ def _get_calendar_event(
             event_end,
         )
 
-        return CalendarEvent(
-            summary=translated_summary,
-            start=event_start,
-            end=event_end,
-            description=event_summary,
+        return Interval(
+            state=translated_summary if translate else event_summary,
+            startTime=event_start,
+            endTime=event_end,
         )
 
-    def _event_to_state(self, event: CalendarEvent | None) -> str:
-        summary = event.as_dict().get("summary") if event else None
+    def _event_to_state(self, event: Interval | None) -> str:
+        state = event.state if event else None
         return {
+            STATE_ON: STATE_ON,
             STATE_OFF: STATE_OFF,
             None: STATE_ON,
-        }[summary]
+        }[state]
\ No newline at end of file
diff --git a/custom_components/loe_outages/entity.py b/custom_components/loe_outages/entity.py
index cef442b..9719140 100644
--- a/custom_components/loe_outages/entity.py
+++ b/custom_components/loe_outages/entity.py
@@ -1,4 +1,4 @@
-"""LOE Outages entity."""
+"""Loe Outages entity."""
 
 from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -8,7 +8,7 @@
 
 
 class LoeOutagesEntity(CoordinatorEntity[LoeOutagesCoordinator]):
-    """Common logic for LOE Outages entity."""
+    """Common logic for Loe Outages entity."""
 
     _attr_has_entity_name = True
 
@@ -19,6 +19,6 @@ def device_info(self) -> DeviceInfo:
             translation_key="loe_outages",
             translation_placeholders={"group": self.coordinator.group},
             identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)},
-            manufacturer="LOE",
+            manufacturer="Loe",
             entry_type=DeviceEntryType.SERVICE,
-        )
+        )
\ No newline at end of file
diff --git a/custom_components/loe_outages/manifest.json b/custom_components/loe_outages/manifest.json
index 85d76cf..84f520c 100644
--- a/custom_components/loe_outages/manifest.json
+++ b/custom_components/loe_outages/manifest.json
@@ -1,11 +1,15 @@
 {
   "domain": "loe_outages",
   "name": "LOE Outages",
-  "codeowners": ["@jurkash"],
+  "codeowners": [
+    "@jurkash"
+  ],
   "config_flow": true,
   "documentation": "https://github.com/jurkash/ha-loe-outages",
   "iot_class": "calculated",
   "issue_tracker": "https://github.com/jurkash/ha-loe-outages",
-  "requirements": ["icalendar>=5.0.12", "recurring_ical_events>=2.2.1"],
-  "version": "0.0.0-unreleased"
+  "requirements": [
+
+  ],
+  "version": "0.0.3"
 }
\ No newline at end of file
diff --git a/custom_components/loe_outages/models.py b/custom_components/loe_outages/models.py
new file mode 100644
index 0000000..f111d92
--- /dev/null
+++ b/custom_components/loe_outages/models.py
@@ -0,0 +1,94 @@
+import datetime
+import pytz
+from dateutil import parser
+from typing import List
+
+utc=pytz.UTC
+
+class Interval:
+    def __init__(self, state: str, startTime: str, endTime: str):
+        self.state = state
+        self.startTime = startTime
+        self.endTime = endTime
+
+    @staticmethod
+    def from_dict(obj: dict) -> 'Interval':
+        return Interval(
+            state=obj.get("state"),
+            startTime=parser.parse(obj.get("startTime")).astimezone(utc),
+            endTime=parser.parse(obj.get("endTime")).astimezone(utc),
+        )
+
+    def to_dict(self) -> dict:
+        return {
+            "state": self.state,
+            "startTime": self.startTime,
+            "endTime": self.endTime
+        }
+
+class Group:
+    def __init__(self, id: str, intervals: List[Interval]):
+        self.id = id
+        self.intervals = intervals
+
+    @staticmethod
+    def from_dict(obj: dict) -> 'Group':
+        intervals = [Interval.from_dict(interval) for interval in obj.get("intervals", [])]
+        return Group(
+            id=obj.get("id"),
+            intervals=intervals
+        )
+
+    def to_dict(self) -> dict:
+        return {
+            "id": self.id,
+            "intervals": [interval.to_dict() for interval in self.intervals]
+        }
+
+class OutageSchedule:
+    def __init__(self, id: str, date: str, dateString: str, imageUrl: str, groups: List[Group]):
+        self.id = id
+        self.date = date
+        self.dateString = dateString
+        self.imageUrl = imageUrl
+        self.groups = groups
+
+    @staticmethod
+    def from_dict(obj: dict) -> 'OutageSchedule':
+        groups = [Group.from_dict(group) for group in obj.get("groups", [])]
+        return OutageSchedule(
+            id=obj.get("id"),
+            date=parser.parse(obj.get("date")).astimezone(utc),
+            dateString=obj.get("dateString"),
+            imageUrl=obj.get("imageUrl"),
+            groups=groups
+        )
+
+    def to_dict(self) -> dict:
+        return {
+            "id": self.id,
+            "date": self.date,
+            "dateString": self.dateString,
+            "imageUrl": self.imageUrl,
+            "groups": [group.to_dict() for group in self.groups]
+        }
+    
+    def get_current_event(self, group_id: str, at: datetime.datetime) -> dict:
+        at = at.astimezone(utc)
+        for group in self.groups:
+            if group.id == group_id:
+                for interval in group.intervals:
+                    if interval.startTime <= at <= interval.endTime:
+                        return interval.to_dict()
+        return {}
+    
+    def between(self, group_id: str, start: datetime.datetime, end: datetime.datetime) -> list[dict]:
+        start = start.astimezone(utc)
+        end = end.astimezone(utc)
+        res = []
+        for group in self.groups:
+            if group.id == group_id:
+                for interval in group.intervals:
+                    if start <= interval.startTime and interval.endTime <= end:
+                        res.append(interval.to_dict())
+        return res
diff --git a/custom_components/loe_outages/sensor.py b/custom_components/loe_outages/sensor.py
index 0b2cf96..6a7708c 100644
--- a/custom_components/loe_outages/sensor.py
+++ b/custom_components/loe_outages/sensor.py
@@ -1,4 +1,4 @@
-"""Calendar platform for LOE outages integration."""
+"""Calendar platform for Loe outages integration."""
 
 import logging
 from collections.abc import Callable
@@ -22,7 +22,7 @@
 
 @dataclass(frozen=True, kw_only=True)
 class LoeOutagesSensorDescription(SensorEntityDescription):
-    """LOE Outages entity description."""
+    """Loe Outages entity description."""
 
     val_func: Callable[[LoeOutagesCoordinator], bool]
 
@@ -64,7 +64,7 @@ async def async_setup_entry(
     config_entry: ConfigEntry,
     async_add_entities: AddEntitiesCallback,
 ) -> None:
-    """Set up the LOE outages calendar platform."""
+    """Set up the Loe outages calendar platform."""
     LOGGER.debug("Setup new entry: %s", config_entry)
     coordinator: LoeOutagesCoordinator = config_entry.runtime_data
     async_add_entities(
@@ -92,4 +92,4 @@ def __init__(
     @property
     def native_value(self) -> str | None:
         """Return the state of the sensor."""
-        return self.entity_description.val_func(self.coordinator)
+        return self.entity_description.val_func(self.coordinator)
\ No newline at end of file
diff --git a/custom_components/loe_outages/translations/en.json b/custom_components/loe_outages/translations/en.json
index 6ea4ea2..732cb50 100644
--- a/custom_components/loe_outages/translations/en.json
+++ b/custom_components/loe_outages/translations/en.json
@@ -41,7 +41,7 @@
             "message": {
               "name": "Connectivity",
               "state": {
-                "off": "Electricity Outage"
+                "PowerOff": "Electricity Outage"
               }
             }
           }
@@ -51,8 +51,8 @@
         "electricity": {
           "name": "Electricity",
           "state": {
-            "on": "Connected",
-            "off": "Outage"
+            "PowerOn": "Connected",
+            "PowerOff": "Outage"
           }
         },
         "next_outage": {
diff --git a/custom_components/loe_outages/translations/uk.json b/custom_components/loe_outages/translations/uk.json
index 4622acc..6b887af 100644
--- a/custom_components/loe_outages/translations/uk.json
+++ b/custom_components/loe_outages/translations/uk.json
@@ -17,7 +17,7 @@
     "options": {
       "step": {
         "init": {
-          "title": "Опції LOE Відключення",
+          "title": "Опції ЛОЕ Відключення",
           "description": "Оберіть іншу групу:",
           "data": {
             "group": "Група"
@@ -30,7 +30,7 @@
     },
     "device": {
       "loe_outages": {
-        "name": "LOE Група {group}"
+        "name": "ЛОЕ Група {group}"
       }
     },
     "entity": {
@@ -41,7 +41,7 @@
             "message": {
               "name": "Підключення",
               "state": {
-                "off": "Відключення"
+                "PowerOff": "Відключення"
               }
             }
           }
@@ -51,15 +51,15 @@
         "electricity": {
           "name": "Електрика",
           "state": {
-            "on": "Заживлено",
-            "off": "Відключення"
+            "PowerOn": "Заживлено",
+            "PowerOff": "Відключення"
           }
         },
         "next_outage": {
           "name": "Наступне відключення"
         },
         "next_connectivity": {
-          "name": "Наступна заживленість"
+          "name": "Наступне харчування"
         }
       }
     },
diff --git a/icons/icon.png b/icons/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..848a52c11786371a634ec06f33e5cc75d449f01f
GIT binary patch
literal 6202
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
YDmxe;ZecFA@yp-R40$s5#MPVs0gHBcYybcN

literal 0
HcmV?d00001

diff --git a/requirements.txt b/requirements.txt
index 1c8966e..210483d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,4 @@
 homeassistant==2024.7.0
-icalendar >= 5.0.12
 pip>=21.0,<24.2
 pre-commit>=3.7.1
-recurring-ical-events>=2.2.1
 ruff==0.5.0
\ No newline at end of file