Initial commit
This commit is contained in:
+78
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
Reference in New Issue
Block a user