meteocat 2.1.0 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/custom_components/meteocat/__init__.py +29 -1
- package/custom_components/meteocat/config_flow.py +140 -1
- package/custom_components/meteocat/const.py +14 -0
- package/custom_components/meteocat/coordinator.py +524 -9
- package/custom_components/meteocat/manifest.json +2 -2
- package/custom_components/meteocat/options_flow.py +30 -4
- package/custom_components/meteocat/sensor.py +420 -4
- package/custom_components/meteocat/strings.json +197 -4
- package/custom_components/meteocat/translations/ca.json +197 -4
- package/custom_components/meteocat/translations/en.json +197 -4
- package/custom_components/meteocat/translations/es.json +197 -4
- package/custom_components/meteocat/version.py +1 -1
- package/images/api_limits.png +0 -0
- package/images/diagnostic_sensors.png +0 -0
- package/images/dynamic_sensors.png +0 -0
- package/package.json +1 -1
- package/poetry.lock +598 -586
- package/pyproject.toml +3 -3
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import aiofiles
|
|
6
6
|
import logging
|
|
7
7
|
import asyncio
|
|
8
|
+
import unicodedata
|
|
8
9
|
from datetime import datetime, timedelta, timezone, time
|
|
9
10
|
from zoneinfo import ZoneInfo
|
|
10
11
|
from typing import Dict, Any
|
|
@@ -18,6 +19,8 @@ from meteocatpy.data import MeteocatStationData
|
|
|
18
19
|
from meteocatpy.uvi import MeteocatUviData
|
|
19
20
|
from meteocatpy.forecast import MeteocatForecast
|
|
20
21
|
from meteocatpy.alerts import MeteocatAlerts
|
|
22
|
+
from meteocatpy.quotes import MeteocatQuotes
|
|
23
|
+
from meteocatpy.lightning import MeteocatLightning
|
|
21
24
|
|
|
22
25
|
from meteocatpy.exceptions import (
|
|
23
26
|
BadRequestError,
|
|
@@ -35,10 +38,12 @@ from .const import (
|
|
|
35
38
|
DEFAULT_VALIDITY_HOURS,
|
|
36
39
|
DEFAULT_VALIDITY_MINUTES,
|
|
37
40
|
DEFAULT_ALERT_VALIDITY_TIME,
|
|
41
|
+
DEFAULT_QUOTES_VALIDITY_TIME,
|
|
38
42
|
ALERT_VALIDITY_MULTIPLIER_100,
|
|
39
43
|
ALERT_VALIDITY_MULTIPLIER_200,
|
|
40
44
|
ALERT_VALIDITY_MULTIPLIER_500,
|
|
41
|
-
ALERT_VALIDITY_MULTIPLIER_DEFAULT
|
|
45
|
+
ALERT_VALIDITY_MULTIPLIER_DEFAULT,
|
|
46
|
+
DEFAULT_LIGHTNING_VALIDITY_TIME
|
|
42
47
|
)
|
|
43
48
|
|
|
44
49
|
_LOGGER = logging.getLogger(__name__)
|
|
@@ -55,6 +60,10 @@ DEFAULT_CONDITION_SENSOR_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
|
55
60
|
DEFAULT_TEMP_FORECAST_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
56
61
|
DEFAULT_ALERTS_UPDATE_INTERVAL = timedelta(minutes=10)
|
|
57
62
|
DEFAULT_ALERTS_REGION_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
63
|
+
DEFAULT_QUOTES_UPDATE_INTERVAL = timedelta(minutes=10)
|
|
64
|
+
DEFAULT_QUOTES_FILE_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
65
|
+
DEFAULT_LIGHTNING_UPDATE_INTERVAL = timedelta(minutes=10)
|
|
66
|
+
DEFAULT_LIGHTNING_FILE_UPDATE_INTERVAL = timedelta(minutes=5)
|
|
58
67
|
|
|
59
68
|
# Definir la zona horaria local
|
|
60
69
|
TIMEZONE = ZoneInfo("Europe/Madrid")
|
|
@@ -92,6 +101,52 @@ async def load_json_from_file(input_file: str) -> dict:
|
|
|
92
101
|
_LOGGER.error("Error al decodificar JSON del archivo %s: %s", input_file, err)
|
|
93
102
|
return {}
|
|
94
103
|
|
|
104
|
+
def normalize_name(name):
|
|
105
|
+
"""Normaliza el nombre eliminando acentos y convirtiendo a minúsculas."""
|
|
106
|
+
name = unicodedata.normalize("NFKD", name).encode("ASCII", "ignore").decode("utf-8")
|
|
107
|
+
return name.lower()
|
|
108
|
+
|
|
109
|
+
# Definir _quotes_lock para evitar que varios coordinadores inicien una carrera para modificar quotes.json al mismo tiempo
|
|
110
|
+
_quotes_lock = asyncio.Lock()
|
|
111
|
+
|
|
112
|
+
async def _update_quotes(hass: HomeAssistant, plan_name: str) -> None:
|
|
113
|
+
"""Actualiza las cuotas en quotes.json después de una consulta."""
|
|
114
|
+
async with _quotes_lock:
|
|
115
|
+
quotes_file = hass.config.path(
|
|
116
|
+
"custom_components", "meteocat", "files", "quotes.json"
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
data = await load_json_from_file(quotes_file)
|
|
120
|
+
|
|
121
|
+
# Validar estructura del archivo
|
|
122
|
+
if not data or not isinstance(data, dict):
|
|
123
|
+
_LOGGER.warning("quotes.json está vacío o tiene un formato inválido: %s", data)
|
|
124
|
+
return
|
|
125
|
+
if "plans" not in data or not isinstance(data["plans"], list):
|
|
126
|
+
_LOGGER.warning("Estructura inesperada en quotes.json: %s", data)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Buscar el plan y actualizar las cuotas
|
|
130
|
+
for plan in data["plans"]:
|
|
131
|
+
if plan.get("nom") == plan_name:
|
|
132
|
+
plan["consultesRealitzades"] += 1
|
|
133
|
+
plan["consultesRestants"] = max(0, plan["consultesRestants"] - 1)
|
|
134
|
+
_LOGGER.debug(
|
|
135
|
+
"Cuota actualizada para el plan %s: Consultas realizadas %s, restantes %s",
|
|
136
|
+
plan_name, plan["consultesRealitzades"], plan["consultesRestants"]
|
|
137
|
+
)
|
|
138
|
+
break # Salimos del bucle al encontrar el plan
|
|
139
|
+
|
|
140
|
+
# Guardar cambios en el archivo
|
|
141
|
+
await save_json_to_file(data, quotes_file)
|
|
142
|
+
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
_LOGGER.error("El archivo quotes.json no fue encontrado en la ruta esperada: %s", quotes_file)
|
|
145
|
+
except json.JSONDecodeError:
|
|
146
|
+
_LOGGER.error("Error al decodificar quotes.json, posiblemente el archivo está corrupto.")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
_LOGGER.exception("Error inesperado al actualizar las cuotas en quotes.json: %s", str(e))
|
|
149
|
+
|
|
95
150
|
class MeteocatSensorCoordinator(DataUpdateCoordinator):
|
|
96
151
|
"""Coordinator para manejar la actualización de datos de los sensores."""
|
|
97
152
|
|
|
@@ -142,6 +197,9 @@ class MeteocatSensorCoordinator(DataUpdateCoordinator):
|
|
|
142
197
|
)
|
|
143
198
|
_LOGGER.debug("Datos de sensores actualizados exitosamente: %s", data)
|
|
144
199
|
|
|
200
|
+
# Actualizar las cuotas usando la función externa
|
|
201
|
+
await _update_quotes(self.hass, "XEMA") # Asegúrate de usar el nombre correcto del plan aquí
|
|
202
|
+
|
|
145
203
|
# Validar que los datos sean una lista de diccionarios
|
|
146
204
|
if not isinstance(data, list) or not all(isinstance(item, dict) for item in data):
|
|
147
205
|
_LOGGER.error(
|
|
@@ -196,7 +254,7 @@ class MeteocatSensorCoordinator(DataUpdateCoordinator):
|
|
|
196
254
|
err,
|
|
197
255
|
)
|
|
198
256
|
# Intentar cargar datos en caché si hay un error
|
|
199
|
-
cached_data = load_json_from_file(self.station_file)
|
|
257
|
+
cached_data = await load_json_from_file(self.station_file)
|
|
200
258
|
if cached_data:
|
|
201
259
|
_LOGGER.warning("Usando datos en caché para la estación %s.", self.station_id)
|
|
202
260
|
return cached_data
|
|
@@ -224,6 +282,8 @@ class MeteocatStaticSensorCoordinator(DataUpdateCoordinator):
|
|
|
224
282
|
self.town_id = entry_data["town_id"] # ID del municipio
|
|
225
283
|
self.station_name = entry_data["station_name"] # Nombre de la estación
|
|
226
284
|
self.station_id = entry_data["station_id"] # ID de la estación
|
|
285
|
+
self.region_name = entry_data["region_name"] # Nombre de la región
|
|
286
|
+
self.region_id = entry_data["region_id"] # ID de la región
|
|
227
287
|
|
|
228
288
|
super().__init__(
|
|
229
289
|
hass,
|
|
@@ -239,17 +299,21 @@ class MeteocatStaticSensorCoordinator(DataUpdateCoordinator):
|
|
|
239
299
|
Since static sensors use entry_data, this method simply logs the process.
|
|
240
300
|
"""
|
|
241
301
|
_LOGGER.debug(
|
|
242
|
-
"Updating static sensor data for town: %s (ID: %s), station: %s (ID: %s)",
|
|
302
|
+
"Updating static sensor data for town: %s (ID: %s), station: %s (ID: %s), region: %s (ID: %s)",
|
|
243
303
|
self.town_name,
|
|
244
304
|
self.town_id,
|
|
245
305
|
self.station_name,
|
|
246
306
|
self.station_id,
|
|
307
|
+
self.region_name,
|
|
308
|
+
self.region_id,
|
|
247
309
|
)
|
|
248
310
|
return {
|
|
249
311
|
"town_name": self.town_name,
|
|
250
312
|
"town_id": self.town_id,
|
|
251
313
|
"station_name": self.station_name,
|
|
252
314
|
"station_id": self.station_id,
|
|
315
|
+
"region_name": self.region_name,
|
|
316
|
+
"region_id": self.region_id,
|
|
253
317
|
}
|
|
254
318
|
|
|
255
319
|
class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
@@ -340,6 +404,9 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
340
404
|
)
|
|
341
405
|
_LOGGER.debug("Datos actualizados exitosamente: %s", data)
|
|
342
406
|
|
|
407
|
+
# Actualizar las cuotas usando la función externa
|
|
408
|
+
await _update_quotes(self.hass, "Prediccio") # Asegúrate de usar el nombre correcto del plan aquí
|
|
409
|
+
|
|
343
410
|
# Validar que los datos sean un dict con una clave 'uvi'
|
|
344
411
|
if not isinstance(data, dict) or 'uvi' not in data:
|
|
345
412
|
_LOGGER.error("Formato inválido: Se esperaba un dict con la clave 'uvi'. Datos: %s", data)
|
|
@@ -380,7 +447,7 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
380
447
|
err,
|
|
381
448
|
)
|
|
382
449
|
# Intentar cargar datos en caché si hay un error
|
|
383
|
-
cached_data = load_json_from_file(self.uvi_file)
|
|
450
|
+
cached_data = await load_json_from_file(self.uvi_file)
|
|
384
451
|
if cached_data:
|
|
385
452
|
_LOGGER.warning("Usando datos en caché para la ciudad %s.", self.town_id)
|
|
386
453
|
return cached_data.get('uvi', [])
|
|
@@ -551,9 +618,27 @@ class MeteocatEntityCoordinator(DataUpdateCoordinator):
|
|
|
551
618
|
|
|
552
619
|
async def _fetch_and_save_data(self, api_method, file_path: str) -> dict:
|
|
553
620
|
"""Obtiene datos de la API y los guarda en un archivo JSON."""
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
621
|
+
try:
|
|
622
|
+
data = await asyncio.wait_for(api_method(self.town_id), timeout=30)
|
|
623
|
+
|
|
624
|
+
# Procesar los datos antes de guardarlos
|
|
625
|
+
for day in data.get('dies', []):
|
|
626
|
+
for var, details in day.get('variables', {}).items():
|
|
627
|
+
if var == 'precipitacio' and isinstance(details.get('valor'), str) and details['valor'].startswith('-'):
|
|
628
|
+
details['valor'] = '0.0'
|
|
629
|
+
|
|
630
|
+
await save_json_to_file(data, file_path)
|
|
631
|
+
|
|
632
|
+
# Actualizar cuotas dependiendo del tipo de predicción
|
|
633
|
+
if api_method.__name__ == 'get_prediccion_horaria':
|
|
634
|
+
await _update_quotes(self.hass, "Prediccio")
|
|
635
|
+
elif api_method.__name__ == 'get_prediccion_diaria':
|
|
636
|
+
await _update_quotes(self.hass, "Prediccio")
|
|
637
|
+
|
|
638
|
+
return data
|
|
639
|
+
except Exception as err:
|
|
640
|
+
_LOGGER.error(f"Error al obtener datos de la API para {file_path}: {err}")
|
|
641
|
+
raise
|
|
557
642
|
|
|
558
643
|
async def _async_update_data(self) -> dict:
|
|
559
644
|
"""Actualiza los datos de predicción horaria y diaria."""
|
|
@@ -602,8 +687,8 @@ class MeteocatEntityCoordinator(DataUpdateCoordinator):
|
|
|
602
687
|
_LOGGER.exception("Error inesperado al obtener datos de predicción: %s", err)
|
|
603
688
|
|
|
604
689
|
# Si ocurre un error, intentar cargar datos desde los archivos locales
|
|
605
|
-
hourly_cache = load_json_from_file(self.hourly_file) or {}
|
|
606
|
-
daily_cache = load_json_from_file(self.daily_file) or {}
|
|
690
|
+
hourly_cache = await load_json_from_file(self.hourly_file) or {}
|
|
691
|
+
daily_cache = await load_json_from_file(self.daily_file) or {}
|
|
607
692
|
|
|
608
693
|
_LOGGER.warning(
|
|
609
694
|
"Cargando datos desde caché para %s. Datos horarios: %s, Datos diarios: %s",
|
|
@@ -1203,6 +1288,9 @@ class MeteocatAlertsCoordinator(DataUpdateCoordinator):
|
|
|
1203
1288
|
)
|
|
1204
1289
|
raise ValueError("Formato de datos inválido")
|
|
1205
1290
|
|
|
1291
|
+
# Actualizar cuotas usando la función externa
|
|
1292
|
+
await _update_quotes(self.hass, "Prediccio") # Asegúrate de usar el nombre correcto del plan aquí
|
|
1293
|
+
|
|
1206
1294
|
# Añadir la clave 'actualitzat' con la fecha y hora actual de la zona horaria local
|
|
1207
1295
|
current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
1208
1296
|
data_with_timestamp = {
|
|
@@ -1557,3 +1645,430 @@ class MeteocatAlertsRegionCoordinator(DataUpdateCoordinator):
|
|
|
1557
1645
|
_LOGGER.info("Detalles recibidos: %s", alert_data.get("detalles", []))
|
|
1558
1646
|
|
|
1559
1647
|
return alert_data
|
|
1648
|
+
|
|
1649
|
+
class MeteocatQuotesCoordinator(DataUpdateCoordinator):
|
|
1650
|
+
"""Coordinator para manejar la actualización de las cuotas de la API de Meteocat."""
|
|
1651
|
+
|
|
1652
|
+
def __init__(
|
|
1653
|
+
self,
|
|
1654
|
+
hass: HomeAssistant,
|
|
1655
|
+
entry_data: dict,
|
|
1656
|
+
):
|
|
1657
|
+
"""
|
|
1658
|
+
Inicializa el coordinador de cuotas de Meteocat.
|
|
1659
|
+
|
|
1660
|
+
Args:
|
|
1661
|
+
hass (HomeAssistant): Instancia de Home Assistant.
|
|
1662
|
+
entry_data (dict): Datos de configuración obtenidos de core.config_entries.
|
|
1663
|
+
"""
|
|
1664
|
+
self.api_key = entry_data["api_key"] # Usamos la API key de la configuración
|
|
1665
|
+
self.meteocat_quotes = MeteocatQuotes(self.api_key)
|
|
1666
|
+
|
|
1667
|
+
self.quotes_file = os.path.join(
|
|
1668
|
+
hass.config.path(),
|
|
1669
|
+
"custom_components",
|
|
1670
|
+
"meteocat",
|
|
1671
|
+
"files",
|
|
1672
|
+
"quotes.json"
|
|
1673
|
+
)
|
|
1674
|
+
|
|
1675
|
+
super().__init__(
|
|
1676
|
+
hass,
|
|
1677
|
+
_LOGGER,
|
|
1678
|
+
name=f"{DOMAIN} Quotes Coordinator",
|
|
1679
|
+
update_interval=DEFAULT_QUOTES_UPDATE_INTERVAL,
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
async def _async_update_data(self) -> Dict:
|
|
1683
|
+
"""Actualiza los datos de las cuotas desde la API de Meteocat o usa datos en caché según la antigüedad."""
|
|
1684
|
+
existing_data = await load_json_from_file(self.quotes_file) or {}
|
|
1685
|
+
|
|
1686
|
+
# Definir la duración de validez de los datos
|
|
1687
|
+
validity_duration = timedelta(minutes=DEFAULT_QUOTES_VALIDITY_TIME)
|
|
1688
|
+
|
|
1689
|
+
# Si no existe el archivo
|
|
1690
|
+
if not existing_data:
|
|
1691
|
+
return await self._fetch_and_save_new_data()
|
|
1692
|
+
else:
|
|
1693
|
+
# Comprobar la antigüedad de los datos
|
|
1694
|
+
last_update = datetime.fromisoformat(existing_data['actualitzat']['dataUpdate'])
|
|
1695
|
+
now = datetime.now(timezone.utc).astimezone(TIMEZONE)
|
|
1696
|
+
|
|
1697
|
+
# Comparar la antigüedad de los datos
|
|
1698
|
+
if now - last_update >= validity_duration:
|
|
1699
|
+
return await self._fetch_and_save_new_data()
|
|
1700
|
+
else:
|
|
1701
|
+
# Devolver los datos del archivo existente
|
|
1702
|
+
_LOGGER.debug("Usando datos existentes de cuotas: %s", existing_data)
|
|
1703
|
+
return {
|
|
1704
|
+
"actualizado": existing_data['actualitzat']['dataUpdate']
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
async def _fetch_and_save_new_data(self):
|
|
1708
|
+
"""Obtiene nuevos datos de la API y los guarda en el archivo JSON."""
|
|
1709
|
+
try:
|
|
1710
|
+
data = await asyncio.wait_for(
|
|
1711
|
+
self.meteocat_quotes.get_quotes(),
|
|
1712
|
+
timeout=30 # Tiempo límite de 30 segundos
|
|
1713
|
+
)
|
|
1714
|
+
_LOGGER.debug("Datos de cuotas actualizados exitosamente: %s", data)
|
|
1715
|
+
|
|
1716
|
+
if not isinstance(data, dict):
|
|
1717
|
+
_LOGGER.error("Formato inválido: Se esperaba un diccionario, pero se obtuvo %s", type(data).__name__)
|
|
1718
|
+
raise ValueError("Formato de datos inválido")
|
|
1719
|
+
|
|
1720
|
+
# Modificar los nombres de los planes con normalización
|
|
1721
|
+
plan_mapping = {
|
|
1722
|
+
"xdde_": "XDDE",
|
|
1723
|
+
"prediccio_": "Prediccio",
|
|
1724
|
+
"referencia basic": "Basic",
|
|
1725
|
+
"xema_": "XEMA",
|
|
1726
|
+
"quota": "Quota"
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
modified_plans = []
|
|
1730
|
+
for plan in data["plans"]:
|
|
1731
|
+
normalized_nom = normalize_name(plan["nom"])
|
|
1732
|
+
new_name = next((v for k, v in plan_mapping.items() if normalized_nom.startswith(k)), plan["nom"])
|
|
1733
|
+
|
|
1734
|
+
# Si el plan es "Quota", actualizamos las consultas realizadas y restantes
|
|
1735
|
+
if new_name == "Quota":
|
|
1736
|
+
plan["consultesRealitzades"] += 1
|
|
1737
|
+
plan["consultesRestants"] = max(0, plan["consultesRestants"] - 1)
|
|
1738
|
+
|
|
1739
|
+
modified_plans.append({
|
|
1740
|
+
"nom": new_name,
|
|
1741
|
+
"periode": plan["periode"],
|
|
1742
|
+
"maxConsultes": plan["maxConsultes"],
|
|
1743
|
+
"consultesRestants": plan["consultesRestants"],
|
|
1744
|
+
"consultesRealitzades": plan["consultesRealitzades"]
|
|
1745
|
+
})
|
|
1746
|
+
|
|
1747
|
+
# Añadir la clave 'actualitzat' con la fecha y hora actual de la zona horaria local
|
|
1748
|
+
current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
1749
|
+
data_with_timestamp = {
|
|
1750
|
+
"actualitzat": {
|
|
1751
|
+
"dataUpdate": current_time
|
|
1752
|
+
},
|
|
1753
|
+
"client": data["client"],
|
|
1754
|
+
"plans": modified_plans
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
# Guardar los datos en un archivo JSON
|
|
1758
|
+
await save_json_to_file(data_with_timestamp, self.quotes_file)
|
|
1759
|
+
|
|
1760
|
+
# Devolver tanto los datos de alertas como la fecha de actualización
|
|
1761
|
+
return {
|
|
1762
|
+
"actualizado": data_with_timestamp['actualitzat']['dataUpdate']
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
except asyncio.TimeoutError as err:
|
|
1766
|
+
_LOGGER.warning("Tiempo de espera agotado al obtener las cuotas de la API de Meteocat.")
|
|
1767
|
+
raise ConfigEntryNotReady from err
|
|
1768
|
+
except ForbiddenError as err:
|
|
1769
|
+
_LOGGER.error("Acceso denegado al obtener cuotas de la API de Meteocat: %s", err)
|
|
1770
|
+
raise ConfigEntryNotReady from err
|
|
1771
|
+
except TooManyRequestsError as err:
|
|
1772
|
+
_LOGGER.warning("Límite de solicitudes alcanzado al obtener cuotas de la API de Meteocat: %s", err)
|
|
1773
|
+
raise ConfigEntryNotReady from err
|
|
1774
|
+
except (BadRequestError, InternalServerError, UnknownAPIError) as err:
|
|
1775
|
+
_LOGGER.error("Error al obtener cuotas de la API de Meteocat: %s", err)
|
|
1776
|
+
raise
|
|
1777
|
+
except Exception as err:
|
|
1778
|
+
_LOGGER.exception("Error inesperado al obtener cuotas de la API de Meteocat: %s", err)
|
|
1779
|
+
|
|
1780
|
+
# Intentar cargar datos en caché si hay un error
|
|
1781
|
+
cached_data = await load_json_from_file(self.quotes_file)
|
|
1782
|
+
if cached_data:
|
|
1783
|
+
_LOGGER.warning("Usando datos en caché para las cuotas de la API de Meteocat.")
|
|
1784
|
+
return cached_data
|
|
1785
|
+
|
|
1786
|
+
_LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché.")
|
|
1787
|
+
return None
|
|
1788
|
+
|
|
1789
|
+
class MeteocatQuotesFileCoordinator(DataUpdateCoordinator):
|
|
1790
|
+
"""Coordinator para manejar la actualización de las cuotas desde quotes.json."""
|
|
1791
|
+
|
|
1792
|
+
def __init__(
|
|
1793
|
+
self,
|
|
1794
|
+
hass: HomeAssistant,
|
|
1795
|
+
entry_data: dict,
|
|
1796
|
+
):
|
|
1797
|
+
"""
|
|
1798
|
+
Inicializa el coordinador del sensor de cuotas de la API de Meteocat.
|
|
1799
|
+
|
|
1800
|
+
Args:
|
|
1801
|
+
hass (HomeAssistant): Instancia de Home Assistant.
|
|
1802
|
+
entry_data (dict): Datos de configuración obtenidos de core.config_entries.
|
|
1803
|
+
update_interval (timedelta): Intervalo de actualización.
|
|
1804
|
+
"""
|
|
1805
|
+
self.town_id = entry_data["town_id"] # Usamos el ID del municipio
|
|
1806
|
+
|
|
1807
|
+
super().__init__(
|
|
1808
|
+
hass,
|
|
1809
|
+
_LOGGER,
|
|
1810
|
+
name="Meteocat Quotes File Coordinator",
|
|
1811
|
+
update_interval=DEFAULT_QUOTES_FILE_UPDATE_INTERVAL,
|
|
1812
|
+
)
|
|
1813
|
+
self.quotes_file = os.path.join(
|
|
1814
|
+
hass.config.path(),
|
|
1815
|
+
"custom_components",
|
|
1816
|
+
"meteocat",
|
|
1817
|
+
"files",
|
|
1818
|
+
"quotes.json"
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
async def _async_update_data(self) -> Dict[str, Any]:
|
|
1822
|
+
"""Carga los datos de quotes.json y devuelve el estado de las cuotas."""
|
|
1823
|
+
existing_data = await self._load_json_file()
|
|
1824
|
+
|
|
1825
|
+
if not existing_data:
|
|
1826
|
+
_LOGGER.warning("No se encontraron datos en quotes.json.")
|
|
1827
|
+
return {}
|
|
1828
|
+
|
|
1829
|
+
return {
|
|
1830
|
+
"actualizado": existing_data.get("actualitzat", {}).get("dataUpdate"),
|
|
1831
|
+
"client": existing_data.get("client", {}).get("nom"),
|
|
1832
|
+
"plans": [
|
|
1833
|
+
{
|
|
1834
|
+
"nom": plan.get("nom"),
|
|
1835
|
+
"periode": plan.get("periode"),
|
|
1836
|
+
"maxConsultes": plan.get("maxConsultes"),
|
|
1837
|
+
"consultesRestants": plan.get("consultesRestants"),
|
|
1838
|
+
"consultesRealitzades": plan.get("consultesRealitzades"),
|
|
1839
|
+
}
|
|
1840
|
+
for plan in existing_data.get("plans", [])
|
|
1841
|
+
]
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
async def _load_json_file(self) -> dict:
|
|
1845
|
+
"""Carga el archivo JSON de forma asincrónica."""
|
|
1846
|
+
try:
|
|
1847
|
+
async with aiofiles.open(self.quotes_file, "r", encoding="utf-8") as f:
|
|
1848
|
+
data = await f.read()
|
|
1849
|
+
return json.loads(data)
|
|
1850
|
+
except FileNotFoundError:
|
|
1851
|
+
_LOGGER.warning("El archivo %s no existe.", self.quotes_file)
|
|
1852
|
+
return {}
|
|
1853
|
+
except json.JSONDecodeError as err:
|
|
1854
|
+
_LOGGER.error("Error al decodificar JSON del archivo %s: %s", self.quotes_file, err)
|
|
1855
|
+
return {}
|
|
1856
|
+
|
|
1857
|
+
async def get_plan_info(self, plan_name: str) -> dict:
|
|
1858
|
+
"""Obtiene la información de un plan específico."""
|
|
1859
|
+
data = await self._async_update_data()
|
|
1860
|
+
for plan in data.get("plans", []):
|
|
1861
|
+
if plan.get("nom") == plan_name:
|
|
1862
|
+
return {
|
|
1863
|
+
"nom": plan.get("nom"),
|
|
1864
|
+
"periode": plan.get("periode"),
|
|
1865
|
+
"maxConsultes": plan.get("maxConsultes"),
|
|
1866
|
+
"consultesRestants": plan.get("consultesRestants"),
|
|
1867
|
+
"consultesRealitzades": plan.get("consultesRealitzades"),
|
|
1868
|
+
}
|
|
1869
|
+
_LOGGER.warning("Plan %s no encontrado en quotes.json.", plan_name)
|
|
1870
|
+
return {}
|
|
1871
|
+
|
|
1872
|
+
class MeteocatLightningCoordinator(DataUpdateCoordinator):
|
|
1873
|
+
"""Coordinator para manejar la actualización de los datos de rayos de la API de Meteocat."""
|
|
1874
|
+
|
|
1875
|
+
def __init__(
|
|
1876
|
+
self,
|
|
1877
|
+
hass: HomeAssistant,
|
|
1878
|
+
entry_data: dict,
|
|
1879
|
+
):
|
|
1880
|
+
"""
|
|
1881
|
+
Inicializa el coordinador de rayos de Meteocat.
|
|
1882
|
+
|
|
1883
|
+
Args:
|
|
1884
|
+
hass (HomeAssistant): Instancia de Home Assistant.
|
|
1885
|
+
entry_data (dict): Datos de configuración obtenidos de core.config_entries.
|
|
1886
|
+
"""
|
|
1887
|
+
self.api_key = entry_data["api_key"] # API Key de la configuración
|
|
1888
|
+
self.region_id = entry_data["region_id"] # Región de la configuración
|
|
1889
|
+
self.meteocat_lightning = MeteocatLightning(self.api_key)
|
|
1890
|
+
|
|
1891
|
+
self.lightning_file = os.path.join(
|
|
1892
|
+
hass.config.path(),
|
|
1893
|
+
"custom_components",
|
|
1894
|
+
"meteocat",
|
|
1895
|
+
"files",
|
|
1896
|
+
f"lightning_{self.region_id}.json",
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
super().__init__(
|
|
1900
|
+
hass,
|
|
1901
|
+
_LOGGER,
|
|
1902
|
+
name=f"{DOMAIN} Lightning Coordinator",
|
|
1903
|
+
update_interval=DEFAULT_LIGHTNING_UPDATE_INTERVAL,
|
|
1904
|
+
)
|
|
1905
|
+
|
|
1906
|
+
async def _async_update_data(self) -> Dict:
|
|
1907
|
+
"""Actualiza los datos de rayos desde la API de Meteocat o usa datos en caché según la antigüedad."""
|
|
1908
|
+
existing_data = await load_json_from_file(self.lightning_file) or {}
|
|
1909
|
+
|
|
1910
|
+
# Definir la duración de validez de los datos
|
|
1911
|
+
validity_duration = timedelta(minutes=DEFAULT_LIGHTNING_VALIDITY_TIME)
|
|
1912
|
+
|
|
1913
|
+
if not existing_data:
|
|
1914
|
+
return await self._fetch_and_save_new_data()
|
|
1915
|
+
else:
|
|
1916
|
+
last_update = datetime.fromisoformat(existing_data['actualitzat']['dataUpdate'])
|
|
1917
|
+
now = datetime.now(timezone.utc).astimezone(TIMEZONE)
|
|
1918
|
+
|
|
1919
|
+
if now - last_update >= validity_duration:
|
|
1920
|
+
return await self._fetch_and_save_new_data()
|
|
1921
|
+
else:
|
|
1922
|
+
_LOGGER.debug("Usando datos existentes de rayos: %s", existing_data)
|
|
1923
|
+
return {"actualizado": existing_data['actualitzat']['dataUpdate']}
|
|
1924
|
+
|
|
1925
|
+
async def _fetch_and_save_new_data(self):
|
|
1926
|
+
"""Obtiene nuevos datos de la API y los guarda en el archivo JSON."""
|
|
1927
|
+
try:
|
|
1928
|
+
data = await asyncio.wait_for(
|
|
1929
|
+
self.meteocat_lightning.get_lightning_data(self.region_id),
|
|
1930
|
+
timeout=30 # Tiempo límite de 30 segundos
|
|
1931
|
+
)
|
|
1932
|
+
_LOGGER.debug("Datos de rayos actualizados exitosamente: %s", data)
|
|
1933
|
+
|
|
1934
|
+
# Verificar que `data` sea una lista (como la API de Meteocat devuelve)
|
|
1935
|
+
if not isinstance(data, list):
|
|
1936
|
+
_LOGGER.error("Formato inválido: Se esperaba una lista, pero se obtuvo %s", type(data).__name__)
|
|
1937
|
+
raise ValueError("Formato de datos inválido")
|
|
1938
|
+
|
|
1939
|
+
# Estructurar los datos en el formato correcto
|
|
1940
|
+
current_time = datetime.now(timezone.utc).astimezone(TIMEZONE).isoformat()
|
|
1941
|
+
data_with_timestamp = {
|
|
1942
|
+
"actualitzat": {
|
|
1943
|
+
"dataUpdate": current_time
|
|
1944
|
+
},
|
|
1945
|
+
"dades": data # Siempre será una lista
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
# Guardar los datos en un archivo JSON
|
|
1949
|
+
await save_json_to_file(data_with_timestamp, self.lightning_file)
|
|
1950
|
+
|
|
1951
|
+
# Actualizar cuotas usando la función externa
|
|
1952
|
+
await _update_quotes(self.hass, "XDDE") # Asegúrate de usar el nombre correcto del plan aquí
|
|
1953
|
+
|
|
1954
|
+
return {"actualizado": data_with_timestamp['actualitzat']['dataUpdate']}
|
|
1955
|
+
|
|
1956
|
+
except asyncio.TimeoutError as err:
|
|
1957
|
+
_LOGGER.warning("Tiempo de espera agotado al obtener los datos de rayos de la API de Meteocat.")
|
|
1958
|
+
raise ConfigEntryNotReady from err
|
|
1959
|
+
except Exception as err:
|
|
1960
|
+
_LOGGER.exception("Error inesperado al obtener los datos de rayos de la API de Meteocat: %s", err)
|
|
1961
|
+
|
|
1962
|
+
# Intentar cargar datos en caché si la API falla
|
|
1963
|
+
cached_data = await load_json_from_file(self.lightning_file)
|
|
1964
|
+
if cached_data:
|
|
1965
|
+
_LOGGER.warning("Usando datos en caché para los datos de rayos de la API de Meteocat.")
|
|
1966
|
+
return cached_data
|
|
1967
|
+
|
|
1968
|
+
_LOGGER.error("No se pudo obtener datos actualizados ni cargar datos en caché.")
|
|
1969
|
+
return None
|
|
1970
|
+
|
|
1971
|
+
class MeteocatLightningFileCoordinator(DataUpdateCoordinator):
|
|
1972
|
+
"""Coordinator para manejar la actualización de los datos de rayos desde lightning_{region_id}.json."""
|
|
1973
|
+
|
|
1974
|
+
def __init__(
|
|
1975
|
+
self,
|
|
1976
|
+
hass: HomeAssistant,
|
|
1977
|
+
entry_data: dict,
|
|
1978
|
+
):
|
|
1979
|
+
"""
|
|
1980
|
+
Inicializa el coordinador de rayos desde archivo.
|
|
1981
|
+
|
|
1982
|
+
Args:
|
|
1983
|
+
hass (HomeAssistant): Instancia de Home Assistant.
|
|
1984
|
+
entry_data (dict): Datos de configuración de la entrada.
|
|
1985
|
+
"""
|
|
1986
|
+
self.region_id = entry_data["region_id"]
|
|
1987
|
+
self.town_id = entry_data["town_id"]
|
|
1988
|
+
|
|
1989
|
+
self.lightning_file = os.path.join(
|
|
1990
|
+
hass.config.path(),
|
|
1991
|
+
"custom_components",
|
|
1992
|
+
"meteocat",
|
|
1993
|
+
"files",
|
|
1994
|
+
f"lightning_{self.region_id}.json",
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
super().__init__(
|
|
1998
|
+
hass,
|
|
1999
|
+
_LOGGER,
|
|
2000
|
+
name="Meteocat Lightning File Coordinator",
|
|
2001
|
+
update_interval=DEFAULT_LIGHTNING_FILE_UPDATE_INTERVAL,
|
|
2002
|
+
)
|
|
2003
|
+
|
|
2004
|
+
async def _async_update_data(self) -> Dict[str, Any]:
|
|
2005
|
+
"""Carga los datos de rayos desde el archivo JSON y procesa la información."""
|
|
2006
|
+
existing_data = await load_json_from_file(self.lightning_file)
|
|
2007
|
+
|
|
2008
|
+
if not existing_data:
|
|
2009
|
+
_LOGGER.warning("No se encontraron datos en %s.", self.lightning_file)
|
|
2010
|
+
return {
|
|
2011
|
+
"actualizado": datetime.now(ZoneInfo("Europe/Madrid")).isoformat(),
|
|
2012
|
+
"region": self._reset_data(),
|
|
2013
|
+
"town": self._reset_data()
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
# Convertir la cadena de fecha a un objeto datetime y ajustar a la zona horaria local
|
|
2017
|
+
update_date = datetime.fromisoformat(existing_data.get("actualitzat", {}).get("dataUpdate", ""))
|
|
2018
|
+
update_date = update_date.astimezone(ZoneInfo("Europe/Madrid"))
|
|
2019
|
+
now = datetime.now(ZoneInfo("Europe/Madrid"))
|
|
2020
|
+
|
|
2021
|
+
if update_date.date() != now.date(): # Si la fecha no es la de hoy
|
|
2022
|
+
_LOGGER.info("Los datos de rayos son de un día diferente. Reiniciando valores a cero.")
|
|
2023
|
+
region_data = town_data = self._reset_data()
|
|
2024
|
+
update_date = datetime.now(ZoneInfo("Europe/Madrid")).isoformat() # Usar la fecha actual
|
|
2025
|
+
else:
|
|
2026
|
+
region_data = self._process_region_data(existing_data.get("dades", []))
|
|
2027
|
+
town_data = self._process_town_data(existing_data.get("dades", []))
|
|
2028
|
+
|
|
2029
|
+
return {
|
|
2030
|
+
"actualizado": update_date,
|
|
2031
|
+
"region": region_data,
|
|
2032
|
+
"town": town_data
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
def _process_region_data(self, data_list):
|
|
2036
|
+
"""Suma los tipos de descargas para toda la región."""
|
|
2037
|
+
region_counts = {
|
|
2038
|
+
"cc": 0,
|
|
2039
|
+
"cg-": 0,
|
|
2040
|
+
"cg+": 0
|
|
2041
|
+
}
|
|
2042
|
+
for town in data_list:
|
|
2043
|
+
for discharge in town.get("descarregues", []):
|
|
2044
|
+
if discharge["tipus"] in region_counts:
|
|
2045
|
+
region_counts[discharge["tipus"]] += discharge["recompte"]
|
|
2046
|
+
|
|
2047
|
+
region_counts["total"] = sum(region_counts.values())
|
|
2048
|
+
return region_counts
|
|
2049
|
+
|
|
2050
|
+
def _process_town_data(self, data_list):
|
|
2051
|
+
"""Encuentra y suma los tipos de descargas para un municipio específico."""
|
|
2052
|
+
town_counts = {
|
|
2053
|
+
"cc": 0,
|
|
2054
|
+
"cg-": 0,
|
|
2055
|
+
"cg+": 0
|
|
2056
|
+
}
|
|
2057
|
+
for town in data_list:
|
|
2058
|
+
if town["codi"] == self.town_id:
|
|
2059
|
+
for discharge in town.get("descarregues", []):
|
|
2060
|
+
if discharge["tipus"] in town_counts:
|
|
2061
|
+
town_counts[discharge["tipus"]] += discharge["recompte"]
|
|
2062
|
+
break # Solo necesitamos datos de un municipio
|
|
2063
|
+
|
|
2064
|
+
town_counts["total"] = sum(town_counts.values())
|
|
2065
|
+
return town_counts
|
|
2066
|
+
|
|
2067
|
+
def _reset_data(self):
|
|
2068
|
+
"""Resetea los datos a cero."""
|
|
2069
|
+
return {
|
|
2070
|
+
"cc": 0,
|
|
2071
|
+
"cg-": 0,
|
|
2072
|
+
"cg+": 0,
|
|
2073
|
+
"total": 0
|
|
2074
|
+
}
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"iot_class": "cloud_polling",
|
|
9
9
|
"issue_tracker": "https://github.com/figorr/meteocat/issues",
|
|
10
10
|
"loggers": ["meteocatpy"],
|
|
11
|
-
"requirements": ["meteocatpy==
|
|
12
|
-
"version": "2.
|
|
11
|
+
"requirements": ["meteocatpy==1.0.1", "packaging>=20.3", "wrapt>=1.14.0"],
|
|
12
|
+
"version": "2.2.3"
|
|
13
13
|
}
|