import os import re import json import urllib.parse import logging from datetime import timedelta, datetime from zoneinfo import ZoneInfo from typing import Any, Dict, Optional, Tuple from dateutil import tz from itertools import dropwhile, takewhile import aiohttp from homeassistant.const import Platform, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.components.sensor import RestoreSensor, SensorEntity from .const import ( DOMAIN, SENSOR_DEFINITIONS, CONF_INSEE_CODE, CONF_CITY, CONF_LOCATION_MODE, DEVICE_ID_KEY, HA_COORD, NAME, SENSOR_DEFINITIONS, LOCATION_MODES, VigieEauSensorEntityDescription, ) from .config_flow import get_insee_code_fromcoord from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from .api import VigieauApi, VigieauApiError _LOGGER = logging.getLogger(__name__) MIGRATED_FROM_VERSION_1 = "migrated_from_version_1" MIGRATED_FROM_VERSION_3 = "migrated_from_version_3" async def async_migrate_entry(hass, config_entry: ConfigEntry): if config_entry.version == 1: _LOGGER.warn("config entry version is 1, migrating to version 2") new = {**config_entry.data} insee_code, city_name, lat, lon = await get_insee_code_fromcoord(hass) new[CONF_INSEE_CODE] = insee_code new[CONF_CITY] = city_name new[CONF_LOCATION_MODE] = HA_COORD new[ DEVICE_ID_KEY ] = "Vigieau" # hardcoded to match hardcoded id from version 0.3.9 new[CONF_LATITUDE] = lat new[CONF_LONGITUDE] = lon new[MIGRATED_FROM_VERSION_1] = True _LOGGER.warn( f"Migration detected insee code for current HA instance is {insee_code} in {city_name}" ) config_entry.version = 3 hass.config_entries.async_update_entry(config_entry, data=new) if config_entry.version == 2: _LOGGER.warn("config entry version is 2, migrating to version 3") new = {**config_entry.data} insee_code, city_name, lat, lon = await get_insee_code_fromcoord(hass) new[CONF_LATITUDE] = lat new[CONF_LONGITUDE] = lon config_entry.version = 3 hass.config_entries.async_update_entry(config_entry, data=new) if config_entry.version == 3: _LOGGER.warn("config entry version is 3, migrating to version 4") new = {**config_entry.data} insee_code, city_name, lat, lon = await get_insee_code_fromcoord(hass) new[MIGRATED_FROM_VERSION_3] = True config_entry.version = 4 hass.config_entries.async_update_entry(config_entry, data=new) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) # here we store the coordinator for future access if entry.entry_id not in hass.data[DOMAIN]: hass.data[DOMAIN][entry.entry_id] = {} hass.data[DOMAIN][entry.entry_id]["vigieau_coordinator"] = VigieauAPICoordinator( hass, dict(entry.data) ) # will make sure async_setup_entry from sensor.py is called await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) # subscribe to config updates entry.async_on_unload(entry.add_update_listener(update_entry)) return True async def update_entry(hass, entry): """ This method is called when options are updated We trigger the reloading of entry (that will eventually call async_unload_entry) """ _LOGGER.debug("update_entry method called") # will make sure async_setup_entry from sensor.py is called await hass.config_entries.async_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """This method is called to clean all sensors before re-adding them""" _LOGGER.debug("async_unload_entry method called") unload_ok = await hass.config_entries.async_unload_platforms( entry, [Platform.SENSOR] ) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok class VigieauAPICoordinator(DataUpdateCoordinator): """A coordinator to fetch data from the api only once""" def __init__(self, hass, config: ConfigType): super().__init__( hass, _LOGGER, name="vigieau api", # for logging purpose update_interval=timedelta(hours=1), update_method=self.update_method, ) self.config = config self.hass = hass async def update_method(self): """Fetch data from API endpoint.""" try: _LOGGER.debug( f"Calling update method, {len(self._listeners)} listeners subscribed" ) if "VIGIEAU_APIFAIL" in os.environ: raise UpdateFailed( "Failing update on purpose to test state restoration" ) _LOGGER.debug("Starting collecting data") city_code = self.config[CONF_INSEE_CODE] lat = self.config[CONF_LATITUDE] long = self.config[CONF_LONGITUDE] session = async_get_clientsession(self.hass) vigieau = VigieauApi(session) try: # TODO(kamaradclimber): there 4 supported profils: particulier, entreprise, collectivite and exploitation data = await vigieau.get_data(lat, long, city_code, "particulier") except VigieauApiError as e: raise UpdateFailed(f"Failed fetching vigieau data: {e.text}") for usage in data["usages"]: found = False for sensor in SENSOR_DEFINITIONS: for matcher in sensor.matchers: if re.search( matcher, usage["nom"] + "|" + usage['thematique'], re.IGNORECASE, ): found = True if not found: report_data = json.dumps( {"insee code": city_code, "nom": usage["nom"]}, ensure_ascii=False, ) _LOGGER.warn( f"The following restriction is unknown from this integration, please report an issue with: {report_data}" ) return data except Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") class AlertLevelEntity(CoordinatorEntity, SensorEntity): """Expose the alert level for the location""" def __init__( self, coordinator: VigieauAPICoordinator, hass: HomeAssistant, config_entry: ConfigEntry, ): super().__init__(coordinator) self.hass = hass self._attr_name = f"Alert level in {config_entry.data.get(CONF_CITY)}" self._attr_native_value = None self._attr_state_attributes = None if MIGRATED_FROM_VERSION_1 in config_entry.data: self._attr_unique_id = "sensor-vigieau-Alert level" else: self._attr_unique_id = f"sensor-vigieau-{self._attr_name}-{config_entry.data.get(CONF_INSEE_CODE)}" self._attr_device_info = DeviceInfo( name=f"{NAME} {config_entry.data.get(CONF_CITY)}", entry_type=DeviceEntryType.SERVICE, identifiers={ ( DOMAIN, str(config_entry.data.get(DEVICE_ID_KEY)), ) }, manufacturer=NAME, model=config_entry.data.get(CONF_INSEE_CODE), ) def enrich_attributes(self, data: dict, key_source: str, key_target: str): if key_source in data: self._attr_state_attributes = self._attr_state_attributes or {} if key_source in data: self._attr_state_attributes[key_target] = data[key_source] @callback def _handle_coordinator_update(self) -> None: _LOGGER.debug(f"Receiving an update for {self.unique_id} sensor") if not self.coordinator.last_update_success: _LOGGER.debug("Last coordinator failed, assuming state has not changed") return self._attr_native_value = self.coordinator.data["niveauGravite"] self._attr_icon = { "vigilance": "mdi:water-check", "alerte": "mdi:water-alert", "alerte_renforcée": "mdi:water-remove", "alerte_renforcee": "mdi:water-remove", "crise": "mdi:water-off", }[self._attr_native_value.lower().replace(" ", "_")] self.enrich_attributes(self.coordinator.data, "cheminFichier", "source") self.enrich_attributes( self.coordinator.data, "cheminFichierArreteCadre", "source2" ) restrictions = [ restriction["nom"] for restriction in self.coordinator.data["usages"] ] self._attr_state_attributes = self._attr_state_attributes or {} self._attr_state_attributes["current_restrictions"] = ", ".join(restrictions) self.async_write_ha_state() @property def state_attributes(self): return self._attr_state_attributes class UsageRestrictionEntity(CoordinatorEntity, SensorEntity): """Expose a restriction for a given usage""" entity_description: VigieEauSensorEntityDescription def __init__( self, coordinator: VigieauAPICoordinator, hass: HomeAssistant, usage_id: str, config_entry: ConfigEntry, description: VigieEauSensorEntityDescription, ): super().__init__(coordinator) self.hass = hass # naming the attribute very early before it's updated by first api response is a hack # to make sure we have a decent entity_id selected by home assistant self._attr_name = ( f"{description.name}_restrictions_{config_entry.data.get(CONF_CITY)}" ) self._attr_native_value = None self._attr_state_attributes = None self._attr_entity_category = EntityCategory.DIAGNOSTIC self._config = description if MIGRATED_FROM_VERSION_1 in config_entry.data: self._attr_unique_id = f"sensor-vigieau-{self._config.key}" elif MIGRATED_FROM_VERSION_3 in config_entry.data: self._attr_unique_id = f"sensor-vigieau-{self._attr_name}-{config_entry.data.get(CONF_INSEE_CODE)}-{config_entry.data.get(CONF_LATITUDE)}-{config_entry.data.get(CONF_LONGITUDE)}" else: self._attr_unique_id = f"sensor-vigieau-{self._config.key}-{config_entry.data.get(CONF_INSEE_CODE)}-{config_entry.data.get(CONF_LATITUDE)}-{config_entry.data.get(CONF_LONGITUDE)}" self._attr_device_info = DeviceInfo( name=f"{NAME} {config_entry.data.get(CONF_CITY)}", entry_type=DeviceEntryType.SERVICE, identifiers={ ( DOMAIN, str(config_entry.data.get(DEVICE_ID_KEY)), ) }, manufacturer=NAME, model=config_entry.data.get(CONF_INSEE_CODE), ) def enrich_attributes(self, usage: dict, key_source: str, key_target: str): if key_source in usage: self._attr_state_attributes = self._attr_state_attributes or {} self._attr_state_attributes[key_target] = usage[key_source] @property def icon(self): return self._config.icon @callback def _handle_coordinator_update(self) -> None: _LOGGER.debug(f"Receiving an update for {self.unique_id} sensor") if not self.coordinator.last_update_success: _LOGGER.debug("Last coordinator failed, assuming state has not changed") return self._attr_state_attributes = self._attr_state_attributes or {} self._restrictions = [] self._time_restrictions = {} self._attr_name = str(self._config.name) for usage in self.coordinator.data["usages"]: for matcher in self._config.matchers: fully_qualified_usage = usage["nom"] + "|" + usage['thematique'] if re.search(matcher, fully_qualified_usage, re.IGNORECASE): self._attr_state_attributes = self._attr_state_attributes or {} restriction = usage.get("description") if restriction is None: raise UpdateFailed( "Restriction level is not specified" ) self._attr_state_attributes[ f"Categorie: {usage['nom']}" ] = restriction self._restrictions.append(restriction) self.enrich_attributes( usage, "details", f"{usage['nom']} (details)" ) if "heureFin" in usage and "heureDebut" in usage: self._time_restrictions[usage["nom"]] = [ usage["heureDebut"], usage["heureFin"], ] # we only want to add those attributes if they are not ambiguous if len(set([repr(r) for r in self._time_restrictions.values()])) == 1: restrictions = list(self._time_restrictions.values())[0] self._attr_state_attributes["heureDebut"] = restrictions[0] self._attr_state_attributes["heureFin"] = restrictions[1] elif len(self._time_restrictions) > 0: _LOGGER.debug( f"There are {len(self._time_restrictions)} usage with time restrictions for this sensor, exposing info per usage" ) for name in self._time_restrictions: self._attr_state_attributes[ f"{name} (heureDebut)" ] = self._time_restrictions[name][0] self._attr_state_attributes[ f"{name} (heureFin)" ] = self._time_restrictions[name][1] self._attr_native_value = self.compute_native_value() self.async_write_ha_state() def compute_native_value(self) -> Optional[str]: """This method extract the most relevant restriction level to display as aggregate""" if len(self._restrictions) == 0: return "Aucune restriction" if "Interdiction sur plage horaire" in self._restrictions: return "Interdiction sur plage horaire" if "Interdiction sauf exception" in self._restrictions: return "Interdiction sauf exception" if "Interdit sauf pour les usages commerciaux après accord du service de police de l’eau." in self._restrictions: return "Interdiction sauf exception" if "Interdiction" in self._restrictions: return "Interdiction" if "Interdiction." in self._restrictions: return "Interdiction" if "Réduction de prélèvement" in self._restrictions: return "Réduction de prélèvement" if "Consulter l’arrêté" in self._restrictions: return "Erreur: consulter l'arreté" if "Se référer à l'arrêté de restriction en cours de validité." in self._restrictions: return "Erreur: consulter l'arreté" if "Pas de restriction sauf arrêté spécifique." in self._restrictions: return "Autorisé sauf exception" if len(self._restrictions) == 1: return self._restrictions[0] _LOGGER.warn(f"Restrictions are hard to interpret: {self._restrictions}") return None @property def state_attributes(self): return self._attr_state_attributes