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.
@@ -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
- data = await asyncio.wait_for(api_method(self.town_id), timeout=30)
555
- await save_json_to_file(data, file_path)
556
- return data
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==0.0.20", "packaging>=20.3", "wrapt>=1.14.0"],
12
- "version": "2.1.0"
11
+ "requirements": ["meteocatpy==1.0.1", "packaging>=20.3", "wrapt>=1.14.0"],
12
+ "version": "2.2.3"
13
13
  }