From bd5dc07ed80febf3c076fdf6b01f9630926651e9 Mon Sep 17 00:00:00 2001 From: Constantin Pascal Date: Mon, 6 Apr 2026 15:34:01 +0300 Subject: [PATCH] Initial commit --- .gitignore | 78 +++++ Claude.md | 231 +++++++++++++ custom_components/deer_outages/__init__.py | 76 ++++ custom_components/deer_outages/api.py | 198 +++++++++++ custom_components/deer_outages/config_flow.py | 158 +++++++++ custom_components/deer_outages/const.py | 190 ++++++++++ custom_components/deer_outages/coordinator.py | 311 +++++++++++++++++ custom_components/deer_outages/manifest.json | 12 + custom_components/deer_outages/sensor.py | 325 ++++++++++++++++++ custom_components/deer_outages/strings.json | 40 +++ custom_components/deer_outages/switch.py | 121 +++++++ .../deer_outages/translations/en.json | 40 +++ test_api.py | 301 ++++++++++++++++ 13 files changed, 2081 insertions(+) create mode 100644 .gitignore create mode 100644 Claude.md create mode 100644 custom_components/deer_outages/__init__.py create mode 100644 custom_components/deer_outages/api.py create mode 100644 custom_components/deer_outages/config_flow.py create mode 100644 custom_components/deer_outages/const.py create mode 100644 custom_components/deer_outages/coordinator.py create mode 100644 custom_components/deer_outages/manifest.json create mode 100644 custom_components/deer_outages/sensor.py create mode 100644 custom_components/deer_outages/strings.json create mode 100644 custom_components/deer_outages/switch.py create mode 100644 custom_components/deer_outages/translations/en.json create mode 100644 test_api.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fb6475 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# ── OS ───────────────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini + +# ── Secrets / environment ─────────────────────────────────────────────────── +.env +.env.local +.env.*.local +*.pem +*.key +*.p12 +*.pfx + +# ── Logs ─────────────────────────────────────────────────────────────────── +*.log +logs/ + +# ── Compiled / bytecode ───────────────────────────────────────────────────── +*.class +*.pyc +*.pyo +*.pyd +__pycache__/ +*.o +*.a +*.so +*.dylib +*.dll +*.exe +*.lib + +# ── Archives ─────────────────────────────────────────────────────────────── +*.zip +*.tar +*.tar.gz +*.tgz +*.tar.bz2 +*.rar +*.7z + +# ── Java / JVM ────────────────────────────────────────────────────────────── +*.jar +*.war +*.ear +target/ +.gradle/ +build/ +.m2/ + +# ── JavaScript / Node ─────────────────────────────────────────────────────── +node_modules/ +dist/ +.next/ +.nuxt/ +.svelte-kit/ +.output/ + +# ── Python ────────────────────────────────────────────────────────────────── +.venv/ +venv/ +env/ +ENV/ +*.egg-info/ +dist/ +.tox/ +.pytest_cache/ +htmlcov/ +.coverage + +# ── IDE ───────────────────────────────────────────────────────────────────── +.idea/ +.vscode/ +*.iml +.eclipse/ + +# ── Project-specific exclusions ──────────────────────────────────────────── +DEER Deranjamente.iml diff --git a/Claude.md b/Claude.md new file mode 100644 index 0000000..5562306 --- /dev/null +++ b/Claude.md @@ -0,0 +1,231 @@ +This repository holds the custom integration for Home Assistant that is polling data from the electricity grid provider, to catch and notify home assistant whn there are power outages planned/unplanned. + +The integration follows home assistant best practices. + +It has the following configurations: + +1. DEER Region: [TN, TS, MN] for Transilvania Nord, Transilvania Sud, Muntentia Nord +For Cluj County it should default to TN. + +2. City/Village: The city/village for which notifications should be raised +3. Street: the street name, if left blank, notifications will be raised for all streets in the city/village. +4. Polling interval standard: default to 15 minutes +5. Polling interval for power outages: default to 1 minute + +How It works: + +Scenario 1: Normal operation +The integration will poll the electricity grid provider every 15 minutes (or the custom set interval) and check if there are any power outages planned/unplanned. If there are, it will raise a notification. +The endpoints polled are: +https://outages.distributie-energie.ro/api/incidents/{Region}/0 for unplanned power outages +https://outages.distributie-energie.ro/api/scheduled/{Region}/0/15 for planned power outages in the next 15 days. +https://outages.distributie-energie.ro/api/scheduled/{Region}/0/azi for planned power outages today. + +The data returned has the following format(s): + +For scheduled power outages: + [ +{ +"id": "82540", +"nivel": "JT", +"sucursala": "BAIA MARE", +"judet": null, +"centru": null, +"linie": null, +"denumireInst": null, +"ansambluFunctional": "CALINESTI, Strada PRINCIPALA", +"catInstalatii": null, +"instalatie": null, +"dataProgramareStart": "14/01/2026 08:00", +"dataProgramareStop": "14/01/2026 15:00", +"durataProgramare": "7h : 0'", +"adreseLucrari": null, +"intervaluriProgramari": null +} +] + +In this case, the integration will search the fields: "ansambluFunctional" and "adreseLucrari" to see if the city/village and street name are present. the logic will follow the following process: +we'll have a variable: cityPresent=city/village name is present, comparison done by first making both the expected and actual values as lower case. +if it's true, we'l check in a simillar fashion the street name +If we have a planned outage: we'll raise a home assistant event, with attributes for the planned start/stop and duration for the fields: dataProgramareStart, durataProgramareStop, durataProgramare, also ansamblulFunctional and adreseLucrari. + +For unplanned power outages: +[ +{ +"id": "69706", +"judet": "SIBIU", +"localitate": null, +"sat": null, +"tipArtera": null, +"artera": null, +"numar": null, +"bloc": null, +"adresa": "Loc: SURA MARE, Strada: VETERANILOR
Loc: SURA MARE", +"dataStart": "10/01/2026 01:00", +"dataStop": "" +}, + +In this case the search will be done in field: adresa, and the logic will follow the same process as above. +in case of unplanned power outages, we'll raise a home assistant event with the attributes: dataStart, dataStop, adresa. + +When a planned or unplanned power outage is raised, the integration keep track of the notification data, and will only raise a new notification if the start dates/ duration or end date changes. + +Scenario 2: Home assistant restart +In case home assistant is restarted, the integration will keep track of the notification data, and will only raise a new notification if the start dates/ duration or end date changes. + +Scenario 3: Power outage in progress +The integration exposes a switch entity to control the polling interval. +When home assistant detects that a power outage is in progress, the polling interval will be set to 1 minute (or the configured time), by enabling the outage polling mode switch. + +## Entities Created + +The integration creates the following entities: + +### Sensors (11 total) + +#### Planned Outages (15 days) +- `sensor.deer_outages_planned_15_start` - Start datetime of planned outage (device_class: timestamp) +- `sensor.deer_outages_planned_15_end` - End datetime of planned outage (device_class: timestamp) +- `sensor.deer_outages_planned_15_duration` - Duration text (e.g., "7h : 0'") +- `sensor.deer_outages_planned_15_address` - Location address text + +#### Planned Outages (Today) +- `sensor.deer_outages_planned_today_start` - Start datetime of today's planned outage (device_class: timestamp) +- `sensor.deer_outages_planned_today_end` - End datetime of today's planned outage (device_class: timestamp) +- `sensor.deer_outages_planned_today_duration` - Duration text +- `sensor.deer_outages_planned_today_address` - Location address text + +#### Unplanned Incidents +- `sensor.deer_outages_unplanned_start` - Start datetime of unplanned incident (device_class: timestamp) +- `sensor.deer_outages_unplanned_end` - End datetime of unplanned incident (device_class: timestamp) +- `sensor.deer_outages_unplanned_address` - Location address text + +All sensors use `RestoreEntity` to persist their state across Home Assistant restarts. + +### Switch Entity + +- `switch.deer_outages_outage_polling_mode` - Controls polling interval + - **Off (default)**: Normal polling mode - polls every 15 minutes (or configured interval) + - **On**: Outage polling mode - polls every 1 minute (or configured interval) for faster updates during active outages + +The switch state is preserved across Home Assistant restarts. + +## Events + +In addition to sensors, the integration fires Home Assistant events for automation triggers: + +### Event Types + +1. **`deer_outages_planned`** - Fired when a planned outage is detected or changed + +Event data: +```python +{ + "start": "14/01/2026 08:00", + "end": "14/01/2026 15:00", + "duration": "7h : 0'", + "address": "CALINESTI, Strada PRINCIPALA", + "city": "Cluj-Napoca", + "street": "Principala", + "type": "planned_15" # or "planned_today" +} +``` + +2. **`deer_outages_unplanned`** - Fired when an unplanned incident is detected or changed + +Event data: +```python +{ + "start": "10/01/2026 01:00", + "end": "", + "address": "Loc: SURA MARE, Strada: VETERANILOR", + "city": "Sura Mare", + "street": "" +} +``` + +Events are only fired when: +- A new outage is detected (wasn't present before) +- An existing outage's start date, end date, or duration changes + +## Automation Examples + +### Example 1: Notification on Planned Outage +```yaml +automation: + - alias: "Notify on Planned Outage" + trigger: + - platform: event + event_type: deer_outages_planned + action: + - service: notify.mobile_app + data: + title: "Planned Power Outage" + message: > + Outage from {{ trigger.event.data.start }} + to {{ trigger.event.data.end }} + at {{ trigger.event.data.address }} +``` + +### Example 2: Using Sensor States +```yaml +automation: + - alias: "Alert on Today's Outage" + trigger: + - platform: state + entity_id: sensor.deer_outages_planned_today_start + to: ~ + condition: + - condition: template + value_template: "{{ states('sensor.deer_outages_planned_today_start') not in ['unknown', 'unavailable', 'None'] }}" + action: + - service: notify.mobile_app + data: + title: "Power Outage Today!" + message: > + Power outage scheduled for today from + {{ states('sensor.deer_outages_planned_today_start') }} + to {{ states('sensor.deer_outages_planned_today_end') }} +``` + +### Example 3: Auto-Enable Outage Mode +```yaml +automation: + - alias: "Enable Fast Polling on Active Outage" + trigger: + - platform: state + entity_id: sensor.deer_outages_unplanned_start + to: ~ + condition: + - condition: template + value_template: "{{ states('sensor.deer_outages_unplanned_start') not in ['unknown', 'unavailable', 'None'] }}" + action: + - service: switch.turn_on + target: + entity_id: switch.deer_outages_outage_polling_mode +``` + +## Installation + +### Manual Installation +1. Copy the `custom_components/deer_outages` directory to your Home Assistant `custom_components` directory +2. Restart Home Assistant +3. Go to **Settings** > **Devices & Services** > **Add Integration** +4. Search for "DEER Power Outages" +5. Complete the configuration: + - Select your region (TN, TS, or MN) + - Enter your city/village name + - Optionally enter your street name + - Configure polling intervals (defaults: 15 min normal, 1 min outage) + +### Testing the API +Before installing in Home Assistant, you can test the API connection using the standalone script: + +```bash +python test_api.py TN Cluj-Napoca "Strada Avram Iancu" +``` + +This will: +- Test all three API endpoints +- Show filtered results for your location +- Validate the filtering logic \ No newline at end of file diff --git a/custom_components/deer_outages/__init__.py b/custom_components/deer_outages/__init__.py new file mode 100644 index 0000000..bad6ffc --- /dev/null +++ b/custom_components/deer_outages/__init__.py @@ -0,0 +1,76 @@ +"""The DEER Power Outages integration.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import DeerApiClient +from .const import CONF_REGION, DOMAIN +from .coordinator import DeerOutageCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up DEER Power Outages from a config entry. + + Args: + hass: Home Assistant instance + entry: Config entry + + Returns: + True if setup was successful + """ + _LOGGER.debug("Setting up DEER Power Outages integration") + + # Create aiohttp session + session = async_get_clientsession(hass) + + # Get region from config + region = entry.data[CONF_REGION] + + # Initialize API client + api_client = DeerApiClient(session, region) + + # Initialize coordinator + coordinator = DeerOutageCoordinator(hass, api_client, entry) + + # Store coordinator in hass.data + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + # Fetch initial data + await coordinator.async_config_entry_first_refresh() + + # Forward setup to platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + _LOGGER.info("DEER Power Outages integration setup complete") + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry. + + Args: + hass: Home Assistant instance + entry: Config entry + + Returns: + True if unload was successful + """ + _LOGGER.debug("Unloading DEER Power Outages integration") + + # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + # Clean up coordinator + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/deer_outages/api.py b/custom_components/deer_outages/api.py new file mode 100644 index 0000000..61310ee --- /dev/null +++ b/custom_components/deer_outages/api.py @@ -0,0 +1,198 @@ +"""API client for DEER Power Outages.""" +import asyncio +import logging +import re +from typing import Optional + +import aiohttp + +from .const import ( + API_BASE_URL, + API_INCIDENTS, + API_SCHEDULED_15, + API_SCHEDULED_TODAY, +) + +_LOGGER = logging.getLogger(__name__) + + +class DeerApiClient: + """DEER API client for fetching power outage data.""" + + def __init__(self, session: aiohttp.ClientSession, region: str) -> None: + """Initialize the API client. + + Args: + session: aiohttp client session + region: DEER region (TN, TS, MN) + """ + self._session = session + self._region = region + + @staticmethod + def strip_html_tags(text: str) -> str: + """Remove HTML tags from text. + + Args: + text: Text potentially containing HTML tags + + Returns: + Text with HTML tags removed + """ + if not text: + return text + return re.sub(r"<[^>]+>", " ", text).strip() + + async def _fetch(self, endpoint: str) -> list[dict]: + """Fetch data from DEER API endpoint. + + Args: + endpoint: API endpoint path + + Returns: + List of outage dictionaries + + Raises: + aiohttp.ClientError: On HTTP errors + asyncio.TimeoutError: On timeout + """ + url = API_BASE_URL + endpoint.format(region=self._region) + _LOGGER.debug("Fetching data from %s", url) + + try: + async with asyncio.timeout(30): + async with self._session.get(url) as response: + response.raise_for_status() + data = await response.json() + + if not isinstance(data, list): + _LOGGER.error("Expected list response, got %s", type(data)) + return [] + + _LOGGER.debug("Fetched %d items from %s", len(data), url) + return data + + except asyncio.TimeoutError: + _LOGGER.error("Timeout fetching data from %s", url) + raise + except aiohttp.ClientError as err: + _LOGGER.error("HTTP error fetching data from %s: %s", url, err) + raise + except Exception as err: + _LOGGER.error("Unexpected error fetching data from %s: %s", url, err) + raise + + async def get_incidents(self) -> list[dict]: + """Fetch unplanned incidents. + + Returns: + List of incident dictionaries + """ + return await self._fetch(API_INCIDENTS) + + async def get_scheduled_15(self) -> list[dict]: + """Fetch scheduled outages for the next 15 days. + + Returns: + List of scheduled outage dictionaries + """ + return await self._fetch(API_SCHEDULED_15) + + async def get_scheduled_today(self) -> list[dict]: + """Fetch scheduled outages for today. + + Returns: + List of scheduled outage dictionaries + """ + return await self._fetch(API_SCHEDULED_TODAY) + + def filter_by_location( + self, + data: list[dict], + city: str, + street: Optional[str], + is_planned: bool, + ) -> Optional[dict]: + """Filter outages by city and optionally street. + + This method returns the FIRST matching outage. + + Args: + data: List of outage dictionaries + city: City/village name + street: Street name (optional) + is_planned: True for planned outages, False for incidents + + Returns: + First matching outage dictionary or None + """ + if not data: + return None + + for item in data: + if is_planned: + if self._matches_location_planned(item, city, street): + return item + else: + if self._matches_location_unplanned(item, city, street): + return item + + return None + + def _matches_location_planned( + self, item: dict, city: str, street: Optional[str] + ) -> bool: + """Check if planned outage matches configured location. + + Args: + item: Outage dictionary + city: City/village name + street: Street name (optional) + + Returns: + True if location matches + """ + fields_to_check = [ + item.get("ansambluFunctional", ""), + item.get("adreseLucrari", ""), + ] + + # Combine all fields to search + combined = " ".join(str(f) for f in fields_to_check if f).lower() + + # Check city (required) + if city.lower() not in combined: + return False + + # Check street (optional) + if street and street.strip(): + if street.lower() not in combined: + return False + + return True + + def _matches_location_unplanned( + self, item: dict, city: str, street: Optional[str] + ) -> bool: + """Check if unplanned outage matches configured location. + + Args: + item: Incident dictionary + city: City/village name + street: Street name (optional) + + Returns: + True if location matches + """ + adresa = item.get("adresa", "").lower() + + # Check city (required) + if city.lower() not in adresa: + return False + + # Check street (optional) + if street and street.strip(): + if street.lower() not in adresa: + return False + + return True diff --git a/custom_components/deer_outages/config_flow.py b/custom_components/deer_outages/config_flow.py new file mode 100644 index 0000000..5a0c729 --- /dev/null +++ b/custom_components/deer_outages/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for DEER Power Outages integration.""" +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + CONF_CITY, + CONF_NORMAL_INTERVAL, + CONF_OUTAGE_INTERVAL, + CONF_REGION, + CONF_STREET, + DEFAULT_NORMAL_INTERVAL, + DEFAULT_OUTAGE_INTERVAL, + DEFAULT_REGION, + DOMAIN, + REGIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +class DeerOutagesConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for DEER Power Outages.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step. + + Args: + user_input: User provided configuration + + Returns: + FlowResult for next step or entry creation + """ + errors = {} + + if user_input is not None: + # Validate inputs + if user_input[CONF_REGION] not in REGIONS: + errors["base"] = "invalid_region" + elif not user_input[CONF_CITY].strip(): + errors["base"] = "empty_city" + elif user_input[CONF_NORMAL_INTERVAL] <= 0: + errors["base"] = "invalid_interval" + elif user_input[CONF_OUTAGE_INTERVAL] <= 0: + errors["base"] = "invalid_interval" + else: + # Create entry + title = f"DEER {user_input[CONF_REGION]} - {user_input[CONF_CITY]}" + return self.async_create_entry(title=title, data=user_input) + + # Show configuration form + data_schema = vol.Schema( + { + vol.Required(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), + vol.Required(CONF_CITY): str, + vol.Optional(CONF_STREET, default=""): str, + vol.Optional( + CONF_NORMAL_INTERVAL, default=DEFAULT_NORMAL_INTERVAL + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional( + CONF_OUTAGE_INTERVAL, default=DEFAULT_OUTAGE_INTERVAL + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get the options flow for this handler. + + Args: + config_entry: Config entry + + Returns: + Options flow handler + """ + return DeerOutagesOptionsFlow(config_entry) + + +class DeerOutagesOptionsFlow(config_entries.OptionsFlow): + """Handle options flow for DEER Power Outages.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow. + + Args: + config_entry: Config entry + """ + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options. + + Args: + user_input: User provided options + + Returns: + FlowResult for options update + """ + errors = {} + + if user_input is not None: + # Validate inputs + if not user_input[CONF_CITY].strip(): + errors["base"] = "empty_city" + elif user_input[CONF_NORMAL_INTERVAL] <= 0: + errors["base"] = "invalid_interval" + elif user_input[CONF_OUTAGE_INTERVAL] <= 0: + errors["base"] = "invalid_interval" + else: + # Update config entry data + self.hass.config_entries.async_update_entry( + self.config_entry, data=user_input + ) + return self.async_create_entry(title="", data={}) + + # Get current values + current_data = self.config_entry.data + + # Show options form + data_schema = vol.Schema( + { + vol.Required( + CONF_REGION, default=current_data.get(CONF_REGION, DEFAULT_REGION) + ): vol.In(REGIONS), + vol.Required(CONF_CITY, default=current_data.get(CONF_CITY, "")): str, + vol.Optional(CONF_STREET, default=current_data.get(CONF_STREET, "")): str, + vol.Optional( + CONF_NORMAL_INTERVAL, + default=current_data.get(CONF_NORMAL_INTERVAL, DEFAULT_NORMAL_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional( + CONF_OUTAGE_INTERVAL, + default=current_data.get(CONF_OUTAGE_INTERVAL, DEFAULT_OUTAGE_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=1)), + } + ) + + return self.async_show_form( + step_id="init", data_schema=data_schema, errors=errors + ) diff --git a/custom_components/deer_outages/const.py b/custom_components/deer_outages/const.py new file mode 100644 index 0000000..d529f03 --- /dev/null +++ b/custom_components/deer_outages/const.py @@ -0,0 +1,190 @@ +"""Constants for the DEER Power Outages integration.""" + +DOMAIN = "deer_outages" + +# API Configuration +API_BASE_URL = "https://outages.distributie-energie.ro/api" +API_INCIDENTS = "/incidents/{region}/0" +API_SCHEDULED_15 = "/scheduled/{region}/0/15" +API_SCHEDULED_TODAY = "/scheduled/{region}/0/azi" + +# Config Flow Keys +CONF_REGION = "region" +CONF_CITY = "city" +CONF_STREET = "street" +CONF_NORMAL_INTERVAL = "normal_interval" +CONF_OUTAGE_INTERVAL = "outage_interval" + +# Regions +REGION_TN = "TN" +REGION_TS = "TS" +REGION_MN = "MN" +REGIONS = [REGION_TN, REGION_TS, REGION_MN] + +# Defaults +DEFAULT_REGION = REGION_TN +DEFAULT_NORMAL_INTERVAL = 15 # minutes +DEFAULT_OUTAGE_INTERVAL = 1 # minutes + +# Events +EVENT_PLANNED_OUTAGE = f"{DOMAIN}_planned" +EVENT_UNPLANNED_OUTAGE = f"{DOMAIN}_unplanned" + +# Sensor Types - Planned 15 days +SENSOR_PLANNED_15_START = "planned_15_start" +SENSOR_PLANNED_15_END = "planned_15_end" +SENSOR_PLANNED_15_DURATION = "planned_15_duration" +SENSOR_PLANNED_15_ADDRESS = "planned_15_address" + +# Sensor Types - Planned Today +SENSOR_PLANNED_TODAY_START = "planned_today_start" +SENSOR_PLANNED_TODAY_END = "planned_today_end" +SENSOR_PLANNED_TODAY_DURATION = "planned_today_duration" +SENSOR_PLANNED_TODAY_ADDRESS = "planned_today_address" + +# Sensor Types - Unplanned +SENSOR_UNPLANNED_START = "unplanned_start" +SENSOR_UNPLANNED_END = "unplanned_end" +SENSOR_UNPLANNED_ADDRESS = "unplanned_address" + +# All sensor types +SENSOR_TYPES = [ + SENSOR_PLANNED_15_START, + SENSOR_PLANNED_15_END, + SENSOR_PLANNED_15_DURATION, + SENSOR_PLANNED_15_ADDRESS, + SENSOR_PLANNED_TODAY_START, + SENSOR_PLANNED_TODAY_END, + SENSOR_PLANNED_TODAY_DURATION, + SENSOR_PLANNED_TODAY_ADDRESS, + SENSOR_UNPLANNED_START, + SENSOR_UNPLANNED_END, + SENSOR_UNPLANNED_ADDRESS, +] + +# Sensor Types - Last Known Values (Planned 15 days) +SENSOR_PLANNED_15_START_LAST = "planned_15_start_last" +SENSOR_PLANNED_15_END_LAST = "planned_15_end_last" +SENSOR_PLANNED_15_DURATION_LAST = "planned_15_duration_last" +SENSOR_PLANNED_15_ADDRESS_LAST = "planned_15_address_last" + +# Sensor Types - Last Known Values (Planned Today) +SENSOR_PLANNED_TODAY_START_LAST = "planned_today_start_last" +SENSOR_PLANNED_TODAY_END_LAST = "planned_today_end_last" +SENSOR_PLANNED_TODAY_DURATION_LAST = "planned_today_duration_last" +SENSOR_PLANNED_TODAY_ADDRESS_LAST = "planned_today_address_last" + +# Sensor Types - Last Known Values (Unplanned) +SENSOR_UNPLANNED_START_LAST = "unplanned_start_last" +SENSOR_UNPLANNED_END_LAST = "unplanned_end_last" +SENSOR_UNPLANNED_ADDRESS_LAST = "unplanned_address_last" + +# All last-value sensor types +SENSOR_TYPES_LAST = [ + SENSOR_PLANNED_15_START_LAST, + SENSOR_PLANNED_15_END_LAST, + SENSOR_PLANNED_15_DURATION_LAST, + SENSOR_PLANNED_15_ADDRESS_LAST, + SENSOR_PLANNED_TODAY_START_LAST, + SENSOR_PLANNED_TODAY_END_LAST, + SENSOR_PLANNED_TODAY_DURATION_LAST, + SENSOR_PLANNED_TODAY_ADDRESS_LAST, + SENSOR_UNPLANNED_START_LAST, + SENSOR_UNPLANNED_END_LAST, + SENSOR_UNPLANNED_ADDRESS_LAST, +] + +# Sensor names and icons +SENSOR_INFO = { + SENSOR_PLANNED_15_START: { + "name": "Planned 15 Days Start", + "icon": "mdi:calendar-clock", + }, + SENSOR_PLANNED_15_END: { + "name": "Planned 15 Days End", + "icon": "mdi:calendar-clock", + }, + SENSOR_PLANNED_15_DURATION: { + "name": "Planned 15 Days Duration", + "icon": "mdi:timer-outline", + }, + SENSOR_PLANNED_15_ADDRESS: { + "name": "Planned 15 Days Address", + "icon": "mdi:map-marker", + }, + SENSOR_PLANNED_TODAY_START: { + "name": "Planned Today Start", + "icon": "mdi:calendar-today", + }, + SENSOR_PLANNED_TODAY_END: { + "name": "Planned Today End", + "icon": "mdi:calendar-today", + }, + SENSOR_PLANNED_TODAY_DURATION: { + "name": "Planned Today Duration", + "icon": "mdi:timer-outline", + }, + SENSOR_PLANNED_TODAY_ADDRESS: { + "name": "Planned Today Address", + "icon": "mdi:map-marker", + }, + SENSOR_UNPLANNED_START: { + "name": "Unplanned Start", + "icon": "mdi:alert-circle", + }, + SENSOR_UNPLANNED_END: { + "name": "Unplanned End", + "icon": "mdi:alert-circle", + }, + SENSOR_UNPLANNED_ADDRESS: { + "name": "Unplanned Address", + "icon": "mdi:map-marker-alert", + }, + SENSOR_PLANNED_15_START_LAST: { + "name": "Planned 15 Days Start Last", + "icon": "mdi:calendar-clock", + }, + SENSOR_PLANNED_15_END_LAST: { + "name": "Planned 15 Days End Last", + "icon": "mdi:calendar-clock", + }, + SENSOR_PLANNED_15_DURATION_LAST: { + "name": "Planned 15 Days Duration Last", + "icon": "mdi:timer-outline", + }, + SENSOR_PLANNED_15_ADDRESS_LAST: { + "name": "Planned 15 Days Address Last", + "icon": "mdi:map-marker", + }, + SENSOR_PLANNED_TODAY_START_LAST: { + "name": "Planned Today Start Last", + "icon": "mdi:calendar-today", + }, + SENSOR_PLANNED_TODAY_END_LAST: { + "name": "Planned Today End Last", + "icon": "mdi:calendar-today", + }, + SENSOR_PLANNED_TODAY_DURATION_LAST: { + "name": "Planned Today Duration Last", + "icon": "mdi:timer-outline", + }, + SENSOR_PLANNED_TODAY_ADDRESS_LAST: { + "name": "Planned Today Address Last", + "icon": "mdi:map-marker", + }, + SENSOR_UNPLANNED_START_LAST: { + "name": "Unplanned Start Last", + "icon": "mdi:alert-circle", + }, + SENSOR_UNPLANNED_END_LAST: { + "name": "Unplanned End Last", + "icon": "mdi:alert-circle", + }, + SENSOR_UNPLANNED_ADDRESS_LAST: { + "name": "Unplanned Address Last", + "icon": "mdi:map-marker-alert", + }, +} + +# Switch +SWITCH_OUTAGE_MODE = "outage_polling_mode" diff --git a/custom_components/deer_outages/coordinator.py b/custom_components/deer_outages/coordinator.py new file mode 100644 index 0000000..0aecd06 --- /dev/null +++ b/custom_components/deer_outages/coordinator.py @@ -0,0 +1,311 @@ +"""DataUpdateCoordinator for DEER Power Outages.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Optional + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.config_entries import ConfigEntry + +from .api import DeerApiClient +from .const import ( + CONF_CITY, + CONF_NORMAL_INTERVAL, + CONF_OUTAGE_INTERVAL, + CONF_STREET, + DOMAIN, + EVENT_PLANNED_OUTAGE, + EVENT_UNPLANNED_OUTAGE, +) + +_LOGGER = logging.getLogger(__name__) + + +class DeerOutageCoordinator(DataUpdateCoordinator): + """Coordinator to manage DEER power outage data updates.""" + + def __init__( + self, + hass: HomeAssistant, + api_client: DeerApiClient, + entry: ConfigEntry, + ) -> None: + """Initialize the coordinator. + + Args: + hass: Home Assistant instance + api_client: DEER API client + entry: Config entry + """ + self._api = api_client + self._city = entry.data[CONF_CITY] + self._street = entry.data.get(CONF_STREET) + self._normal_interval = entry.data.get(CONF_NORMAL_INTERVAL, 15) + self._outage_interval = entry.data.get(CONF_OUTAGE_INTERVAL, 1) + + # Track last known outage states for change detection + self._last_planned_15: Optional[dict] = None + self._last_planned_today: Optional[dict] = None + self._last_unplanned: Optional[dict] = None + + # Initialize with normal polling interval + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=self._normal_interval), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from all three DEER endpoints. + + Returns: + Dictionary with planned_15, planned_today, and unplanned data + + Raises: + UpdateFailed: If all endpoints fail + """ + _LOGGER.debug("Fetching outage data for %s", self._city) + + # Fetch from all three endpoints in parallel + results = await asyncio.gather( + self._api.get_scheduled_15(), + self._api.get_scheduled_today(), + self._api.get_incidents(), + return_exceptions=True + ) + + # Unpack and handle results + planned_15_raw = None + planned_today_raw = None + unplanned_raw = None + errors = [] + + # Process scheduled 15-day result + if isinstance(results[0], Exception): + _LOGGER.warning("Failed to fetch 15-day scheduled outages: %s", results[0]) + errors.append(f"scheduled_15: {results[0]}") + else: + planned_15_raw = results[0] + + # Process scheduled today result + if isinstance(results[1], Exception): + _LOGGER.warning("Failed to fetch today's scheduled outages: %s", results[1]) + errors.append(f"scheduled_today: {results[1]}") + else: + planned_today_raw = results[1] + + # Process incidents result + if isinstance(results[2], Exception): + _LOGGER.warning("Failed to fetch unplanned incidents: %s", results[2]) + errors.append(f"incidents: {results[2]}") + else: + unplanned_raw = results[2] + + # If all endpoints failed, raise UpdateFailed + if len(errors) == 3: + raise UpdateFailed(f"All endpoints failed: {'; '.join(errors)}") + + # Filter by location + planned_15_item = self._api.filter_by_location( + planned_15_raw or [], self._city, self._street, is_planned=True + ) + planned_today_item = self._api.filter_by_location( + planned_today_raw or [], self._city, self._street, is_planned=True + ) + unplanned_item = self._api.filter_by_location( + unplanned_raw or [], self._city, self._street, is_planned=False + ) + + # Process and structure the data + planned_15_data = self._process_planned_outage(planned_15_item) + planned_today_data = self._process_planned_outage(planned_today_item) + unplanned_data = self._process_unplanned_outage(unplanned_item) + + # Detect changes and fire events + self._detect_and_fire_events( + planned_15_data, planned_today_data, unplanned_data + ) + + # Store current state for next comparison + self._last_planned_15 = planned_15_data + self._last_planned_today = planned_today_data + self._last_unplanned = unplanned_data + + return { + "planned_15": planned_15_data, + "planned_today": planned_today_data, + "unplanned": unplanned_data, + } + + def _process_planned_outage(self, item: Optional[dict]) -> Optional[dict]: + """Process planned outage data. + + Args: + item: Raw API response item + + Returns: + Processed outage data or None + """ + if not item: + return None + + # Extract key fields + return { + "start": item.get("dataProgramareStart", ""), + "end": item.get("dataProgramareStop", ""), + "duration": item.get("durataProgramare", ""), + "address": self._format_planned_address(item), + "raw": item, + } + + def _process_unplanned_outage(self, item: Optional[dict]) -> Optional[dict]: + """Process unplanned outage data. + + Args: + item: Raw API response item + + Returns: + Processed incident data or None + """ + if not item: + return None + + # Extract key fields and strip HTML + address = self._api.strip_html_tags(item.get("adresa", "")) + + return { + "start": item.get("dataStart", ""), + "end": item.get("dataStop", ""), + "address": address, + "raw": item, + } + + def _format_planned_address(self, item: dict) -> str: + """Format address from planned outage data. + + Args: + item: Raw API response item + + Returns: + Formatted address string + """ + parts = [] + + if loc := item.get("ansambluFunctional"): + parts.append(loc) + + if addr := item.get("adreseLucrari"): + parts.append(addr) + + return " - ".join(parts) if parts else "" + + def _detect_and_fire_events( + self, + planned_15: Optional[dict], + planned_today: Optional[dict], + unplanned: Optional[dict], + ) -> None: + """Detect changes and fire Home Assistant events. + + Args: + planned_15: Current 15-day planned outage data + planned_today: Current today planned outage data + unplanned: Current unplanned incident data + """ + # Check planned 15-day outages + if self._has_changed(planned_15, self._last_planned_15): + if planned_15: + self._fire_planned_event(planned_15, "planned_15") + + # Check planned today outages + if self._has_changed(planned_today, self._last_planned_today): + if planned_today: + self._fire_planned_event(planned_today, "planned_today") + + # Check unplanned incidents + if self._has_changed(unplanned, self._last_unplanned): + if unplanned: + self._fire_unplanned_event(unplanned) + + def _has_changed( + self, new_data: Optional[dict], old_data: Optional[dict] + ) -> bool: + """Check if outage data has changed. + + Args: + new_data: New outage data + old_data: Previous outage data + + Returns: + True if data has changed + """ + # If both are None, no change + if new_data is None and old_data is None: + return False + + # If one is None and the other isn't, it changed + if (new_data is None) != (old_data is None): + return True + + # Compare key fields + return ( + new_data.get("start") != old_data.get("start") + or new_data.get("end") != old_data.get("end") + or new_data.get("duration") != old_data.get("duration") + ) + + def _fire_planned_event(self, data: dict, outage_type: str) -> None: + """Fire a planned outage event. + + Args: + data: Outage data + outage_type: Type identifier (planned_15 or planned_today) + """ + event_data = { + "start": data.get("start", ""), + "end": data.get("end", ""), + "duration": data.get("duration", ""), + "address": data.get("address", ""), + "city": self._city, + "street": self._street or "", + "type": outage_type, + } + + _LOGGER.info("Firing planned outage event: %s", outage_type) + self.hass.bus.async_fire(EVENT_PLANNED_OUTAGE, event_data) + + def _fire_unplanned_event(self, data: dict) -> None: + """Fire an unplanned incident event. + + Args: + data: Incident data + """ + event_data = { + "start": data.get("start", ""), + "end": data.get("end", ""), + "address": data.get("address", ""), + "city": self._city, + "street": self._street or "", + } + + _LOGGER.info("Firing unplanned incident event") + self.hass.bus.async_fire(EVENT_UNPLANNED_OUTAGE, event_data) + + async def set_outage_mode(self, enabled: bool) -> None: + """Switch between normal and outage polling intervals. + + Args: + enabled: True for outage mode (fast polling), False for normal + """ + if enabled: + new_interval = timedelta(minutes=self._outage_interval) + _LOGGER.info("Enabling outage mode: polling every %d minute(s)", self._outage_interval) + else: + new_interval = timedelta(minutes=self._normal_interval) + _LOGGER.info("Disabling outage mode: polling every %d minute(s)", self._normal_interval) + + self.update_interval = new_interval + # Force immediate refresh with new interval + await self.async_request_refresh() diff --git a/custom_components/deer_outages/manifest.json b/custom_components/deer_outages/manifest.json new file mode 100644 index 0000000..086a0e1 --- /dev/null +++ b/custom_components/deer_outages/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "deer_outages", + "name": "DEER Power Outages", + "codeowners": ["@constantin"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/yourusername/deer_outages", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": [], + "version": "1.0.0" +} diff --git a/custom_components/deer_outages/sensor.py b/custom_components/deer_outages/sensor.py new file mode 100644 index 0000000..3ce2f11 --- /dev/null +++ b/custom_components/deer_outages/sensor.py @@ -0,0 +1,325 @@ +"""Sensor platform for DEER Power Outages.""" +from datetime import datetime +import logging +from typing import Any, Optional +from zoneinfo import ZoneInfo + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + SENSOR_INFO, + SENSOR_PLANNED_15_ADDRESS, + SENSOR_PLANNED_15_DURATION, + SENSOR_PLANNED_15_END, + SENSOR_PLANNED_15_START, + SENSOR_PLANNED_TODAY_ADDRESS, + SENSOR_PLANNED_TODAY_DURATION, + SENSOR_PLANNED_TODAY_END, + SENSOR_PLANNED_TODAY_START, + SENSOR_TYPES, + SENSOR_TYPES_LAST, + SENSOR_UNPLANNED_ADDRESS, + SENSOR_UNPLANNED_END, + SENSOR_UNPLANNED_START, +) +from .coordinator import DeerOutageCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up DEER Outage sensors. + + Args: + hass: Home Assistant instance + entry: Config entry + async_add_entities: Callback to add entities + """ + coordinator: DeerOutageCoordinator = hass.data[DOMAIN][entry.entry_id] + + sensors = [DeerOutageSensor(coordinator, sensor_type) for sensor_type in SENSOR_TYPES] + sensors += [DeerOutageLastSensor(coordinator, sensor_type) for sensor_type in SENSOR_TYPES_LAST] + + async_add_entities(sensors) + + +class DeerOutageSensor(CoordinatorEntity, RestoreSensor, SensorEntity): + """Sensor for DEER outage data.""" + + def __init__(self, coordinator: DeerOutageCoordinator, sensor_type: str) -> None: + """Initialize the sensor. + + Args: + coordinator: Data update coordinator + sensor_type: Type of sensor (e.g., planned_15_start) + """ + super().__init__(coordinator) + self._sensor_type = sensor_type + self._attr_has_entity_name = True + + # Set unique ID + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{sensor_type}" + + # Set name and icon from SENSOR_INFO + sensor_info = SENSOR_INFO.get(sensor_type, {}) + self._attr_name = sensor_info.get("name", sensor_type) + self._attr_icon = sensor_info.get("icon", "mdi:power-plug-off") + + # Set device class for timestamp sensors + if sensor_type.endswith("_start") or sensor_type.endswith("_end"): + self._attr_device_class = SensorDeviceClass.TIMESTAMP + else: + self._attr_device_class = None + + @property + def native_value(self) -> Optional[str | datetime]: + """Return the state of the sensor. + + Returns: + Sensor value (datetime for timestamps, string for others) + """ + # Get the appropriate data based on sensor type + data = self._get_outage_data() + + if data is None: + return None + + # Extract the specific field based on sensor type + if self._sensor_type.endswith("_start"): + value = data.get("start") + elif self._sensor_type.endswith("_end"): + value = data.get("end") + elif self._sensor_type.endswith("_duration"): + value = data.get("duration") + elif self._sensor_type.endswith("_address"): + value = data.get("address") + else: + return None + + # Convert datetime strings to datetime objects for timestamp sensors + if self._attr_device_class == SensorDeviceClass.TIMESTAMP: + return self._parse_datetime(value) + + return value if value else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes. + + Returns: + Dictionary of attributes + """ + data = self._get_outage_data() + + if data is None: + return {} + + # Include raw API data for debugging/advanced use + attributes = { + "raw_data": data.get("raw", {}), + } + + # Add all fields for context + if "start" in data: + attributes["start"] = data["start"] + if "end" in data: + attributes["end"] = data["end"] + if "duration" in data: + attributes["duration"] = data["duration"] + if "address" in data: + attributes["address"] = data["address"] + + return attributes + + def _get_outage_data(self) -> Optional[dict]: + """Get the outage data for this sensor. + + Returns: + Outage data dictionary or None + """ + if self.coordinator.data is None: + return None + + # Determine which data source to use based on sensor type + if self._sensor_type.startswith("planned_15_"): + return self.coordinator.data.get("planned_15") + elif self._sensor_type.startswith("planned_today_"): + return self.coordinator.data.get("planned_today") + elif self._sensor_type.startswith("unplanned_"): + return self.coordinator.data.get("unplanned") + + return None + + def _parse_datetime(self, date_str: str) -> Optional[datetime]: + """Parse datetime string from DEER API. + + Args: + date_str: Date string in format DD/MM/YYYY HH:MM + + Returns: + Timezone-aware datetime object or None + """ + if not date_str or not date_str.strip(): + return None + + try: + # Parse format: 14/01/2026 08:00 + dt = datetime.strptime(date_str.strip(), "%d/%m/%Y %H:%M") + # DEER times are in Romanian timezone (EET/EEST - Europe/Bucharest) + # Add timezone information to make it timezone-aware + return dt.replace(tzinfo=ZoneInfo("Europe/Bucharest")) + except ValueError as err: + _LOGGER.warning("Failed to parse datetime '%s': %s", date_str, err) + return None + + async def async_added_to_hass(self) -> None: + """Handle entity added to Home Assistant. + + Restores previous state if available. + """ + await super().async_added_to_hass() + + # Restore previous state + if (last_state := await self.async_get_last_state()) is not None: + _LOGGER.debug( + "Restoring previous state for %s: %s", + self.entity_id, + last_state.state, + ) + + # For timestamp sensors, restore datetime + if self._attr_device_class == SensorDeviceClass.TIMESTAMP: + if last_state.state not in ("unknown", "unavailable"): + try: + # Parse ISO format datetime from HA state + self._attr_native_value = datetime.fromisoformat( + last_state.state + ) + except (ValueError, TypeError) as err: + _LOGGER.warning("Failed to restore datetime: %s", err) + else: + # For other sensors, restore string value + if last_state.state not in ("unknown", "unavailable"): + self._attr_native_value = last_state.state + + +class DeerOutageLastSensor(DeerOutageSensor): + """Sensor that retains the last known non-empty value for an outage field. + + Unlike the regular sensor which clears when no outage is found, this sensor + keeps its previous value and only updates when a new non-empty value arrives. + """ + + def __init__(self, coordinator: DeerOutageCoordinator, sensor_type: str) -> None: + """Initialize the last-value sensor. + + Args: + coordinator: Data update coordinator + sensor_type: Type of sensor with _last suffix (e.g., planned_15_start_last) + """ + super().__init__(coordinator, sensor_type) + # Derive base type by stripping the "_last" suffix + self._base_type = sensor_type[: -len("_last")] + self._cached_value: Optional[str | datetime] = None + + # Correct device_class: base type may end with _start/_end, not sensor_type + if self._base_type.endswith("_start") or self._base_type.endswith("_end"): + self._attr_device_class = SensorDeviceClass.TIMESTAMP + else: + self._attr_device_class = None + + @property + def native_value(self) -> Optional[str | datetime]: + """Return the last known non-empty value.""" + return self._cached_value + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return no extra attributes for last-value sensors.""" + return {} + + def _get_live_value(self) -> Optional[str | datetime]: + """Compute the current value from coordinator data using the base sensor type.""" + if self.coordinator.data is None: + return None + + if self._base_type.startswith("planned_15_"): + data = self.coordinator.data.get("planned_15") + elif self._base_type.startswith("planned_today_"): + data = self.coordinator.data.get("planned_today") + elif self._base_type.startswith("unplanned_"): + data = self.coordinator.data.get("unplanned") + else: + return None + + if data is None: + return None + + if self._base_type.endswith("_start"): + value = data.get("start") + elif self._base_type.endswith("_end"): + value = data.get("end") + elif self._base_type.endswith("_duration"): + value = data.get("duration") + elif self._base_type.endswith("_address"): + value = data.get("address") + else: + return None + + if self._attr_device_class == SensorDeviceClass.TIMESTAMP: + return self._parse_datetime(value) + + return value if value else None + + @property + def available(self) -> bool: + """Always available when a cached value exists. + + Unlike regular coordinator entities, last-value sensors should not go + unavailable just because the coordinator poll failed — they exist precisely + to retain the last known value through API outages. + """ + return self._cached_value is not None + + def _handle_coordinator_update(self) -> None: + """Cache the new value only when it is non-empty.""" + live = self._get_live_value() + if live is not None: + self._cached_value = live + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore cached value from the previous HA state.""" + # Call coordinator + restore setup (skips DeerOutageSensor-specific restore) + await super().async_added_to_hass() + + if (last_state := await self.async_get_last_state()) is not None: + _LOGGER.debug( + "Restoring last-value state for %s: %s", + self.entity_id, + last_state.state, + ) + if last_state.state not in ("unknown", "unavailable"): + if self._attr_device_class == SensorDeviceClass.TIMESTAMP: + try: + self._cached_value = datetime.fromisoformat(last_state.state) + except (ValueError, TypeError) as err: + _LOGGER.warning("Failed to restore last datetime: %s", err) + else: + self._cached_value = last_state.state + # Write state immediately so the restored value is visible before + # the first coordinator poll (avoids a brief "unavailable" flash). + self.async_write_ha_state() diff --git a/custom_components/deer_outages/strings.json b/custom_components/deer_outages/strings.json new file mode 100644 index 0000000..fccea7b --- /dev/null +++ b/custom_components/deer_outages/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "title": "DEER Power Outages Setup", + "description": "Configure monitoring for power outages in your area", + "data": { + "region": "DEER Region", + "city": "City/Village", + "street": "Street (optional)", + "normal_interval": "Normal Polling Interval (minutes)", + "outage_interval": "Outage Polling Interval (minutes)" + } + } + }, + "error": { + "invalid_region": "Invalid region selected. Must be TN, TS, or MN.", + "empty_city": "City/Village cannot be empty.", + "invalid_interval": "Polling interval must be a positive number." + }, + "abort": { + "already_configured": "This location is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "DEER Power Outages Options", + "description": "Update configuration for power outage monitoring", + "data": { + "region": "DEER Region", + "city": "City/Village", + "street": "Street (optional)", + "normal_interval": "Normal Polling Interval (minutes)", + "outage_interval": "Outage Polling Interval (minutes)" + } + } + } + } +} diff --git a/custom_components/deer_outages/switch.py b/custom_components/deer_outages/switch.py new file mode 100644 index 0000000..26cf269 --- /dev/null +++ b/custom_components/deer_outages/switch.py @@ -0,0 +1,121 @@ +"""Switch platform for DEER Power Outages.""" +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, SWITCH_OUTAGE_MODE +from .coordinator import DeerOutageCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up DEER Outage mode switch. + + Args: + hass: Home Assistant instance + entry: Config entry + async_add_entities: Callback to add entities + """ + coordinator: DeerOutageCoordinator = hass.data[DOMAIN][entry.entry_id] + + # Create the outage mode switch + async_add_entities([DeerOutageModeSwitch(coordinator)]) + + +class DeerOutageModeSwitch(CoordinatorEntity, RestoreEntity, SwitchEntity): + """Switch to control outage polling mode.""" + + def __init__(self, coordinator: DeerOutageCoordinator) -> None: + """Initialize the switch. + + Args: + coordinator: Data update coordinator + """ + super().__init__(coordinator) + self._attr_has_entity_name = True + + # Set unique ID + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{SWITCH_OUTAGE_MODE}" + ) + + # Set name and icon + self._attr_name = "Outage Polling Mode" + self._attr_icon = "mdi:clock-fast" + + # Initialize state + self._attr_is_on = False + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional state attributes. + + Returns: + Dictionary of attributes + """ + return { + "description": "Enable fast polling during power outages", + "normal_interval_minutes": self.coordinator._normal_interval, + "outage_interval_minutes": self.coordinator._outage_interval, + "current_interval_minutes": ( + self.coordinator._outage_interval + if self._attr_is_on + else self.coordinator._normal_interval + ), + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable outage mode (fast polling). + + Args: + **kwargs: Additional arguments + """ + _LOGGER.info("Enabling outage polling mode") + self._attr_is_on = True + await self.coordinator.set_outage_mode(True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable outage mode (normal polling). + + Args: + **kwargs: Additional arguments + """ + _LOGGER.info("Disabling outage polling mode") + self._attr_is_on = False + await self.coordinator.set_outage_mode(False) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity added to Home Assistant. + + Restores previous state and syncs coordinator. + """ + await super().async_added_to_hass() + + # Restore previous state + if (last_state := await self.async_get_last_state()) is not None: + _LOGGER.debug( + "Restoring previous state for %s: %s", + self.entity_id, + last_state.state, + ) + self._attr_is_on = last_state.state == STATE_ON + + # Sync coordinator interval with restored state + await self.coordinator.set_outage_mode(self._attr_is_on) + + # Update HA state + self.async_write_ha_state() diff --git a/custom_components/deer_outages/translations/en.json b/custom_components/deer_outages/translations/en.json new file mode 100644 index 0000000..fccea7b --- /dev/null +++ b/custom_components/deer_outages/translations/en.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "title": "DEER Power Outages Setup", + "description": "Configure monitoring for power outages in your area", + "data": { + "region": "DEER Region", + "city": "City/Village", + "street": "Street (optional)", + "normal_interval": "Normal Polling Interval (minutes)", + "outage_interval": "Outage Polling Interval (minutes)" + } + } + }, + "error": { + "invalid_region": "Invalid region selected. Must be TN, TS, or MN.", + "empty_city": "City/Village cannot be empty.", + "invalid_interval": "Polling interval must be a positive number." + }, + "abort": { + "already_configured": "This location is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "DEER Power Outages Options", + "description": "Update configuration for power outage monitoring", + "data": { + "region": "DEER Region", + "city": "City/Village", + "street": "Street (optional)", + "normal_interval": "Normal Polling Interval (minutes)", + "outage_interval": "Outage Polling Interval (minutes)" + } + } + } + } +} diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..1c9d435 --- /dev/null +++ b/test_api.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Standalone test script for DEER Power Outages API. + +This script tests the DEER API endpoints without any Home Assistant dependencies. +Usage: python test_api.py [REGION] [CITY] [STREET] +Example: python test_api.py TN Cluj-Napoca "Strada Avram Iancu" +""" + +import asyncio +import aiohttp +import json +import sys +import re +from typing import Optional + + +API_BASE_URL = "https://outages.distributie-energie.ro/api" +API_INCIDENTS = "/incidents/{region}/0" +API_SCHEDULED_15 = "/scheduled/{region}/0/15" +API_SCHEDULED_TODAY = "/scheduled/{region}/0/azi" + + +def strip_html_tags(text: str) -> str: + """Remove HTML tags from text.""" + if not text: + return text + return re.sub(r'<[^>]+>', ' ', text).strip() + + +def matches_location_planned(item: dict, city: str, street: Optional[str]) -> bool: + """Check if planned outage matches configured location.""" + fields_to_check = [ + item.get("ansambluFunctional", ""), + item.get("adreseLucrari", ""), + ] + + # Combine all fields to search + combined = " ".join(str(f) for f in fields_to_check if f).lower() + + # Check city (required) + if city.lower() not in combined: + return False + + # Check street (optional) + if street and street.strip(): + if street.lower() not in combined: + return False + + return True + + +def matches_location_unplanned(item: dict, city: str, street: Optional[str]) -> bool: + """Check if unplanned outage matches configured location.""" + adresa = item.get("adresa", "").lower() + + # Check city (required) + if city.lower() not in adresa: + return False + + # Check street (optional) + if street and street.strip(): + if street.lower() not in adresa: + return False + + return True + + +async def test_incidents(session: aiohttp.ClientSession, region: str, city: Optional[str] = None, street: Optional[str] = None): + """Test unplanned incidents endpoint.""" + url = API_BASE_URL + API_INCIDENTS.format(region=region) + + print(f"\n{'='*80}") + print(f"Testing Incidents (Unplanned) - Region: {region}") + print(f"URL: {url}") + print(f"{'='*80}") + + try: + async with asyncio.timeout(30): + async with session.get(url) as response: + print(f"Status: {response.status}") + response.raise_for_status() + data = await response.json() + + print(f"Total incidents: {len(data)}") + + if city: + filtered = [item for item in data if matches_location_unplanned(item, city, street)] + print(f"Filtered incidents for {city}" + (f", {street}" if street else "") + f": {len(filtered)}") + data = filtered + + if data: + print(f"\nFirst {min(3, len(data))} incidents:") + for i, item in enumerate(data[:3], 1): + print(f"\n--- Incident {i} ---") + print(f"ID: {item.get('id')}") + print(f"County: {item.get('judet')}") + print(f"Address: {strip_html_tags(item.get('adresa', ''))}") + print(f"Start: {item.get('dataStart')}") + print(f"End: {item.get('dataStop')}") + else: + print("No incidents found" + (f" for {city}" if city else "")) + + except asyncio.TimeoutError: + print("ERROR: Timeout connecting to API") + except aiohttp.ClientError as err: + print(f"ERROR: HTTP error - {err}") + except json.JSONDecodeError: + print("ERROR: Invalid JSON response") + except Exception as err: + print(f"ERROR: Unexpected error - {err}") + + +async def test_scheduled_15(session: aiohttp.ClientSession, region: str, city: Optional[str] = None, street: Optional[str] = None): + """Test scheduled outages (15 days) endpoint.""" + url = API_BASE_URL + API_SCHEDULED_15.format(region=region) + + print(f"\n{'='*80}") + print(f"Testing Scheduled Outages (15 days) - Region: {region}") + print(f"URL: {url}") + print(f"{'='*80}") + + try: + async with asyncio.timeout(30): + async with session.get(url) as response: + print(f"Status: {response.status}") + response.raise_for_status() + data = await response.json() + + print(f"Total scheduled outages: {len(data)}") + + if city: + filtered = [item for item in data if matches_location_planned(item, city, street)] + print(f"Filtered outages for {city}" + (f", {street}" if street else "") + f": {len(filtered)}") + data = filtered + + if data: + print(f"\nFirst {min(3, len(data))} scheduled outages:") + for i, item in enumerate(data[:3], 1): + print(f"\n--- Scheduled Outage {i} ---") + print(f"ID: {item.get('id')}") + print(f"Branch: {item.get('sucursala')}") + print(f"Location: {item.get('ansambluFunctional')}") + print(f"Work Address: {item.get('adreseLucrari')}") + print(f"Start: {item.get('dataProgramareStart')}") + print(f"End: {item.get('dataProgramareStop')}") + print(f"Duration: {item.get('durataProgramare')}") + else: + print("No scheduled outages found" + (f" for {city}" if city else "")) + + except asyncio.TimeoutError: + print("ERROR: Timeout connecting to API") + except aiohttp.ClientError as err: + print(f"ERROR: HTTP error - {err}") + except json.JSONDecodeError: + print("ERROR: Invalid JSON response") + except Exception as err: + print(f"ERROR: Unexpected error - {err}") + + +async def test_scheduled_today(session: aiohttp.ClientSession, region: str, city: Optional[str] = None, street: Optional[str] = None): + """Test scheduled outages (today) endpoint.""" + url = API_BASE_URL + API_SCHEDULED_TODAY.format(region=region) + + print(f"\n{'='*80}") + print(f"Testing Scheduled Outages (Today) - Region: {region}") + print(f"URL: {url}") + print(f"{'='*80}") + + try: + async with asyncio.timeout(30): + async with session.get(url) as response: + print(f"Status: {response.status}") + response.raise_for_status() + data = await response.json() + + print(f"Total today's outages: {len(data)}") + + if city: + filtered = [item for item in data if matches_location_planned(item, city, street)] + print(f"Filtered outages for {city}" + (f", {street}" if street else "") + f": {len(filtered)}") + data = filtered + + if data: + print(f"\nAll today's scheduled outages:") + for i, item in enumerate(data, 1): + print(f"\n--- Today's Outage {i} ---") + print(f"ID: {item.get('id')}") + print(f"Branch: {item.get('sucursala')}") + print(f"Location: {item.get('ansambluFunctional')}") + print(f"Work Address: {item.get('adreseLucrari')}") + print(f"Start: {item.get('dataProgramareStart')}") + print(f"End: {item.get('dataProgramareStop')}") + print(f"Duration: {item.get('durataProgramare')}") + else: + print("No outages scheduled for today" + (f" for {city}" if city else "")) + + except asyncio.TimeoutError: + print("ERROR: Timeout connecting to API") + except aiohttp.ClientError as err: + print(f"ERROR: HTTP error - {err}") + except json.JSONDecodeError: + print("ERROR: Invalid JSON response") + except Exception as err: + print(f"ERROR: Unexpected error - {err}") + + +async def main(region: str = "TN", city: Optional[str] = None, street: Optional[str] = None): + """Main test function.""" + print(f"\n{'#'*80}") + print(f"# DEER Power Outages API Test") + print(f"# Region: {region}") + if city: + print(f"# City: {city}") + if street: + print(f"# Street: {street}") + print(f"{'#'*80}") + + async with aiohttp.ClientSession() as session: + # Test all three endpoints + await test_incidents(session, region, city, street) + await test_scheduled_15(session, region, city, street) + await test_scheduled_today(session, region, city, street) + + print(f"\n{'#'*80}") + print("# Test completed") + print(f"{'#'*80}\n") + + +def test_filtering_logic(): + """Test location filtering logic with sample data.""" + print(f"\n{'='*80}") + print("Testing Filtering Logic") + print(f"{'='*80}\n") + + # Sample planned outage data + planned_sample = { + "id": "82540", + "ansambluFunctional": "CALINESTI, Strada PRINCIPALA", + "adreseLucrari": "Zona Centrala", + "dataProgramareStart": "14/01/2026 08:00", + "dataProgramareStop": "14/01/2026 15:00", + "durataProgramare": "7h : 0'" + } + + # Sample unplanned incident data + unplanned_sample = { + "id": "69706", + "adresa": "Loc: SURA MARE, Strada: VETERANILOR
Loc: SURA MARE", + "dataStart": "10/01/2026 01:00", + "dataStop": "" + } + + # Test cases + test_cases = [ + ("Planned - Match city only", planned_sample, "CALINESTI", None, matches_location_planned, True), + ("Planned - Match city (case insensitive)", planned_sample, "calinesti", None, matches_location_planned, True), + ("Planned - Match city and street", planned_sample, "CALINESTI", "PRINCIPALA", matches_location_planned, True), + ("Planned - City match, wrong street", planned_sample, "CALINESTI", "SECUNDARA", matches_location_planned, False), + ("Planned - Wrong city", planned_sample, "CLUJ", None, matches_location_planned, False), + ("Unplanned - Match city only", unplanned_sample, "SURA MARE", None, matches_location_unplanned, True), + ("Unplanned - Match city (case insensitive)", unplanned_sample, "sura mare", None, matches_location_unplanned, True), + ("Unplanned - Match city and street", unplanned_sample, "SURA MARE", "VETERANILOR", matches_location_unplanned, True), + ("Unplanned - City match, wrong street", unplanned_sample, "SURA MARE", "CENTRALA", matches_location_unplanned, False), + ("Unplanned - Wrong city", unplanned_sample, "CLUJ", None, matches_location_unplanned, False), + ] + + passed = 0 + failed = 0 + + for test_name, data, city, street, func, expected in test_cases: + result = func(data, city, street) + status = "✓ PASS" if result == expected else "✗ FAIL" + if result == expected: + passed += 1 + else: + failed += 1 + print(f"{status}: {test_name}") + if result != expected: + print(f" Expected: {expected}, Got: {result}") + + print(f"\n{'='*80}") + print(f"Results: {passed} passed, {failed} failed") + print(f"{'='*80}\n") + + +if __name__ == "__main__": + # Parse command line arguments + region = sys.argv[1] if len(sys.argv) > 1 else "TN" + city = sys.argv[2] if len(sys.argv) > 2 else None + street = sys.argv[3] if len(sys.argv) > 3 else None + + # Validate region + if region not in ["TN", "TS", "MN"]: + print(f"ERROR: Invalid region '{region}'. Must be one of: TN, TS, MN") + sys.exit(1) + + # Run filtering logic tests + test_filtering_logic() + + # Run API tests + asyncio.run(main(region, city, street))