402 lines
16 KiB
Python
402 lines
16 KiB
Python
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
|