Add nina integration (#56647)

* Added nina integration

* Improvements implemented

* Fixed lint errors

* Added tests

* Improvements implemented

* Use client session from HA

* Added custom coordinator

* Fixed tests

* Fix pylint errors

* Library updated to 0.1.4

* Optimization of static attributes

* Removed unused code

* Switched to BinarySensorDeviceClass

* Switched to Platform Enum

* Improve repetition

* Improve repetition

* Fix corona filter

* Removed intermediate variable

Co-authored-by: Marvin Wichmann <marvin@fam-wichmann.de>

* Fix black formatting

Co-authored-by: Marvin Wichmann <marvin@fam-wichmann.de>
This commit is contained in:
Maximilian 2021-12-05 14:11:02 +00:00 committed by GitHub
parent cf371ea8dd
commit 9f7b8d3009
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 924 additions and 0 deletions

View File

@ -353,6 +353,7 @@ homeassistant/components/nextcloud/* @meichthys
homeassistant/components/nfandroidtv/* @tkdrob
homeassistant/components/nightscout/* @marciogranzotto
homeassistant/components/nilu/* @hfurubotten
homeassistant/components/nina/* @DeerMaximum
homeassistant/components/nissan_leaf/* @filcole
homeassistant/components/nmap_tracker/* @bdraco
homeassistant/components/nmbs/* @thibmaek

View File

@ -0,0 +1,106 @@
"""The Nina integration."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from async_timeout import timeout
from pynina import ApiError, Nina, Warning as NinaWarning
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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
_LOGGER,
ATTR_EXPIRES,
ATTR_HEADLINE,
ATTR_ID,
ATTR_SENT,
ATTR_START,
CONF_FILTER_CORONA,
CONF_REGIONS,
DOMAIN,
SCAN_INTERVAL,
)
PLATFORMS: list[str] = [Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up platform from a ConfigEntry."""
regions: dict[str, str] = entry.data[CONF_REGIONS]
coordinator = NINADataUpdateCoordinator(
hass, regions, entry.data[CONF_FILTER_CORONA]
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
class NINADataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching NINA data API."""
def __init__(
self, hass: HomeAssistant, regions: dict[str, str], corona_filter: bool
) -> None:
"""Initialize."""
self._regions: dict[str, str] = regions
self._nina: Nina = Nina(async_get_clientsession(hass))
self.warnings: dict[str, Any] = {}
self.corona_filter: bool = corona_filter
for region in regions.keys():
self._nina.addRegion(region)
update_interval: timedelta = SCAN_INTERVAL
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
async def _async_update_data(self) -> dict[str, Any]:
"""Update data."""
try:
async with timeout(10):
await self._nina.update()
return self._parse_data()
except ApiError as err:
raise UpdateFailed(err) from err
def _parse_data(self) -> dict[str, Any]:
"""Parse warning data."""
return_data: dict[str, Any] = {}
for (
region_id
) in self._nina.warnings: # pylint: disable=consider-using-dict-items
raw_warnings: list[NinaWarning] = self._nina.warnings[region_id]
warnings_for_regions: list[Any] = []
for raw_warn in raw_warnings:
if "corona" in raw_warn.headline.lower() and self.corona_filter:
continue
warn_obj: dict[str, Any] = {
ATTR_ID: raw_warn.id,
ATTR_HEADLINE: raw_warn.headline,
ATTR_SENT: raw_warn.sent or "",
ATTR_START: raw_warn.start or "",
ATTR_EXPIRES: raw_warn.expires or "",
}
warnings_for_regions.append(warn_obj)
return_data[region_id] = warnings_for_regions
return return_data

View File

@ -0,0 +1,94 @@
"""NINA sensor platform."""
from __future__ import annotations
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
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 . import NINADataUpdateCoordinator
from .const import (
ATTR_EXPIRES,
ATTR_HEADLINE,
ATTR_ID,
ATTR_SENT,
ATTR_START,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
DOMAIN,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entries."""
coordinator: NINADataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS]
entities: list[NINAMessage] = []
for ent in coordinator.data:
for i in range(0, message_slots):
entities.append(NINAMessage(coordinator, ent, regions[ent], i + 1))
async_add_entities(entities)
class NINAMessage(CoordinatorEntity, BinarySensorEntity):
"""Representation of an NINA warning."""
def __init__(
self,
coordinator: NINADataUpdateCoordinator,
region: str,
regionName: str,
slotID: int,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._region: str = region
self._region_name: str = regionName
self._slot_id: int = slotID
self._warning_index: int = slotID - 1
self._coordinator: NINADataUpdateCoordinator = coordinator
self._attr_name: str = f"Warning: {self._region_name} {self._slot_id}"
self._attr_unique_id: str = f"{self._region}-{self._slot_id}"
self._attr_device_class: str = BinarySensorDeviceClass.SAFETY
@property
def is_on(self) -> bool:
"""Return the state of the sensor."""
return len(self._coordinator.data[self._region]) > self._warning_index
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra attributes of the sensor."""
if (
not len(self._coordinator.data[self._region]) > self._warning_index
) or not self.is_on:
return {}
data: dict[str, Any] = self._coordinator.data[self._region][self._warning_index]
return {
ATTR_HEADLINE: data[ATTR_HEADLINE],
ATTR_ID: data[ATTR_ID],
ATTR_SENT: data[ATTR_SENT],
ATTR_START: data[ATTR_START],
ATTR_EXPIRES: data[ATTR_EXPIRES],
}

View File

@ -0,0 +1,138 @@
"""Config flow for Nina integration."""
from __future__ import annotations
from typing import Any
from pynina import ApiError, Nina
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv
from .const import (
_LOGGER,
CONF_FILTER_CORONA,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
CONST_REGION_MAPPING,
CONST_REGIONS,
DOMAIN,
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for NINA."""
VERSION: int = 1
def __init__(self) -> None:
"""Initialize."""
super().__init__()
self._all_region_codes_sorted: dict[str, str] = {}
self.regions: dict[str, dict[str, Any]] = {}
for name in CONST_REGIONS:
self.regions[name] = {}
async def async_step_user(
self: ConfigFlow,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, Any] = {}
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
has_error: bool = False
if len(self._all_region_codes_sorted) == 0:
try:
nina: Nina = Nina()
self._all_region_codes_sorted = self.swap_key_value(
await nina.getAllRegionalCodes()
)
self.split_regions()
except ApiError as err:
_LOGGER.warning("NINA setup error: %s", err)
errors["base"] = "cannot_connect"
has_error = True
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception: %s", err)
errors["base"] = "unknown"
return self.async_abort(reason="unknown")
if user_input is not None and not has_error:
config: dict[str, Any] = user_input
config[CONF_REGIONS] = []
for group in CONST_REGIONS:
if group_input := user_input.get(group):
config[CONF_REGIONS] += group_input
if len(config[CONF_REGIONS]) > 0:
tmp: dict[str, Any] = {}
for reg in config[CONF_REGIONS]:
tmp[self._all_region_codes_sorted[reg]] = reg.split("_", 1)[0]
compact: dict[str, Any] = {}
for key, val in tmp.items():
if val in compact:
compact[val] = f"{compact[val]} + {key}"
break
compact[val] = key
config[CONF_REGIONS] = compact
return self.async_create_entry(title="NINA", data=config)
errors["base"] = "no_selection"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
**{
vol.Optional(region): cv.multi_select(self.regions[region])
for region in CONST_REGIONS
},
vol.Required(CONF_MESSAGE_SLOTS, default=5): vol.All(
int, vol.Range(min=1, max=20)
),
vol.Required(CONF_FILTER_CORONA, default=True): cv.boolean,
}
),
errors=errors,
)
@staticmethod
def swap_key_value(dict_to_sort: dict[str, str]) -> dict[str, str]:
"""Swap keys and values in dict."""
all_region_codes_swaped: dict[str, str] = {}
for key, value in dict_to_sort.items():
if value not in all_region_codes_swaped:
all_region_codes_swaped[value] = key
else:
for i in range(len(dict_to_sort)):
tmp_value: str = value + "_" + str(i)
if tmp_value not in all_region_codes_swaped:
all_region_codes_swaped[tmp_value] = key
break
return dict(sorted(all_region_codes_swaped.items(), key=lambda ele: ele[1]))
def split_regions(self) -> None:
"""Split regions alphabetical."""
for index, name in self._all_region_codes_sorted.items():
for region_name, grouping_letters in CONST_REGION_MAPPING.items():
if name[0] in grouping_letters:
self.regions[region_name][index] = name
break

View File

@ -0,0 +1,53 @@
"""Constants for the Nina integration."""
from __future__ import annotations
from datetime import timedelta
from logging import Logger, getLogger
from typing import Final
_LOGGER: Logger = getLogger(__package__)
SCAN_INTERVAL: timedelta = timedelta(minutes=5)
DOMAIN: str = "nina"
CONF_REGIONS: str = "regions"
CONF_MESSAGE_SLOTS: str = "slots"
CONF_FILTER_CORONA: str = "corona_filter"
ATTR_HEADLINE: str = "Headline"
ATTR_ID: str = "ID"
ATTR_SENT: str = "Sent"
ATTR_START: str = "Start"
ATTR_EXPIRES: str = "Expires"
CONST_LIST_A_TO_D: list[str] = ["A", "Ä", "B", "C", "D"]
CONST_LIST_E_TO_H: list[str] = ["E", "F", "G", "H"]
CONST_LIST_I_TO_L: list[str] = ["I", "J", "K", "L"]
CONST_LIST_M_TO_Q: list[str] = ["M", "N", "O", "Ö", "P", "Q"]
CONST_LIST_R_TO_U: list[str] = ["R", "S", "T", "U", "Ü"]
CONST_LIST_V_TO_Z: list[str] = ["V", "W", "X", "Y"]
CONST_REGION_A_TO_D: Final = "_a_to_d"
CONST_REGION_E_TO_H: Final = "_e_to_h"
CONST_REGION_I_TO_L: Final = "_i_to_l"
CONST_REGION_M_TO_Q: Final = "_m_to_q"
CONST_REGION_R_TO_U: Final = "_r_to_u"
CONST_REGION_V_TO_Z: Final = "_v_to_z"
CONST_REGIONS: Final = [
CONST_REGION_A_TO_D,
CONST_REGION_E_TO_H,
CONST_REGION_I_TO_L,
CONST_REGION_M_TO_Q,
CONST_REGION_R_TO_U,
CONST_REGION_V_TO_Z,
]
CONST_REGION_MAPPING: dict[str, list[str]] = {
CONST_REGION_A_TO_D: CONST_LIST_A_TO_D,
CONST_REGION_E_TO_H: CONST_LIST_E_TO_H,
CONST_REGION_I_TO_L: CONST_LIST_I_TO_L,
CONST_REGION_M_TO_Q: CONST_LIST_M_TO_Q,
CONST_REGION_R_TO_U: CONST_LIST_R_TO_U,
CONST_REGION_V_TO_Z: CONST_LIST_V_TO_Z,
}

View File

@ -0,0 +1,14 @@
{
"domain": "nina",
"name": "NINA",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nina",
"requirements": [
"pynina==0.1.4"
],
"dependencies": [],
"codeowners": [
"@DeerMaximum"
],
"iot_class": "cloud_polling"
}

View File

@ -0,0 +1,27 @@
{
"config": {
"step":{
"user": {
"title": "Select city/county",
"data" : {
"_a_to_d": "City/county (A-D)",
"_e_to_h": "City/county (E-H)",
"_i_to_l": "City/county (I-L)",
"_m_to_q": "City/county (M-Q)",
"_r_to_u": "City/county (R-U)",
"_v_to_z": "City/county (V-Z)",
"slots": "Maximum warnings per city/county",
"corona_filter": "Remove Corona Warnings"
}
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"no_selection": "Please select at least one city/county",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@ -0,0 +1,27 @@
{
"config": {
"abort": {
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {
"cannot_connect": "Failed to connect",
"no_selection": "Please select at least one city/county",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"_a_to_d": "City/county (A-D)",
"_e_to_h": "City/county (E-H)",
"_i_to_l": "City/county (I-L)",
"_m_to_q": "City/county (M-Q)",
"_r_to_u": "City/county (R-U)",
"_v_to_z": "City/county (V-Z)",
"corona_filter": "Remove Corona Warnings",
"slots": "Maximum warnings per city/county"
},
"title": "Select city/county"
}
}
}
}

View File

@ -202,6 +202,7 @@ FLOWS = [
"nexia",
"nfandroidtv",
"nightscout",
"nina",
"nmap_tracker",
"notion",
"nuheat",

View File

@ -1669,6 +1669,9 @@ pynetgear==0.7.0
# homeassistant.components.netio
pynetio==0.1.9.1
# homeassistant.components.nina
pynina==0.1.4
# homeassistant.components.nuki
pynuki==1.4.1

View File

@ -1021,6 +1021,9 @@ pymysensors==0.22.1
# homeassistant.components.netgear
pynetgear==0.7.0
# homeassistant.components.nina
pynina==0.1.4
# homeassistant.components.nuki
pynuki==1.4.1

View File

@ -0,0 +1 @@
"""Tests for the Nina integration."""

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,44 @@
[
{
"id": "mow.DE-BW-S-SE018-20211102-18-001",
"payload": {
"version": 1,
"type": "ALERT",
"id": "mow.DE-BW-S-SE018-20211102-18-001",
"hash": "cae97b1c11bde900017305f681904ad5a6e8fd1c841241ced524b83eaa3522f4",
"data": {
"headline": "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen",
"provider": "MOWAS",
"severity": "Minor",
"msgType": "Update",
"transKeys": {"event": "BBK-EVC-040"},
"area": {"type": "ZGEM", "data": "9956+1102,100001"}
}
},
"i18nTitle": {
"de": "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen"
},
"sent": "2021-11-02T20:07:16+01:00"
},
{
"id": "mow.DE-NW-BN-SE030-20201014-30-000",
"payload": {
"version": 1,
"type": "ALERT",
"id": "mow.DE-NW-BN-SE030-20201014-30-000",
"hash": "551db820a43be7e4f39283e1dfb71b212cd520c3ee478d44f43519e9c48fde4c",
"data": {
"headline": "Ausfall Notruf 112",
"provider": "MOWAS",
"severity": "Minor",
"msgType": "Update",
"transKeys": {"event": "BBK-EVC-040"},
"area": {"type": "ZGEM", "data": "1+11057,100001"}
}
},
"i18nTitle": {"de": "Ausfall Notruf 112"},
"start": "2021-11-01T05:20:00+01:00",
"sent": "2021-10-11T05:20:00+01:00",
"expires": "3021-11-22T05:19:00+01:00"
}
]

View File

@ -0,0 +1,235 @@
"""Test the Nina binary sensor."""
import json
from typing import Any, Dict
from unittest.mock import patch
from pynina import ApiError
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.nina.const import (
ATTR_EXPIRES,
ATTR_HEADLINE,
ATTR_ID,
ATTR_SENT,
ATTR_START,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, load_fixture
ENTRY_DATA: Dict[str, Any] = {
"slots": 5,
"corona_filter": True,
"regions": {"083350000000": "Aach, Stadt"},
}
ENTRY_DATA_NO_CORONA: Dict[str, Any] = {
"slots": 5,
"corona_filter": False,
"regions": {"083350000000": "Aach, Stadt"},
}
async def test_sensors(hass: HomeAssistant) -> None:
"""Test the creation and values of the NINA sensors."""
dummy_response: Dict[str, Any] = json.loads(
load_fixture("sample_warnings.json", "nina")
)
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
return_value=dummy_response,
):
conf_entry: MockConfigEntry = MockConfigEntry(
domain=DOMAIN, title="NINA", data=ENTRY_DATA
)
entity_registry: er = er.async_get(hass)
conf_entry.add_to_hass(hass)
await hass.config_entries.async_setup(conf_entry.entry_id)
await hass.async_block_till_done()
assert conf_entry.state == ConfigEntryState.LOADED
state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1")
entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1")
assert state_w1.state == STATE_ON
assert state_w1.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112"
assert state_w1.attributes.get(ATTR_ID) == "mow.DE-NW-BN-SE030-20201014-30-000"
assert state_w1.attributes.get(ATTR_SENT) == "2021-10-11T05:20:00+01:00"
assert state_w1.attributes.get(ATTR_START) == "2021-11-01T05:20:00+01:00"
assert state_w1.attributes.get(ATTR_EXPIRES) == "3021-11-22T05:19:00+01:00"
assert entry_w1.unique_id == "083350000000-1"
assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2")
entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2")
assert state_w2.state == STATE_OFF
assert state_w2.attributes.get(ATTR_HEADLINE) is None
assert state_w2.attributes.get(ATTR_ID) is None
assert state_w2.attributes.get(ATTR_SENT) is None
assert state_w2.attributes.get(ATTR_START) is None
assert state_w2.attributes.get(ATTR_EXPIRES) is None
assert entry_w2.unique_id == "083350000000-2"
assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3")
entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3")
assert state_w3.state == STATE_OFF
assert state_w3.attributes.get(ATTR_HEADLINE) is None
assert state_w3.attributes.get(ATTR_ID) is None
assert state_w3.attributes.get(ATTR_SENT) is None
assert state_w3.attributes.get(ATTR_START) is None
assert state_w3.attributes.get(ATTR_EXPIRES) is None
assert entry_w3.unique_id == "083350000000-3"
assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4")
entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4")
assert state_w4.state == STATE_OFF
assert state_w4.attributes.get(ATTR_HEADLINE) is None
assert state_w4.attributes.get(ATTR_ID) is None
assert state_w4.attributes.get(ATTR_SENT) is None
assert state_w4.attributes.get(ATTR_START) is None
assert state_w4.attributes.get(ATTR_EXPIRES) is None
assert entry_w4.unique_id == "083350000000-4"
assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5")
entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5")
assert state_w5.state == STATE_OFF
assert state_w5.attributes.get(ATTR_HEADLINE) is None
assert state_w5.attributes.get(ATTR_ID) is None
assert state_w5.attributes.get(ATTR_SENT) is None
assert state_w5.attributes.get(ATTR_START) is None
assert state_w5.attributes.get(ATTR_EXPIRES) is None
assert entry_w5.unique_id == "083350000000-5"
assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None:
"""Test the creation and values of the NINA sensors without the corona filter."""
dummy_response: Dict[str, Any] = json.loads(
load_fixture("nina/sample_warnings.json")
)
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
return_value=dummy_response,
):
conf_entry: MockConfigEntry = MockConfigEntry(
domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_CORONA
)
entity_registry: er = er.async_get(hass)
conf_entry.add_to_hass(hass)
await hass.config_entries.async_setup(conf_entry.entry_id)
await hass.async_block_till_done()
assert conf_entry.state == ConfigEntryState.LOADED
state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1")
entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1")
assert state_w1.state == STATE_ON
assert (
state_w1.attributes.get(ATTR_HEADLINE)
== "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen"
)
assert state_w1.attributes.get(ATTR_ID) == "mow.DE-BW-S-SE018-20211102-18-001"
assert state_w1.attributes.get(ATTR_SENT) == "2021-11-02T20:07:16+01:00"
assert state_w1.attributes.get(ATTR_START) == ""
assert state_w1.attributes.get(ATTR_EXPIRES) == ""
assert entry_w1.unique_id == "083350000000-1"
assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2")
entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2")
assert state_w2.state == STATE_ON
assert state_w2.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112"
assert state_w2.attributes.get(ATTR_ID) == "mow.DE-NW-BN-SE030-20201014-30-000"
assert state_w2.attributes.get(ATTR_SENT) == "2021-10-11T05:20:00+01:00"
assert state_w2.attributes.get(ATTR_START) == "2021-11-01T05:20:00+01:00"
assert state_w2.attributes.get(ATTR_EXPIRES) == "3021-11-22T05:19:00+01:00"
assert entry_w2.unique_id == "083350000000-2"
assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3")
entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3")
assert state_w3.state == STATE_OFF
assert state_w3.attributes.get(ATTR_HEADLINE) is None
assert state_w3.attributes.get(ATTR_ID) is None
assert state_w3.attributes.get(ATTR_SENT) is None
assert state_w3.attributes.get(ATTR_START) is None
assert state_w3.attributes.get(ATTR_EXPIRES) is None
assert entry_w3.unique_id == "083350000000-3"
assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4")
entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4")
assert state_w4.state == STATE_OFF
assert state_w4.attributes.get(ATTR_HEADLINE) is None
assert state_w4.attributes.get(ATTR_ID) is None
assert state_w4.attributes.get(ATTR_SENT) is None
assert state_w4.attributes.get(ATTR_START) is None
assert state_w4.attributes.get(ATTR_EXPIRES) is None
assert entry_w4.unique_id == "083350000000-4"
assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5")
entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5")
assert state_w5.state == STATE_OFF
assert state_w5.attributes.get(ATTR_HEADLINE) is None
assert state_w5.attributes.get(ATTR_ID) is None
assert state_w5.attributes.get(ATTR_SENT) is None
assert state_w5.attributes.get(ATTR_START) is None
assert state_w5.attributes.get(ATTR_EXPIRES) is None
assert entry_w5.unique_id == "083350000000-5"
assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY
async def test_sensors_connection_error(hass: HomeAssistant) -> None:
"""Test the creation and values of the NINA sensors with no connected."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=ApiError("Could not connect to Api"),
):
conf_entry: MockConfigEntry = MockConfigEntry(
domain=DOMAIN, title="NINA", data=ENTRY_DATA
)
conf_entry.add_to_hass(hass)
await hass.config_entries.async_setup(conf_entry.entry_id)
await hass.async_block_till_done()
assert conf_entry.state == ConfigEntryState.SETUP_RETRY

View File

@ -0,0 +1,130 @@
"""Test the Nina config flow."""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import patch
from pynina import ApiError
from homeassistant import data_entry_flow
from homeassistant.components.nina.const import (
CONF_FILTER_CORONA,
CONF_MESSAGE_SLOTS,
CONST_REGION_A_TO_D,
CONST_REGION_E_TO_H,
CONST_REGION_I_TO_L,
CONST_REGION_M_TO_Q,
CONST_REGION_R_TO_U,
CONST_REGION_V_TO_Z,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from tests.common import load_fixture
DUMMY_DATA: dict[str, Any] = {
CONF_MESSAGE_SLOTS: 5,
CONST_REGION_A_TO_D: ["095760000000_0", "095760000000_1"],
CONST_REGION_E_TO_H: ["010610000000_0", "010610000000_1"],
CONST_REGION_I_TO_L: ["071320000000_0", "071320000000_1"],
CONST_REGION_M_TO_Q: ["071380000000_0", "071380000000_1"],
CONST_REGION_R_TO_U: ["072320000000_0", "072320000000_1"],
CONST_REGION_V_TO_Z: ["081270000000_0", "081270000000_1"],
CONF_FILTER_CORONA: True,
}
DUMMY_RESPONSE: dict[str, Any] = json.loads(load_fixture("sample_regions.json", "nina"))
async def test_show_set_form(hass: HomeAssistant) -> None:
"""Test that the setup form is served."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
return_value=DUMMY_RESPONSE,
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
async def test_step_user_connection_error(hass: HomeAssistant) -> None:
"""Test starting a flow by user but no connection."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=ApiError("Could not connect to Api"),
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None:
"""Test starting a flow by user but with an unexpected exception."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
side_effect=Exception("DUMMY"),
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
async def test_step_user(hass: HomeAssistant) -> None:
"""Test starting a flow by user with valid values."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
return_value=DUMMY_RESPONSE,
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "NINA"
async def test_step_user_no_selection(hass: HomeAssistant) -> None:
"""Test starting a flow by user with no selection."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
return_value=DUMMY_RESPONSE,
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "no_selection"}
async def test_step_user_already_configured(hass: HomeAssistant) -> None:
"""Test starting a flow by user but it was already configured."""
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
return_value=DUMMY_RESPONSE,
):
result: dict[str, Any] = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT

View File

@ -0,0 +1,46 @@
"""Test the Nina init file."""
import json
from typing import Any, Dict
from unittest.mock import patch
from homeassistant.components.nina.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_fixture
ENTRY_DATA: Dict[str, Any] = {
"slots": 5,
"corona_filter": True,
"regions": {"083350000000": "Aach, Stadt"},
}
async def init_integration(hass) -> MockConfigEntry:
"""Set up the NINA integration in Home Assistant."""
dummy_response: Dict[str, Any] = json.loads(
load_fixture("sample_warnings.json", "nina")
)
with patch(
"pynina.baseApi.BaseAPI._makeRequest",
return_value=dummy_response,
):
entry: MockConfigEntry = MockConfigEntry(
domain=DOMAIN, title="NINA", data=ENTRY_DATA
)
entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
return entry
async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
"""Test the configuration entry."""
entry: MockConfigEntry = await init_integration(hass)
assert entry.state == ConfigEntryState.LOADED