Add air alert levels to Ukraine Alarm (#181558)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oleksii
2026-09-10 14:11:18 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 3521db15fa
commit 86501aa473
6 changed files with 165 additions and 2 deletions
@@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ALERT_TYPE_AIR,
ALERT_TYPE_AIR_RED,
ALERT_TYPE_AIR_YELLOW,
ALERT_TYPE_ARTILLERY,
ALERT_TYPE_CHEMICAL,
ALERT_TYPE_NUCLEAR,
@@ -37,6 +39,16 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
translation_key="air",
device_class=BinarySensorDeviceClass.SAFETY,
),
BinarySensorEntityDescription(
key=ALERT_TYPE_AIR_RED,
translation_key="air_red",
device_class=BinarySensorDeviceClass.SAFETY,
),
BinarySensorEntityDescription(
key=ALERT_TYPE_AIR_YELLOW,
translation_key="air_yellow",
device_class=BinarySensorDeviceClass.SAFETY,
),
BinarySensorEntityDescription(
key=ALERT_TYPE_URBAN_FIGHTS,
translation_key="urban_fights",
@@ -113,4 +125,4 @@ class UkraineAlarmSensor(
@override
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.coordinator.data.get(self.entity_description.key, None)
return self.coordinator.data[self.entity_description.key]
@@ -7,6 +7,8 @@ ATTRIBUTION = "Data provided by Ukraine Alarm"
MANUFACTURER = "Ukraine Alarm"
ALERT_TYPE_UNKNOWN = "UNKNOWN"
ALERT_TYPE_AIR = "AIR"
ALERT_TYPE_AIR_RED = "AIR_RED"
ALERT_TYPE_AIR_YELLOW = "AIR_YELLOW"
ALERT_TYPE_ARTILLERY = "ARTILLERY"
ALERT_TYPE_URBAN_FIGHTS = "URBAN_FIGHTS"
ALERT_TYPE_CHEMICAL = "CHEMICAL"
@@ -14,9 +16,16 @@ ALERT_TYPE_NUCLEAR = "NUCLEAR"
ALERT_TYPES = {
ALERT_TYPE_UNKNOWN,
ALERT_TYPE_AIR,
ALERT_TYPE_AIR_RED,
ALERT_TYPE_AIR_YELLOW,
ALERT_TYPE_ARTILLERY,
ALERT_TYPE_URBAN_FIGHTS,
ALERT_TYPE_CHEMICAL,
ALERT_TYPE_NUCLEAR,
}
# The API attaches levels to all alert types, but they are only meaningful for AIR.
AIR_ALERT_LEVELS = {
"red": ALERT_TYPE_AIR_RED,
"yellow": ALERT_TYPE_AIR_YELLOW,
}
PLATFORMS = [Platform.BINARY_SENSOR]
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_REGION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ALERT_TYPES, DOMAIN
from .const import AIR_ALERT_LEVELS, ALERT_TYPE_AIR, ALERT_TYPES, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -57,4 +57,10 @@ class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for alert in res[0]["activeAlerts"]:
current[alert["type"]] = True
if alert["type"] != ALERT_TYPE_AIR:
continue
for level in alert.get("activeAlertLevels") or []:
if key := AIR_ALERT_LEVELS.get(str(level.get("alertLevel")).lower()):
current[key] = True
return current
@@ -4,6 +4,12 @@
"air": {
"default": "mdi:cloud"
},
"air_red": {
"default": "mdi:cloud-alert"
},
"air_yellow": {
"default": "mdi:cloud-alert-outline"
},
"artillery": {
"default": "mdi:tank"
},
@@ -34,6 +34,12 @@
"air": {
"name": "Air"
},
"air_red": {
"name": "Air (red)"
},
"air_yellow": {
"name": "Air (yellow)"
},
"artillery": {
"name": "Artillery"
},
@@ -0,0 +1,124 @@
"""Test the Ukraine Alarm binary sensors."""
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.ukraine_alarm.const import DOMAIN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
REGION_ID = "2.1"
ALL_KEYS = (
"UNKNOWN",
"AIR",
"AIR_RED",
"AIR_YELLOW",
"ARTILLERY",
"URBAN_FIGHTS",
"CHEMICAL",
"NUCLEAR",
)
async def setup_integration(
hass: HomeAssistant, active_alerts: list[dict[str, Any]]
) -> None:
"""Set up the integration against a stubbed region payload."""
entry = MockConfigEntry(
domain=DOMAIN,
version=2,
data={"region": REGION_ID, "name": f"District {REGION_ID}"},
unique_id=REGION_ID,
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.ukraine_alarm.coordinator.Client.get_alerts",
return_value=[{"activeAlerts": active_alerts}],
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
def _state(hass: HomeAssistant, entity_registry: er.EntityRegistry, key: str) -> State:
"""Return the state of the binary sensor for an alert type.
Entity ids are derived from translated names, which are generated from
strings.json at build time and so are not available in a plain checkout.
"""
entity_id = entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, f"{REGION_ID}-{key}".lower()
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
return state
def _level(alert_level: str) -> dict[str, Any]:
"""Build one reported alert level as the API serves it."""
return {
"alertLevel": alert_level,
"reason": "Ракетна загроза (червоний рівень)",
"createdAt": "2026-09-08T11:40:15.440927Z",
}
def _alert(
alert_type: str = "AIR", levels: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
"""Build an active alert, optionally carrying alert levels."""
alert: dict[str, Any] = {"type": alert_type}
if levels is not None:
alert["activeAlertLevels"] = levels
return alert
@pytest.mark.parametrize(
("active_alerts", "expected_on"),
[
([], set()),
# An air alert without levels keeps working as it did before.
([_alert()], {"AIR"}),
([_alert(levels=[])], {"AIR"}),
([_alert(levels=[_level("Red")])], {"AIR", "AIR_RED"}),
([_alert(levels=[_level("Yellow")])], {"AIR", "AIR_YELLOW"}),
# One air alert can report both levels at the same time.
(
[_alert(levels=[_level("Red"), _level("Yellow")])],
{"AIR", "AIR_RED", "AIR_YELLOW"},
),
# So can two air alerts that are active for the region at once.
(
[_alert(levels=[_level("Yellow")]), _alert(levels=[_level("Red")])],
{"AIR", "AIR_RED", "AIR_YELLOW"},
),
# The API attaches levels to all alert types, but they are only
# meaningful for AIR, so the other types are left alone.
([_alert("ARTILLERY", [_level("Red")])], {"ARTILLERY"}),
([_alert("URBAN_FIGHTS", [_level("Red")])], {"URBAN_FIGHTS"}),
# A level the integration does not know must not break the update.
([_alert(levels=[_level("Purple")])], {"AIR"}),
(
[_alert("AIR", [_level("Red")]), _alert("NUCLEAR")],
{"AIR", "AIR_RED", "NUCLEAR"},
),
],
)
async def test_alerts(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
active_alerts: list[dict[str, Any]],
expected_on: set[str],
) -> None:
"""Test each alert turns on exactly the binary sensors it maps to."""
await setup_integration(hass, active_alerts)
for key in ALL_KEYS:
state = _state(hass, entity_registry, key)
assert state.state == ("on" if key in expected_on else "off"), key