diff --git a/custom_components/hochwasserportal/__init__.py b/custom_components/hochwasserportal/__init__.py new file mode 100644 index 0000000..6c20ce9 --- /dev/null +++ b/custom_components/hochwasserportal/__init__.py @@ -0,0 +1,76 @@ +"""The Länderübergreifendes Hochwasser Portal integration.""" + +from __future__ import annotations + +from lhpapi import HochwasserPortalAPI, LHPError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_ADD_UNAVAILABLE, + CONF_PEGEL_IDENTIFIER, + DOMAIN, + LOGGER, + PLATFORMS, +) +from .coordinator import HochwasserPortalCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + pegel_identifier: str = entry.data[CONF_PEGEL_IDENTIFIER] + + # Initialize the API and coordinator. + try: + api = await hass.async_add_executor_job(HochwasserPortalAPI, pegel_identifier) + coordinator = HochwasserPortalCoordinator(hass, api) + except LHPError as err: + LOGGER.exception("Setup of %s failed: %s", pegel_identifier, err) + return False + + # No need to refresh via the following line because api runs + # update during init automatically + # await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug( + "Migrating %s from version %s.%s", + config_entry.title, + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + new = {**config_entry.data} + if config_entry.minor_version < 2: + new[CONF_ADD_UNAVAILABLE] = True # Behaviour as in 1.1 + config_entry.minor_version = 2 + hass.config_entries.async_update_entry(config_entry, data=new) + + LOGGER.debug( + "Migration of %s to version %s.%s successful", + config_entry.title, + config_entry.version, + config_entry.minor_version, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/hochwasserportal/config_flow.py b/custom_components/hochwasserportal/config_flow.py new file mode 100644 index 0000000..94aef41 --- /dev/null +++ b/custom_components/hochwasserportal/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for the hochwasserportal integration.""" + +from __future__ import annotations + +from typing import Any + +from lhpapi import HochwasserPortalAPI, LHPError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import CONF_ADD_UNAVAILABLE, CONF_PEGEL_IDENTIFIER, DOMAIN, LOGGER + + +class HochwasserPortalConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the hochwasserportal integration.""" + + VERSION = 1 + MINOR_VERSION = 2 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict = {} + + if user_input is not None: + pegel_identifier = user_input[CONF_PEGEL_IDENTIFIER] + + # Validate pegel identifier using the API + try: + api = await self.hass.async_add_executor_job( + HochwasserPortalAPI, pegel_identifier + ) + LOGGER.debug( + "%s (%s): Successfully added!", + api.ident, + api.name, + ) + except LHPError as err: + LOGGER.exception("Setup of %s failed: %s", pegel_identifier, err) + errors["base"] = "invalid_identifier" + + if not errors: + # Set the unique ID for this config entry. + await self.async_set_unique_id(f"{DOMAIN}_{pegel_identifier.lower()}") + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=f"{api.name}", data=user_input) + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_PEGEL_IDENTIFIER): cv.string, + vol.Required(CONF_ADD_UNAVAILABLE, default=False): cv.boolean, + } + ), + ) diff --git a/custom_components/hochwasserportal/const.py b/custom_components/hochwasserportal/const.py new file mode 100644 index 0000000..7127a3b --- /dev/null +++ b/custom_components/hochwasserportal/const.py @@ -0,0 +1,46 @@ +"""Constants for the Länderübergreifendes Hochwasser Portal integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "hochwasserportal" + +CONF_PEGEL_IDENTIFIER: Final = "pegel_identifier" +CONF_ADD_UNAVAILABLE: Final = "add_unavailable" + +ATTR_DATA_PROVIDERS: Final[dict[str, str]] = { + "BB": "LfU Brandenburg", + "BE": "SenMVKU Berlin", + "BW": "LUBW Baden-Württemberg", + "BY": "LfU Bayern", + "HB": "SUKW Bremen", + "HE": "HLNUG", + "HH": "LSBG Hamburg", + "MV": "LUNG Mecklenburg-Vorpommern", + "NI": "NLWKN", + "NW": "LANUV Nordrhein-Westfalen", + "RP": "Luf Rheinland-Pfalz", + "SH": "Luf Schleswig-Holstein", + "SL": "LUA Saarland", + "SN": "LfULG Sachsen", + "ST": "Land Sachsen-Anhalt", + "TH": "TLUBN", +} +ATTR_LAST_UPDATE: Final = "last_update" +ATTR_URL: Final = "url" +ATTR_HINT: Final = "hint" + +LEVEL_SENSOR: Final = "level" +STAGE_SENSOR: Final = "stage" +FLOW_SENSOR: Final = "flow" + +DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=15) + +PLATFORMS: Final[list[Platform]] = [Platform.SENSOR] diff --git a/custom_components/hochwasserportal/coordinator.py b/custom_components/hochwasserportal/coordinator.py new file mode 100644 index 0000000..faa6ef6 --- /dev/null +++ b/custom_components/hochwasserportal/coordinator.py @@ -0,0 +1,32 @@ +"""Data coordinator for the hochwasserportal integration.""" + +from __future__ import annotations + +from lhpapi import HochwasserPortalAPI, LHPError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + + +class HochwasserPortalCoordinator(DataUpdateCoordinator[None]): + """Custom coordinator for the hochwasserportal integration.""" + + def __init__(self, hass: HomeAssistant, api: HochwasserPortalAPI) -> None: + """Initialize the hochwasserportal coordinator.""" + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + + self.api = api + LOGGER.debug("%s", repr(self.api)) + + async def _async_update_data(self) -> None: + """Get the latest data from the hochwasserportal API.""" + try: + await self.hass.async_add_executor_job(self.api.update) + LOGGER.debug("%s", repr(self.api)) + except LHPError as err: + LOGGER.exception("Update of %s failed: %s", self.api.ident, err) + return False diff --git a/custom_components/hochwasserportal/manifest.json b/custom_components/hochwasserportal/manifest.json new file mode 100644 index 0000000..d888608 --- /dev/null +++ b/custom_components/hochwasserportal/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "hochwasserportal", + "name": "Länderübergreifendes Hochwasser Portal", + "codeowners": ["@stephan192"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/stephan192/hochwasserportal", + "integration_type": "device", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/stephan192/hochwasserportal/issues", + "loggers": ["hochwasserportal"], + "requirements": ["lhpapi==1.0.3"], + "version": "1.0.1" +} \ No newline at end of file diff --git a/custom_components/hochwasserportal/sensor.py b/custom_components/hochwasserportal/sensor.py new file mode 100644 index 0000000..09b2ecd --- /dev/null +++ b/custom_components/hochwasserportal/sensor.py @@ -0,0 +1,148 @@ +"""Platform for sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from lhpapi import HochwasserPortalAPI + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_DATA_PROVIDERS, + ATTR_HINT, + ATTR_LAST_UPDATE, + ATTR_URL, + CONF_ADD_UNAVAILABLE, + DOMAIN, + FLOW_SENSOR, + LEVEL_SENSOR, + LOGGER, + STAGE_SENSOR, +) +from .coordinator import HochwasserPortalCoordinator + + +@dataclass(frozen=True, kw_only=True) +class HochwasserPortalSensorEntityDescription(SensorEntityDescription): + """Describes HochwasserPortal sensor entity.""" + + value_fn: Callable[[HochwasserPortalAPI], int | float | None] + available_fn: Callable[[HochwasserPortalAPI], bool] + + +SENSOR_TYPES: tuple[HochwasserPortalSensorEntityDescription, ...] = ( + HochwasserPortalSensorEntityDescription( + key=LEVEL_SENSOR, + translation_key=LEVEL_SENSOR, + icon="mdi:waves", + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda api: api.level, + available_fn=lambda api: api.level is not None, + ), + HochwasserPortalSensorEntityDescription( + key=STAGE_SENSOR, + translation_key=STAGE_SENSOR, + icon="mdi:waves-arrow-up", + native_unit_of_measurement=None, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda api: api.stage, + available_fn=lambda api: api.stage is not None, + ), + HochwasserPortalSensorEntityDescription( + key=FLOW_SENSOR, + translation_key=FLOW_SENSOR, + icon="mdi:waves-arrow-right", + native_unit_of_measurement="m³/s", + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda api: api.flow, + available_fn=lambda api: api.flow is not None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entities from config entry.""" + coordinator: HochwasserPortalCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + HochwasserPortalSensor(coordinator, entry, description) + for description in SENSOR_TYPES + if description.available_fn(coordinator.api) + or entry.data.get(CONF_ADD_UNAVAILABLE, False) + ] + ) + + +class HochwasserPortalSensor( + CoordinatorEntity[HochwasserPortalCoordinator], SensorEntity +): + """Sensor representation.""" + + entity_description: HochwasserPortalSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HochwasserPortalCoordinator, + entry: ConfigEntry, + description: HochwasserPortalSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.api = coordinator.api + self.entity_description = description + self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=f"{entry.title}", + configuration_url=self.api.url, + manufacturer=f"{ATTR_DATA_PROVIDERS[self.api.ident[:2]]}", + model=f"{self.api.ident}", + ) + self._attr_attribution = ( + f"Data provided by {ATTR_DATA_PROVIDERS[self.api.ident[:2]]}" + ) + LOGGER.debug("Setting up sensor: %s", self._attr_unique_id) + + @property + def native_value(self): + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.api) + + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + data = {} + if self.api.last_update is not None: + data[ATTR_LAST_UPDATE] = self.api.last_update + if self.api.url is not None: + data[ATTR_URL] = self.api.url + if self.api.hint is not None: + data[ATTR_HINT] = self.api.hint + if bool(data): + return data + return None + + @property + def available(self) -> bool: + """Could the device be accessed during the last update call.""" + return self.entity_description.available_fn(self.api) diff --git a/custom_components/hochwasserportal/string.json b/custom_components/hochwasserportal/string.json new file mode 100644 index 0000000..94db910 --- /dev/null +++ b/custom_components/hochwasserportal/string.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "description": "To identify the pegel, the pegel ID is required.", + "data": { + "pegel_identifier": "Pegel ID", + "add_unavailable": "Add unavailable entities anyway" + } + } + }, + "error": { + "invalid_identifier": "The specified pegel identifier is invalid." + }, + "abort": { + "already_configured": "Pegel is already configured.", + "invalid_identifier": "[%key:component::hochwasserportal::config::error::invalid_identifier%]" + } + }, + "entity": { + "sensor": { + "level": { + "name": "Level" + }, + "stage": { + "name": "Stage" + }, + "flow": { + "name": "Flow" + } + } + } +} \ No newline at end of file diff --git a/custom_components/hochwasserportal/translations/de.json b/custom_components/hochwasserportal/translations/de.json new file mode 100644 index 0000000..2726bbe --- /dev/null +++ b/custom_components/hochwasserportal/translations/de.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "description": "Um den Pegel zu identifizieren, ist die Pegel-ID erforderlich.", + "data": { + "pegel_identifier": "Pegel ID", + "add_unavailable": "Nicht verfügbare Entitäten trotzdem hinzufügen" + } + } + }, + "error": { + "invalid_identifier": "Der angegebene Pegel ist ungültig." + }, + "abort": { + "already_configured": "Pegel bereits konfiguriert.", + "invalid_identifier": "[%key:component::hochwasserportal::config::error::invalid_identifier%]" + } + }, + "entity": { + "sensor": { + "level": { + "name": "Pegelstand" + }, + "stage": { + "name": "Warnstufe" + }, + "flow": { + "name": "Abfluss" + } + } + } +} \ No newline at end of file diff --git a/custom_components/hochwasserportal/translations/en.json b/custom_components/hochwasserportal/translations/en.json new file mode 100644 index 0000000..94db910 --- /dev/null +++ b/custom_components/hochwasserportal/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "description": "To identify the pegel, the pegel ID is required.", + "data": { + "pegel_identifier": "Pegel ID", + "add_unavailable": "Add unavailable entities anyway" + } + } + }, + "error": { + "invalid_identifier": "The specified pegel identifier is invalid." + }, + "abort": { + "already_configured": "Pegel is already configured.", + "invalid_identifier": "[%key:component::hochwasserportal::config::error::invalid_identifier%]" + } + }, + "entity": { + "sensor": { + "level": { + "name": "Level" + }, + "stage": { + "name": "Stage" + }, + "flow": { + "name": "Flow" + } + } + } +} \ No newline at end of file