Home Assistant Git Exporter

This commit is contained in:
root
2024-05-31 13:07:35 +02:00
parent 64a0536537
commit 60abdd866c
275 changed files with 71113 additions and 1 deletions
@@ -0,0 +1,401 @@
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 leau." 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 larrê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
+132
View File
@@ -0,0 +1,132 @@
import logging
import aiohttp
from typing import Optional, Tuple
from aiohttp.client import ClientTimeout
from homeassistant.helpers.update_coordinator import UpdateFailed
from .const import GEOAPI_GOUV_URL, ADDRESS_API_URL, VIGIEAU_API_URL
import re
DEFAULT_TIMEOUT = 120
CLIENT_TIMEOUT = ClientTimeout(total=DEFAULT_TIMEOUT)
_LOGGER = logging.getLogger(__name__)
class InseeApiError(RuntimeError):
pass
class InseeApi:
"""Api to get INSEE data"""
def __init__(
self, session: Optional[aiohttp.ClientSession] = None, timeout=CLIENT_TIMEOUT
) -> None:
self._timeout = timeout
self._session = session or aiohttp.ClientSession()
async def get_insee_list(self):
"""Get all insee codes"""
session = aiohttp.ClientSession()
resp = await session.get(GEOAPI_GOUV_URL)
if resp.status != 200:
raise InseeApiError(
f"Unable to list all INSEE codes. API status was {resp.status}"
)
return await resp.json()
async def get_data(self, zipcode) -> dict:
"""Get INSEE code for a given zip code"""
url = f"{GEOAPI_GOUV_URL}&codePostal={zipcode}&format=json&geometry=centre"
resp = await self._session.get(url)
if resp.status != 200:
raise InseeApiError(f"Unable to get Insee Code for zip {zipcode}")
data = await resp.json()
_LOGGER.debug("Got Data GEOAPI data : %s ", data)
if len(data) == 0:
raise InseeApiError("No data received with GeoApi")
return data
class AddressApiError(RuntimeError):
pass
class AddressApi:
"""API for Reverse geocoding"""
def __init__(
self, session: Optional[aiohttp.ClientSession] = None, timeout=CLIENT_TIMEOUT
) -> None:
self._timeout = timeout
self._session = session or aiohttp.ClientSession()
async def get_data(self, lat: float, lon: float) -> Tuple[str, str, float, float]:
url = f"{ADDRESS_API_URL}/reverse/?lat={lat}&lon={lon}&type=housenumber"
resp = await self._session.get(url)
if resp.status != 200:
raise AddressApiError(
"Failed to fetch address from api-adresse.data.gouv.fr api"
)
data = await resp.json()
_LOGGER.debug(f"Data received from {ADDRESS_API_URL}: {data}")
if len(data["features"]) == 0:
_LOGGER.warn(
"Data received from api-adresse.data.gouv.fr is empty for those coordinates: (%s, %s). Either coordinates are not located in France or the governement geocoding database has no record for them.",
lat,
lon,
)
raise AddressApiError(
"Impossible to find approximate address of the current HA instance. API returned no result."
)
properties = data["features"][0]["properties"]
return (properties["citycode"], properties["city"], lat, lon)
class VigieauApiError(RuntimeError):
def __init__(self, message, text):
super().__init__(message)
self._text = text
@property
def text(self) -> str:
return self._text
class VigieauApi:
def __init__(
self, session: Optional[aiohttp.ClientSession] = None, timeout=CLIENT_TIMEOUT
) -> None:
self._timeout = timeout
self._session = session or aiohttp.ClientSession()
async def get_data(
self, lat: Optional[float], long: Optional[float], insee_code: str, profil: str
) -> dict:
url = f"{VIGIEAU_API_URL}/api/zones?commune={insee_code}&profil={profil}&zoneType=SUP"
if lat is not None and long is not None:
url += f"&lat={lat}&lon={long}"
_LOGGER.debug(f"Requesting restrictions from {url}")
resp = await self._session.get(url)
if (
resp.status == 404
and "message" in await resp.json()
and re.match("Aucune zone.+en vigueur", (await resp.json())["message"])
):
_LOGGER.debug(f"Vigieau replied with no restriction, faking data")
data = {"niveauGravite": "vigilance", "usages": [], "arrete": {}}
elif resp.status == 200 and (await resp.text()) == "":
_LOGGER.debug(f"Vigieau replied with no data at all, faking data")
data = {"niveauGravite": "vigilance", "usages": [], "arrete": {}}
elif resp.status in range(200, 300):
data = await resp.json()
else:
raise VigieauApiError(f"Failed fetching vigieau data", resp.text)
_LOGGER.debug(f"Data fetched from vigieau: {data}")
return data
@@ -0,0 +1,212 @@
import logging
from typing import Any, Optional, Tuple
import voluptuous as vol
from homeassistant.core import callback, HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries
from .api import InseeApi, AddressApi
from .const import (
DOMAIN,
CONF_LOCATION_MODE,
LOCATION_MODES,
HA_COORD,
ZIP_CODE,
CONF_INSEE_CODE,
CONF_CODE_POSTAL,
CONF_CITY,
DEVICE_ID_KEY,
)
from homeassistant.helpers.selector import LocationSelector
from homeassistant import config_entries
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from .api import InseeApi, AddressApi
from .const import (
DOMAIN,
CONF_LOCATION_MODE,
LOCATION_MODES,
HA_COORD,
ZIP_CODE,
CONF_INSEE_CODE,
CONF_CODE_POSTAL,
CONF_CITY,
SELECT_COORD,
CONF_LOCATION_MAP,
)
_LOGGER = logging.getLogger(__name__)
# Description of the config flow:
# async_step_user is called when user starts to configure the integration
# we follow with a flow of form/menu
# eventually we call async_create_entry with a dictionnary of data
# HA calls async_setup_entry with a ConfigEntry which wraps this data (defined in __init__.py)
# in async_setup_entry we call hass.config_entries.async_forward_entry_setups to setup each relevant platform (sensor in our case)
# HA calls async_setup_entry from sensor.py
LOCATION_SCHEMA = vol.Schema(
{vol.Required(CONF_LOCATION_MODE, default=HA_COORD): vol.In(LOCATION_MODES)}
)
ZIPCODE_SCHEMA = vol.Schema({vol.Required(CONF_CODE_POSTAL, default=""): cv.string})
async def get_insee_code_fromzip(hass: HomeAssistant, data: dict) -> None:
"""Get Insee code from zip code"""
session = async_get_clientsession(hass)
try:
client = InseeApi(session)
return await client.get_data(data)
except ValueError as exc:
raise exc
async def get_insee_code_fromcoord(
hass: HomeAssistant, lat=None, lon=None
) -> Tuple[str, str, float, float]:
"""Get Insee code from GPS coords"""
session = async_get_clientsession(hass)
try:
client = AddressApi(session)
if lat is None or lon is None:
lon = hass.config.as_dict()["longitude"]
lat = hass.config.as_dict()["latitude"]
return await client.get_data(lat, lon)
except ValueError as exc:
raise exc
def _build_place_key(city) -> str:
return f"{city['code']};{city['nom']};{city['centre']['coordinates'][0]};{city['centre']['coordinates'][1]}"
class SetupConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 4
def __init__(self):
"""Initialize"""
self.data = {}
self.city_insee = []
@callback
def _show_setup_form(self, step_id=None, user_input=None, schema=None, errors=None):
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
return self.async_show_form(
step_id=step_id,
data_schema=schema,
errors=errors or {},
)
async def async_step_user(self, user_input: Optional[dict[str, Any]] = None):
"""Called once with None as user_input, then a second time with user provided input"""
errors = {}
if user_input is not None:
self.data[CONF_LOCATION_MODE] = user_input[CONF_LOCATION_MODE]
if user_input[CONF_LOCATION_MODE] == HA_COORD:
try:
city_infos = await get_insee_code_fromcoord(self.hass)
except ValueError:
errors["base"] = "noinsee"
if not errors:
self.data[CONF_INSEE_CODE] = city_infos[0]
self.data[CONF_CITY] = city_infos[1]
self.data[CONF_LATITUDE] = city_infos[2]
self.data[CONF_LONGITUDE] = city_infos[3]
self.data[DEVICE_ID_KEY] = city_infos[0]
return await self.async_step_location(user_input=self.data)
elif user_input[CONF_LOCATION_MODE] == ZIP_CODE:
self.data = user_input
return await self.async_step_location()
elif user_input[CONF_LOCATION_MODE] == SELECT_COORD:
return await self.async_step_map_select()
return self._show_setup_form("user", user_input, LOCATION_SCHEMA, errors)
async def async_step_map_select(self, user_input=None):
COORD_SCHEMA = vol.Schema(
{
vol.Required(
CONF_LOCATION_MAP,
default={
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
},
): LocationSelector()
}
)
errors = {}
if user_input is not None:
try:
city_infos = await get_insee_code_fromcoord(
self.hass,
user_input[CONF_LOCATION_MAP][CONF_LATITUDE],
user_input[CONF_LOCATION_MAP][CONF_LONGITUDE],
)
except ValueError:
errors["base"] = "noinsee"
if not errors:
self.data[CONF_INSEE_CODE] = city_infos[0]
self.data[CONF_CITY] = city_infos[1]
# TODO(kamaradclimber): it's not clear whether we should take lat/long from user input
# or from address api results.
self.data[CONF_LATITUDE] = city_infos[2]
self.data[CONF_LONGITUDE] = city_infos[3]
self.data[DEVICE_ID_KEY] = city_infos[0]
return await self.async_step_location(user_input=self.data)
return self._show_setup_form("map_select", None, COORD_SCHEMA, errors)
async def async_step_location(self, user_input=None):
"""Handle location step"""
errors = {}
if user_input is not None:
city_insee = user_input.get(CONF_INSEE_CODE)
if not city_insee:
# get INSEE Code
try:
self.city_insee = await get_insee_code_fromzip(
self.hass, user_input[CONF_CODE_POSTAL]
)
except ValueError:
errors["base"] = "noinsee"
if not errors:
return await self.async_step_multilocation()
else:
return self._show_setup_form(
"location", user_input, ZIPCODE_SCHEMA, errors
)
return self.async_create_entry(title="vigieau", data=self.data)
return self._show_setup_form("location", None, ZIPCODE_SCHEMA, errors)
async def async_step_multilocation(self, user_input=None):
"""Handle location step"""
errors = {}
locations_for_form = {}
for city in self.city_insee:
locations_for_form[_build_place_key(city)] = f"{city['nom']}"
if not user_input:
if len(self.city_insee) > 1:
return self.async_show_form(
step_id="multilocation",
data_schema=vol.Schema(
{
vol.Required("city", default=[]): vol.In(
locations_for_form
),
}
),
errors=errors,
)
user_input = {CONF_CITY: _build_place_key(self.city_insee[0])}
city_infos = user_input[CONF_CITY].split(";")
self.data[CONF_INSEE_CODE] = city_infos[0]
self.data[CONF_CITY] = city_infos[1]
self.data[CONF_LONGITUDE] = city_infos[2]
self.data[CONF_LATITUDE] = city_infos[3]
self.data[DEVICE_ID_KEY] = city_infos[0]
return await self.async_step_location(self.data)
+264
View File
@@ -0,0 +1,264 @@
from homeassistant.components.sensor import (
SensorEntityDescription,
)
from dataclasses import dataclass
DOMAIN = "vigieau"
VIGIEAU_API_URL = "https://api.vigieau.gouv.fr"
GEOAPI_GOUV_URL = "https://geo.api.gouv.fr/communes?&fields=code,nom,centre"
ADDRESS_API_URL = "https://api-adresse.data.gouv.fr"
CONF_LOCATION_MODE = "location_mode"
HA_COORD = 0
ZIP_CODE = 1
SELECT_COORD = 2
LOCATION_MODES = {
HA_COORD: "Coordonnées Home Assistant",
ZIP_CODE: "Code Postal",
SELECT_COORD: "Sélection sur carte",
}
CONF_INSEE_CODE = "INSEE"
CONF_CITY = "city"
CONF_CODE_POSTAL = "Code postal"
CONF_LOCATION_MAP = "location_map"
NAME = "Vigieau"
DEVICE_ID_KEY = "device_id"
@dataclass
class VigieEauRequiredKeysMixin:
"""Mixin for required keys."""
category: str
matchers: list[str]
@dataclass
class VigieEauSensorEntityDescription(
SensorEntityDescription, VigieEauRequiredKeysMixin
):
"""Describes VigieEau sensor entity."""
SENSOR_DEFINITIONS: tuple[VigieEauSensorEntityDescription, ...] = (
VigieEauSensorEntityDescription(
name="Alimentation des fontaines",
icon="mdi:fountain",
category="fountains",
key="fountains",
matchers=[
"alimentation des fontaines.+",
"douches .+ plages.+",
"fontaines",
"jeux d'eau",
],
),
VigieEauSensorEntityDescription(
name="Arrosage des jardins potagers",
icon="mdi:watering-can",
category="potagers",
key="potagers",
matchers=[
"Arrosage des .*potagers",
"arrosage.+arbres.+",
"arrosage.+plant.+",
],
),
VigieEauSensorEntityDescription(
name="Arrosage voirie et trottoirs",
icon="mdi:road",
category="roads",
key="roads",
matchers=[
"trottoirs",
"voiries|voieries",
"Arrosage de surfaces de .+ générant de la poussière",
],
),
VigieEauSensorEntityDescription(
name="Arrosage des pelouses",
icon="mdi:sprinkler-variant",
category="lawn",
key="lawn",
matchers=[
"pelouses",
"jardins d'agrément",
"massifs fleuris",
"Arrosage des espaces verts",
"Arrosage des jeunes plantations d'arbres",
"surface.+sportives.+",
"arrosage.+massif.+",
"Nettoyage / arrosage des sites de manifestations temporaires sportives et culturelles",
"Dispositifs de récupération des eaux de pluie",
"Arrosage, arbustes et arbres",
"Arrosage des jardinières et suspensions",
"Arrosage des espaces arborés",
"Arrosage des terrains de sport",
],
),
VigieEauSensorEntityDescription(
name="Lavage des véhicules",
icon="mdi:car-wash",
category="car_wash",
key="car_wash",
matchers=[
"lavage.+particuliers",
"lavage.+professionnels.+portique",
"lavage.+professionnels.+haute pression",
"lavage.+(station|véhicules)",
"lavage.+professionnel.+",
"Nettoyage des véhicules et bateaux",
"Nettoyage des véhicules, des bateaux Y compris par dispositifs mobiles",
],
),
VigieEauSensorEntityDescription(
name="Lavage des engins nautiques",
icon="mdi:sail-boat",
category="nautical_vehicules",
key="nautical_vehicules",
matchers=[
"Activités nautiques : cas général",
"lavage.+engins nautiques.+professionnels",
"Nettoyage.+embarcation",
"lavage.+bateau.+",
"nettoyage.+bateau.+",
"engins nautiques",
"Lavage des embarcations, motorisées ou non, par tout moyen branché sur le réseau public",
"Lavage de véhicule disposant dun système équipé dun recyclage de leau",
"Carénage des bateaux",
"Lavage et entretien des embarcations .+ en aire de carénage.",
],
),
VigieEauSensorEntityDescription(
name="Lavage des toitures, façades",
icon="mdi:home-roof",
category="roof_clean",
key="roof_clean",
matchers=[
"toitures",
"façades",
"nettoyage.+bâtiments.+",
"nettoyage.+terrasse.+",
],
),
VigieEauSensorEntityDescription(
name="Vidange et remplissage des piscines",
icon="mdi:pool",
category="pool",
key="pool",
matchers=[
"remplissage.+piscines.+(familial|privé)",
"vidange.+piscines",
"piscines privées", # Piscines privées et bains à remous de plus de 1m3
"piscines non collectives", # Remplissage et vidange de piscines non collectives (de plus de 1 m3)
"baignades.+",
"Remise à niveau des piscines à usage privé",
"Remplissage des jeux d'eau",
"Remplissage des piscine privées",
"Remplissage des piscines individuelles",
"remise à niveau des piscines",
],
),
VigieEauSensorEntityDescription(
name="Remplissage/Vidange des plans d'eau",
icon="mdi:waves",
category="ponds",
key="ponds",
matchers=[
"remplissage.+plan.* d.eau",
"vidange.+plan.* d.eau",
"Alimentation de plan d'eau", # Alimentation de plan d'eau en dérivation de cours d'eau à usage domestique
"alimentation.+plan.* d.eau",
"alimentation.+bassin.+",
"lestage pour stabilité",
"Alimentation d’étangs",
],
),
VigieEauSensorEntityDescription(
name="Travaux sur cours d'eau",
icon="mdi:hydro-power",
category="river_rate",
key="river_rate",
matchers=[
"ouvrage.+cours d.eau",
"travaux.+cours d.eau",
"manoeuvre.+vannes", # Manoeuvre de vannes des seuils et barrages
"Gestion des ouvrages", # FIXME: we should probably match with the category as well
"travaux.+rivière",
"rabattement.+nappe.+",
"faucardage.+",
"Faucardement",
"manoeuvre.+d.ouvrage.+",
"rejet direct deaux polluées",
"orpaillage",
"Manœuvres des vannes d.installations hydrauliques",
"Manœuvres douvrages hydrauliques",
"Tout usage domestique non sanitaire de leau",
"Réalisation d'un seuil provisoire",
"Rejets directs en cours deau",
"Pratiques ou activités dans le lit pouvant avoir un impact sur les milieux aquatiques",
"Perturbations physiques du lit des cours deau",
"Entretien de cours d'eau",
"Travaux et rejets",
"Travaux sur les systèmes dassainissement occasionnant des rejets",
],
),
VigieEauSensorEntityDescription(
name="Navigation fluviale",
icon="mdi:ferry",
category="river_movement",
key="river_movement",
matchers=[
"Navigation fluviale",
"Pratique du canyoning sur matériaux alluvionnaires",
"Pratique de la navigation de loisir",
],
),
VigieEauSensorEntityDescription(
name="Arrosage des golfs",
icon="mdi:golf",
category="golfs",
key="golfs",
matchers=["arrosage des golfs"],
),
VigieEauSensorEntityDescription(
name="Prélèvement en canaux",
icon="mdi:water-pump",
category="canals",
key="canals",
matchers=[
"Prélèvement en canaux",
"Prélèvements dans le milieu naturel.+",
"prélèvements.+cours d.eau.+",
"prélèvement.+hydraulique.+",
"alimentation.+canaux.+",
"Prélèvements domestiques directs dans les milieux hydrauliques, hors usage professionnel identifié",
"Prélèvement deau domestique en milieu",
"Prélèvement deau domestique dans un canal existant",
"Prélèvements énergétiques",
"Prélèvement.* en cours d'eau",
"Prélèvements destinés au fonctionnement des milieux naturels",
"Prélèvement sur le site des Marais de Sacy",
"Tout nouveau prélèvement",
"Nouvelles demandes de prélèvement d'eau et création de forages",
"Création de prélèvements",
"Prélèvement en cours deau",
],
),
VigieEauSensorEntityDescription(
name="Restriction spécifique",
category="misc",
key="misc",
matchers=[
"Remplissage tonne de chasse",
"Activités cynégétiques",
"Structures gonflables/tubulaires privées à usage collectif > 1m3 nécessitant 1 vidange quotidienne",
"Abreuvement et hygiène des animaux",
"Abreuvement des animaux",
"Irrigation par aspersion des cultures",
# ICPE means "Installation classée pour la protection de l'environment"
"ICPE soumises à un APC relatif à la sécheresse",
"Usages récréatifs collectifs à partir deau potable.+",
],
),
)
@@ -0,0 +1,13 @@
{
"domain": "vigieau",
"name": "Vigieau",
"codeowners": ["@kamaradclimber"],
"config_flow": true,
"documentation": "https://github.com/kamaradclimber/vigieau",
"integration_type": "device",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/kamaradclimber/vigieau/issues",
"requirements": [
],
"version": "0.1.0"
}
@@ -0,0 +1,156 @@
{
"restrictions": [
{
"usage": "Abreuvement des animaux",
"thematique": "Abreuver"
},
{
"usage": "Activités de loisirs professionnelles ou amateurs en cours deau.",
"thematique": "Travaux et activités en cours d'eau"
},
{
"usage": "Alimentation des fontaines publiques",
"thematique": "Alimenter des fontaines et autres usages de loisirs"
},
{
"usage": "Alimentation des fontaines publiques et privées dornement",
"thematique": "Alimenter des fontaines et autres usages de loisirs"
},
{
"usage": "Arrosage des golfs",
"thematique": "Arroser"
},
{
"usage": "Arrosage des golfs(Conformément à l'accord cadre golf et environnement 2019-2024",
"thematique": "Arroser"
},
{
"usage": "Arrosage des jardins potagers",
"thematique": "Arroser"
},
{
"usage": "Arrosage des jardins potagers collectifs",
"thematique": "Arroser"
},
{
"usage": "Arrosage des jardins potagers individuels",
"thematique": "Arroser"
},
{
"usage": "Arrosage des pelouses, massifs fleuris",
"thematique": "Arroser"
},
{
"usage": "Arrosage des pelouses, massifs fleuris et espaces verts (y compris rond-points, voies de tramway).",
"thematique": "Arroser"
},
{
"usage": "Arrosage des terrains de sport",
"thematique": "Arroser"
},
{
"usage": "ICPE soumises à un APC relatif à la sécheresse",
"thematique": "ICPE"
},
{
"usage": "Irrigation par aspersion des cultures",
"thematique": "Irriguer"
},
{
"usage": "Lavage de véhicules chez les particuliers",
"thematique": "Nettoyer"
},
{
"usage": "Lavage de véhicules en station professionnelle",
"thematique": "Nettoyer"
},
{
"usage": "Lavage de véhicules et bateaux chez les particuliers",
"thematique": "Nettoyer"
},
{
"usage": "Lavage de véhicules par des particuliers, y compris embarcations motorisées ou non (exemple : Jet ski).",
"thematique": "Nettoyer"
},
{
"usage": "Lavage de véhicules par des professionnels",
"thematique": "Nettoyer"
},
{
"usage": "Lavage de véhicules publics ou privés en stations de lavage professionnelles.",
"thematique": "Nettoyer"
},
{
"usage": "Lavage des bateaux",
"thematique": "Nettoyer"
},
{
"usage": "Lavage et entretien des embarcations (motorisées ou non) en aire de carénage.",
"thematique": "Nettoyer"
},
{
"usage": "Navigation fluviale.",
"thematique": "Travaux et activités en cours d'eau"
},
{
"usage": "Nettoyage des façades, terrasses et murs de clôture",
"thematique": "Nettoyer"
},
{
"usage": "Nettoyage des façades, toitures, trottoirs et autres surfaces imperméabilisées",
"thematique": "Nettoyer"
},
{
"usage": "Nettoyage des façades, toitures, trottoirs et autres surfaces imperméabilisées hors activités industrielles.",
"thematique": "Nettoyer"
},
{
"usage": "Nettoyage des façades, toitures, trottoirs, terrasses, façades imperméabilisées...",
"thematique": "Nettoyer"
},
{
"usage": "Nettoyage des voieries",
"thematique": "Nettoyer"
},
{
"usage": "Orpaillage et pêche à laimant.",
"thematique": "Travaux et activités en cours d'eau"
},
{
"usage": "Rejets et travaux en rivière",
"thematique": "Travaux et activités en cours d'eau"
},
{
"usage": "Remplissage / vidange des plans d'eau",
"thematique": "Remplir ou vidanger"
},
{
"usage": "Remplissage / vidange des plans d'eau.",
"thematique": "Remplir ou vidanger"
},
{
"usage": "Remplissage et vidange de piscines privées",
"thematique": "Remplir ou vidanger"
},
{
"usage": "Remplissage et vidange de piscines privées (de plus d'1 m3)",
"thematique": "Remplir ou vidanger"
},
{
"usage": "Remplissage et vidange de piscines privées (de plus d'1 m3).",
"thematique": "Remplir ou vidanger"
},
{
"usage": "Travaux en cours deau",
"thematique": "Travaux et activités en cours d'eau"
},
{
"usage": "Travaux en cours deau.",
"thematique": "Travaux et activités en cours d'eau"
},
{
"usage": "Usages récréatifs collectifs à partir deau potable (dans le cadre de manifestations).",
"thematique": "Remplir ou vidanger"
}
]
}
@@ -0,0 +1,59 @@
import aiohttp
import asyncio
import os
import json
from frozendict import frozendict
import sys
current_dir = os.path.dirname(__file__)
parent_dir = os.path.dirname(current_dir)
sys.path.append(".")
sys.path.append(parent_dir)
from custom_components.vigieau.api import InseeApi, VigieauApi, VigieauApiError
async def main():
restriction_list = {"restrictions": []}
usages = set()
async with aiohttp.ClientSession() as session:
vigieau = VigieauApi(session)
commune_list = await InseeApi(session).get_insee_list()
for i, commune in enumerate(commune_list):
print(f"{i}/{len(commune_list)}: {commune['nom']}")
try:
restriction = await vigieau.get_data(
insee_code=commune["code"],
profil="particulier",
lat=commune["centre"]["coordinates"][1],
long=commune["centre"]["coordinates"][0],
)
except VigieauApiError as e:
print(e.text)
# FIXME: Sometimes insee is enough to call vigieau Api, sometimes not exclude the one where it's not enough , for the moment
if restriction:
for usage in restriction.get("usages", []):
usages.add(
frozendict(
{"usage": usage["nom"], "thematique": usage["thematique"]}
)
)
if i % 10 == 0:
dump_restrictions(restriction_list, usages)
dump_restrictions(restriction_list, usages)
def dump_restrictions(restriction_list, usages):
restriction_list["restrictions"] = sorted(
list(usages), key=lambda h: h["usage"]
)
finaldata = json.dumps(restriction_list, ensure_ascii=False, indent=2)
file = os.path.join(os.path.dirname(__file__), "full_usage_list.json")
with open(file, "w", encoding="utf-8") as outfile:
outfile.write(finaldata)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,29 @@
import logging
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.config_entries import ConfigEntry
from . import (
UsageRestrictionEntity,
AlertLevelEntity,
)
from .const import DOMAIN, SENSOR_DEFINITIONS, SENSOR_DEFINITIONS
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
vigieau_coordinator = hass.data[DOMAIN][entry.entry_id]["vigieau_coordinator"]
sensors = [
UsageRestrictionEntity(
vigieau_coordinator, hass, "mydi", entry, sensor_description
)
for sensor_description in SENSOR_DEFINITIONS
]
sensors.append(AlertLevelEntity(vigieau_coordinator, hass, entry))
async_add_entities(sensors)
await vigieau_coordinator.async_config_entry_first_refresh()
@@ -0,0 +1,8 @@
{
"config": {
"step": {
"user": {
}
}
}
}
@@ -0,0 +1,44 @@
from os import path
import sys
current_dir = path.dirname(__file__)
parent_dir = path.dirname(current_dir)
sys.path.append(".")
sys.path.append(parent_dir)
from custom_components.vigieau.const import SENSOR_DEFINITIONS
import unittest
from pathlib import Path
import json
import os
import re
class TestRegexp(unittest.TestCase):
def test_matcher_in_component(self):
file = os.path.join(parent_dir, "scripts/full_usage_list.json")
with open(file) as f:
input = f.read()
data = json.loads(input)
for restriction in data["restrictions"]: # For all restrictions in the list
with self.subTest(
msg="One matcher failed"
): # For soft fail, ref https://stackoverflow.com/questions/4732827/continuing-in-pythons-unittest-when-an-assertion-fails
found = False
for sensor in SENSOR_DEFINITIONS:
# We may have to create a function rather than copy/paste, but it's a 'simple re.search....
for matcher in sensor.matchers:
if re.search(
matcher,
restriction["usage"] + "|" + restriction['thematique'],
re.IGNORECASE,
):
found = True
self.assertTrue(
found,
f"Value **{restriction['usage']}** in category **{restriction['thematique']}** not found in matcher",
) # Check for one usage if it has been found
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"data": {
"location_mode": ""
},
"description": "Select Localisation mode",
"title": "Localisation mode"
},
"location": {
"data": {
"code_postal": "Zip Code"
},
"description": "Set your zip code",
"title": "Location"
},
"multilocation": {
"data": {
"city": "City"
},
"title": "Many code found",
"description": "Select the city"
},
"location_map": {
"data": {
"latitude": "latitude",
"longitude": "longitude"
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"data": {
"location_mode": ""
},
"description": "Choisissez un mode de localisation",
"title": "Mode de localisation"
},
"location": {
"data": {
"code_postal": "Code Postal"
},
"description": "Saisissez votre code postal",
"title": "Localisation"
},
"multilocation": {
"data": {
"city": "Ville"
},
"title": "Plusieurs codes INSEE trouvés pour ce code postal",
"description": "Sélectionnez la commune"
},
"location_map": {
"data": {
"latitude": "latitude",
"longitude": "longitude"
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"config": {
"step": {
"user": {
"data": {
"location_mode": ""
},
"description": "Selecionar o modo de localização",
"title": "Modo de localizacao"
},
"location": {
"data": {
"code_postal": "Codigo postal"
},
"description": "Inserir o código postal",
"title": "Localização"
},
"multilocation": {
"data": {
"city": "Cidade"
},
"title": "Muitos codigos encontrados",
"description": "Selecione a cidade"
},
"location_map": {
"data": {
"latitude": "latitude",
"longitude": "longitude"
}
}
}
}
}