Initial commit

This commit is contained in:
2026-04-06 15:34:01 +03:00
commit bd5dc07ed8
13 changed files with 2081 additions and 0 deletions
+78
View File
@@ -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
+231
View File
@@ -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<br />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
@@ -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
+198
View File
@@ -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
@@ -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
)
+190
View File
@@ -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"
@@ -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()
@@ -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"
}
+325
View File
@@ -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()
@@ -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)"
}
}
}
}
}
+121
View File
@@ -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()
@@ -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)"
}
}
}
}
}
+301
View File
@@ -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<br />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))