meteocat 0.1.38 → 0.1.40

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,16 +5,17 @@ import json
5
5
  import aiofiles
6
6
  import logging
7
7
  import asyncio
8
- from datetime import datetime, timedelta
8
+ from datetime import datetime, timedelta, timezone
9
9
  from typing import Dict, Any
10
10
 
11
11
  from homeassistant.core import HomeAssistant
12
12
  from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
13
13
  from homeassistant.exceptions import ConfigEntryNotReady
14
+ from homeassistant.components.weather import Forecast
14
15
 
15
16
  from meteocatpy.data import MeteocatStationData
16
17
  from meteocatpy.uvi import MeteocatUviData
17
- # from meteocatpy.forecast import MeteocatForecast
18
+ from meteocatpy.forecast import MeteocatForecast
18
19
 
19
20
  from meteocatpy.exceptions import (
20
21
  BadRequestError,
@@ -24,17 +25,23 @@ from meteocatpy.exceptions import (
24
25
  UnknownAPIError,
25
26
  )
26
27
 
27
- from .const import DOMAIN
28
+ from .condition import get_condition_from_statcel
29
+ from .const import (
30
+ DOMAIN,
31
+ CONDITION_MAPPING,
32
+ )
28
33
 
29
34
  _LOGGER = logging.getLogger(__name__)
30
35
 
31
36
  # Valores predeterminados para los intervalos de actualización
32
37
  DEFAULT_SENSOR_UPDATE_INTERVAL = timedelta(minutes=90)
38
+ DEFAULT_STATIC_SENSOR_UPDATE_INTERVAL = timedelta(hours=24)
33
39
  DEFAULT_ENTITY_UPDATE_INTERVAL = timedelta(hours=48)
34
- DEFAULT_HOURLY_FORECAST_UPDATE_INTERVAL = timedelta(hours=24)
35
- DEFAULT_DAYLY_FORECAST_UPDATE_INTERVAL = timedelta(hours=48)
40
+ DEFAULT_HOURLY_FORECAST_UPDATE_INTERVAL = timedelta(minutes=5)
41
+ DEFAULT_DAILY_FORECAST_UPDATE_INTERVAL = timedelta(minutes=15)
36
42
  DEFAULT_UVI_UPDATE_INTERVAL = timedelta(hours=48)
37
43
  DEFAULT_UVI_SENSOR_UPDATE_INTERVAL = timedelta(minutes=5)
44
+ DEFAULT_CONDITION_SENSOR_UPDATE_INTERVAL = timedelta(minutes=5)
38
45
 
39
46
  async def save_json_to_file(data: dict, output_file: str) -> None:
40
47
  """Guarda datos JSON en un archivo de forma asíncrona."""
@@ -48,15 +55,26 @@ async def save_json_to_file(data: dict, output_file: str) -> None:
48
55
  except Exception as e:
49
56
  raise RuntimeError(f"Error guardando JSON to {output_file}: {e}")
50
57
 
51
- def load_json_from_file(input_file: str) -> dict | list | None:
52
- """Carga datos JSON desde un archivo de forma síncrona."""
58
+ async def load_json_from_file(input_file: str) -> dict:
59
+ """
60
+ Carga un archivo JSON de forma asincrónica.
61
+
62
+ Args:
63
+ input_file (str): Ruta del archivo JSON.
64
+
65
+ Returns:
66
+ dict: Datos JSON cargados.
67
+ """
53
68
  try:
54
- if os.path.exists(input_file):
55
- with open(input_file, "r", encoding="utf-8") as f:
56
- return json.load(f)
57
- except Exception as e:
58
- _LOGGER.error(f"Error cargando JSON desde {input_file}: {e}")
59
- return None
69
+ async with aiofiles.open(input_file, "r", encoding="utf-8") as f:
70
+ data = await f.read()
71
+ return json.loads(data)
72
+ except FileNotFoundError:
73
+ _LOGGER.warning("El archivo %s no existe.", input_file)
74
+ return {}
75
+ except json.JSONDecodeError as err:
76
+ _LOGGER.error("Error al decodificar JSON del archivo %s: %s", input_file, err)
77
+ return {}
60
78
 
61
79
  class MeteocatSensorCoordinator(DataUpdateCoordinator):
62
80
  """Coordinator para manejar la actualización de datos de los sensores."""
@@ -171,6 +189,55 @@ class MeteocatSensorCoordinator(DataUpdateCoordinator):
171
189
  # No se puede actualizar el estado, retornar None o un estado fallido
172
190
  return None # o cualquier otro valor que indique un estado de error
173
191
 
192
+ class MeteocatStaticSensorCoordinator(DataUpdateCoordinator):
193
+ """Coordinator to manage and update static sensor data."""
194
+
195
+ def __init__(
196
+ self,
197
+ hass: HomeAssistant,
198
+ entry_data: dict,
199
+ update_interval: timedelta = DEFAULT_STATIC_SENSOR_UPDATE_INTERVAL,
200
+ ):
201
+ """
202
+ Initialize the MeteocatStaticSensorCoordinator.
203
+
204
+ Args:
205
+ hass (HomeAssistant): Home Assistant instance.
206
+ entry_data (dict): Configuration data from core.config_entries.
207
+ update_interval (timedelta): Update interval for the coordinator.
208
+ """
209
+ self.town_name = entry_data["town_name"] # Nombre del municipio
210
+ self.town_id = entry_data["town_id"] # ID del municipio
211
+ self.station_name = entry_data["station_name"] # Nombre de la estación
212
+ self.station_id = entry_data["station_id"] # ID de la estación
213
+
214
+ super().__init__(
215
+ hass,
216
+ _LOGGER,
217
+ name=f"{DOMAIN} Static Sensor Coordinator",
218
+ update_interval=update_interval,
219
+ )
220
+
221
+ async def _async_update_data(self):
222
+ """
223
+ Fetch and return static sensor data.
224
+
225
+ Since static sensors use entry_data, this method simply logs the process.
226
+ """
227
+ _LOGGER.debug(
228
+ "Updating static sensor data for town: %s (ID: %s), station: %s (ID: %s)",
229
+ self.town_name,
230
+ self.town_id,
231
+ self.station_name,
232
+ self.station_id,
233
+ )
234
+ return {
235
+ "town_name": self.town_name,
236
+ "town_id": self.town_id,
237
+ "station_name": self.station_name,
238
+ "station_id": self.station_id,
239
+ }
240
+
174
241
  class MeteocatUviCoordinator(DataUpdateCoordinator):
175
242
  """Coordinator para manejar la actualización de datos de los sensores."""
176
243
 
@@ -191,6 +258,13 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
191
258
  self.api_key = entry_data["api_key"] # Usamos la API key de la configuración
192
259
  self.town_id = entry_data["town_id"] # Usamos el ID del municipio
193
260
  self.meteocat_uvi_data = MeteocatUviData(self.api_key)
261
+ self.output_file = os.path.join(
262
+ hass.config.path(),
263
+ "custom_components",
264
+ "meteocat",
265
+ "files",
266
+ f"uvi_{self.town_id.lower()}_data.json"
267
+ )
194
268
 
195
269
  super().__init__(
196
270
  hass,
@@ -199,9 +273,43 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
199
273
  update_interval=update_interval,
200
274
  )
201
275
 
276
+ async def is_uvi_data_valid(self) -> dict:
277
+ """Comprueba si el archivo JSON contiene datos válidos para el día actual y devuelve los datos si son válidos."""
278
+ try:
279
+ if not os.path.exists(self.output_file):
280
+ return None
281
+
282
+ async with aiofiles.open(self.output_file, "r", encoding="utf-8") as file:
283
+ content = await file.read()
284
+ data = json.loads(content)
285
+
286
+ # Verificar que el formato sea correcto
287
+ if not isinstance(data, dict) or "uvi" not in data:
288
+ return None
289
+
290
+ uvi_data = data["uvi"]
291
+ if not isinstance(uvi_data, list) or not uvi_data:
292
+ return None
293
+
294
+ # Validar la fecha del primer elemento
295
+ first_date = uvi_data[0].get("date")
296
+ if first_date != datetime.now(timezone.utc).strftime("%Y-%m-%d"):
297
+ return None
298
+
299
+ return data
300
+ except Exception as e:
301
+ _LOGGER.error("Error al validar el archivo JSON del índice UV: %s", e)
302
+ return None
303
+
202
304
  async def _async_update_data(self) -> Dict:
203
305
  """Actualiza los datos de los sensores desde la API de Meteocat."""
204
306
  try:
307
+ # Validar el archivo JSON existente
308
+ valid_data = await self.is_uvi_data_valid()
309
+ if valid_data and "uvi" in valid_data:
310
+ _LOGGER.info("Los datos del índice UV están actualizados. No se realiza llamada a la API.")
311
+ return valid_data['uvi']
312
+
205
313
  # Obtener datos desde la API con manejo de tiempo límite
206
314
  data = await asyncio.wait_for(
207
315
  self.meteocat_uvi_data.get_uvi_index(self.town_id),
@@ -213,28 +321,11 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
213
321
  if not isinstance(data, dict) or 'uvi' not in data:
214
322
  _LOGGER.error("Formato inválido: Se esperaba un dict con la clave 'uvi'. Datos: %s", data)
215
323
  raise ValueError("Formato de datos inválido")
216
-
217
- # Extraer la lista de datos bajo la clave 'uvi'
218
- uvi_data = data.get('uvi', [])
219
-
220
- # Validar que 'uvi' sea una lista de diccionarios
221
- if not isinstance(uvi_data, list) or not all(isinstance(item, dict) for item in uvi_data):
222
- _LOGGER.error("Formato inválido: 'uvi' debe ser una lista de dicts. Datos: %s", uvi_data)
223
- raise ValueError("Formato de datos inválido")
224
-
225
- # Determinar la ruta al archivo en la carpeta raíz del repositorio
226
- output_file = os.path.join(
227
- self.hass.config.path(),
228
- "custom_components",
229
- "meteocat",
230
- "files",
231
- f"uvi_{self.town_id.lower()}_data.json"
232
- )
233
324
 
234
325
  # Guardar los datos en un archivo JSON
235
- await save_json_to_file(data, output_file)
326
+ await save_json_to_file(data, self.output_file)
236
327
 
237
- return uvi_data
328
+ return data['uvi']
238
329
  except asyncio.TimeoutError as err:
239
330
  _LOGGER.warning("Tiempo de espera agotado al obtener datos de la API de Meteocat.")
240
331
  raise ConfigEntryNotReady from err
@@ -260,28 +351,17 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
260
351
  )
261
352
  raise
262
353
  except Exception as err:
263
- if isinstance(err, ConfigEntryNotReady):
264
- # El dispositivo no pudo inicializarse por primera vez
265
- _LOGGER.exception(
266
- "No se pudo inicializar el dispositivo (Town ID: %s) debido a un error: %s",
267
- self.town_id,
268
- err,
269
- )
270
- raise # Re-raise the exception to indicate a fundamental failure in initialization
271
- else:
272
- # Manejar error durante la actualización de datos
273
- _LOGGER.exception(
274
- "Error inesperado al obtener datos del índice UV para (Town ID: %s): %s",
275
- self.town_id,
276
- err,
277
- )
278
- # Intentar cargar datos en caché si hay un error
279
- cached_data = load_json_from_file(output_file)
280
- if cached_data:
281
- _LOGGER.info("Usando datos en caché para la ciudad %s.", self.town_id)
282
- return cached_data
283
- # No se puede actualizar el estado, retornar None o un estado fallido
284
- return None # o cualquier otro valor que indique un estado de error
354
+ _LOGGER.exception(
355
+ "Error inesperado al obtener datos del índice UV para (Town ID: %s): %s",
356
+ self.town_id,
357
+ err,
358
+ )
359
+ # Intentar cargar datos en caché si hay un error
360
+ cached_data = load_json_from_file(self.output_file)
361
+ if cached_data:
362
+ _LOGGER.info("Usando datos en caché para la ciudad %s.", self.town_id)
363
+ return cached_data.get('uvi', [])
364
+ return None
285
365
 
286
366
  class MeteocatUviFileCoordinator(DataUpdateCoordinator):
287
367
  """Coordinator to read and process UV data from a file."""
@@ -361,76 +441,462 @@ class MeteocatUviFileCoordinator(DataUpdateCoordinator):
361
441
  )
362
442
  return {"hour": 0, "uvi": 0, "uvi_clouds": 0}
363
443
 
364
- # class MeteocatEntityCoordinator(DataUpdateCoordinator):
365
- # """Coordinator para manejar la actualización de datos de las entidades de predicción."""
366
-
367
- # def __init__(
368
- # self,
369
- # hass: HomeAssistant,
370
- # entry_data: dict,
371
- # update_interval: timedelta = DEFAULT_ENTITY_UPDATE_INTERVAL,
372
- # ):
373
- # """
374
- # Inicializa el coordinador de datos para entidades de predicción.
375
-
376
- # Args:
377
- # hass (HomeAssistant): Instancia de Home Assistant.
378
- # entry_data (dict): Datos de configuración obtenidos de core.config_entries.
379
- # update_interval (timedelta): Intervalo de actualización.
380
- # """
381
- # self.api_key = entry_data["api_key"]
382
- # self.town_name = entry_data["town_name"]
383
- # self.town_id = entry_data["town_id"]
384
- # self.station_name = entry_data["station_name"]
385
- # self.station_id = entry_data["station_id"]
386
- # self.variable_name = entry_data["variable_name"]
387
- # self.variable_id = entry_data["variable_id"]
388
- # self.meteocat_forecast = MeteocatForecast(self.api_key)
389
-
390
- # super().__init__(
391
- # hass,
392
- # _LOGGER,
393
- # name=f"{DOMAIN} Entity Coordinator",
394
- # update_interval=update_interval,
395
- # )
396
-
397
- # async def _async_update_data(self) -> Dict:
398
- # """Actualiza los datos de las entidades de predicción desde la API de Meteocat."""
399
- # try:
400
- # hourly_forecast = await self.meteocat_forecast.get_prediccion_horaria(self.town_id)
401
- # daily_forecast = await self.meteocat_forecast.get_prediccion_diaria(self.town_id)
402
- # _LOGGER.debug(
403
- # "Datos de predicción actualizados exitosamente (Town ID: %s)", self.town_id
404
- # )
405
- # return {
406
- # "hourly_forecast": hourly_forecast,
407
- # "daily_forecast": daily_forecast,
408
- # }
409
- # except ForbiddenError as err:
410
- # _LOGGER.error(
411
- # "Acceso denegado al obtener datos de predicción (Town ID: %s): %s",
412
- # self.town_id,
413
- # err,
414
- # )
415
- # raise ConfigEntryNotReady from err
416
- # except TooManyRequestsError as err:
417
- # _LOGGER.warning(
418
- # "Límite de solicitudes alcanzado al obtener datos de predicción (Town ID: %s): %s",
419
- # self.town_id,
420
- # err,
421
- # )
422
- # raise ConfigEntryNotReady from err
423
- # except (BadRequestError, InternalServerError, UnknownAPIError) as err:
424
- # _LOGGER.error(
425
- # "Error al obtener datos de predicción (Town ID: %s): %s",
426
- # self.town_id,
427
- # err,
428
- # )
429
- # raise
430
- # except Exception as err:
431
- # _LOGGER.exception(
432
- # "Error inesperado al obtener datos de predicción (Town ID: %s): %s",
433
- # self.town_id,
434
- # err,
435
- # )
436
- # raise
444
+ class MeteocatEntityCoordinator(DataUpdateCoordinator):
445
+ """Coordinator para manejar la actualización de datos de las entidades de predicción."""
446
+
447
+ def __init__(
448
+ self,
449
+ hass: HomeAssistant,
450
+ entry_data: dict,
451
+ update_interval: timedelta = DEFAULT_ENTITY_UPDATE_INTERVAL,
452
+ ):
453
+ """
454
+ Inicializa el coordinador de datos para entidades de predicción.
455
+
456
+ Args:
457
+ hass (HomeAssistant): Instancia de Home Assistant.
458
+ entry_data (dict): Datos de configuración obtenidos de core.config_entries.
459
+ update_interval (timedelta): Intervalo de actualización.
460
+ """
461
+ self.api_key = entry_data["api_key"]
462
+ self.town_name = entry_data["town_name"]
463
+ self.town_id = entry_data["town_id"]
464
+ self.station_name = entry_data["station_name"]
465
+ self.station_id = entry_data["station_id"]
466
+ self.variable_name = entry_data["variable_name"]
467
+ self.variable_id = entry_data["variable_id"]
468
+ self.meteocat_forecast = MeteocatForecast(self.api_key)
469
+
470
+ super().__init__(
471
+ hass,
472
+ _LOGGER,
473
+ name=f"{DOMAIN} Entity Coordinator",
474
+ update_interval=update_interval,
475
+ )
476
+
477
+ async def _is_data_valid(self, file_path: str) -> bool:
478
+ """Verifica si los datos en el archivo JSON son válidos y actuales."""
479
+ if not os.path.exists(file_path):
480
+ return False
481
+
482
+ try:
483
+ async with aiofiles.open(file_path, "r", encoding="utf-8") as f:
484
+ content = await f.read()
485
+ data = json.loads(content)
486
+
487
+ if not data or "dies" not in data or not data["dies"]:
488
+ return False
489
+
490
+ # Obtener la fecha del primer día
491
+ first_date = datetime.fromisoformat(data["dies"][0]["data"].rstrip("Z")).date()
492
+ return first_date == datetime.now(timezone.utc).date()
493
+ except Exception as e:
494
+ _LOGGER.warning("Error validando datos en %s: %s", file_path, e)
495
+ return False
496
+
497
+ async def _fetch_and_save_data(self, api_method, file_path: str) -> dict:
498
+ """Obtiene datos de la API y los guarda en un archivo JSON."""
499
+ data = await asyncio.wait_for(api_method(self.town_id), timeout=30)
500
+ await save_json_to_file(data, file_path)
501
+ return data
502
+
503
+ async def _async_update_data(self) -> Dict:
504
+ """Actualiza los datos de predicción desde la API de Meteocat."""
505
+ hourly_file = os.path.join(
506
+ self.hass.config.path(),
507
+ "custom_components",
508
+ "meteocat",
509
+ "files",
510
+ f"forecast_{self.town_id.lower()}_hourly_data.json",
511
+ )
512
+ daily_file = os.path.join(
513
+ self.hass.config.path(),
514
+ "custom_components",
515
+ "meteocat",
516
+ "files",
517
+ f"forecast_{self.town_id.lower()}_daily_data.json",
518
+ )
519
+
520
+ try:
521
+ hourly_data = (
522
+ load_json_from_file(hourly_file)
523
+ if await self._is_data_valid(hourly_file)
524
+ else await self._fetch_and_save_data(
525
+ self.meteocat_forecast.get_prediccion_horaria, hourly_file
526
+ )
527
+ )
528
+ daily_data = (
529
+ load_json_from_file(daily_file)
530
+ if await self._is_data_valid(daily_file)
531
+ else await self._fetch_and_save_data(
532
+ self.meteocat_forecast.get_prediccion_diaria, daily_file
533
+ )
534
+ )
535
+
536
+ _LOGGER.debug(
537
+ "Datos de predicción horaria y diaria actualizados correctamente para %s.",
538
+ self.town_id,
539
+ )
540
+ return {"hourly": hourly_data, "daily": daily_data}
541
+ except asyncio.TimeoutError as err:
542
+ _LOGGER.warning("Tiempo de espera agotado al obtener datos de predicción.")
543
+ raise ConfigEntryNotReady from err
544
+ except ForbiddenError as err:
545
+ _LOGGER.error(
546
+ "Acceso denegado al obtener datos de predicción (Town ID: %s): %s",
547
+ self.town_id,
548
+ err,
549
+ )
550
+ raise ConfigEntryNotReady from err
551
+ except TooManyRequestsError as err:
552
+ _LOGGER.warning(
553
+ "Límite de solicitudes alcanzado al obtener datos de predicción (Town ID: %s): %s",
554
+ self.town_id,
555
+ err,
556
+ )
557
+ raise ConfigEntryNotReady from err
558
+ except (BadRequestError, InternalServerError, UnknownAPIError) as err:
559
+ _LOGGER.error(
560
+ "Error al obtener datos de predicción (Town ID: %s): %s",
561
+ self.town_id,
562
+ err,
563
+ )
564
+ raise
565
+ except Exception as err:
566
+ _LOGGER.exception("Error inesperado al obtener datos de predicción: %s", err)
567
+ return {
568
+ "hourly": load_json_from_file(hourly_file) or {},
569
+ "daily": load_json_from_file(daily_file) or {},
570
+ }
571
+
572
+ def get_condition_from_code(code: int) -> str:
573
+ """Devuelve la condición meteorológica basada en el código."""
574
+ return next((key for key, codes in CONDITION_MAPPING.items() if code in codes), "unknown")
575
+
576
+ class HourlyForecastCoordinator(DataUpdateCoordinator):
577
+ """Coordinator para manejar las predicciones horarias desde archivos locales."""
578
+
579
+ def __init__(
580
+ self,
581
+ hass: HomeAssistant,
582
+ entry_data: dict,
583
+ update_interval: timedelta = DEFAULT_HOURLY_FORECAST_UPDATE_INTERVAL,
584
+ ):
585
+ """Inicializa el coordinador para predicciones horarias."""
586
+ self.town_name = entry_data["town_name"]
587
+ self.town_id = entry_data["town_id"]
588
+ self.station_name = entry_data["station_name"]
589
+ self.station_id = entry_data["station_id"]
590
+ self.file_path = os.path.join(
591
+ hass.config.path(),
592
+ "custom_components",
593
+ "meteocat",
594
+ "files",
595
+ f"forecast_{self.town_id.lower()}_hourly_data.json",
596
+ )
597
+ super().__init__(
598
+ hass,
599
+ _LOGGER,
600
+ name=f"{DOMAIN} Hourly Forecast Coordinator",
601
+ update_interval=update_interval,
602
+ )
603
+
604
+ async def _is_data_valid(self) -> bool:
605
+ """Verifica si los datos horarios en el archivo JSON son válidos y actuales."""
606
+ if not os.path.exists(self.file_path):
607
+ return False
608
+
609
+ try:
610
+ async with aiofiles.open(self.file_path, "r", encoding="utf-8") as f:
611
+ content = await f.read()
612
+ data = json.loads(content)
613
+
614
+ if not data or "dies" not in data:
615
+ return False
616
+
617
+ now = datetime.now(timezone.utc)
618
+ for dia in data["dies"]:
619
+ for forecast in dia.get("variables", {}).get("estatCel", {}).get("valors", []):
620
+ forecast_time = datetime.fromisoformat(forecast["data"].rstrip("Z")).replace(tzinfo=timezone.utc)
621
+ if forecast_time >= now:
622
+ return True
623
+
624
+ return False
625
+ except Exception as e:
626
+ _LOGGER.warning("Error validando datos horarios en %s: %s", self.file_path, e)
627
+ return False
628
+
629
+ async def _async_update_data(self) -> dict:
630
+ """Lee los datos horarios desde el archivo local."""
631
+ if await self._is_data_valid():
632
+ try:
633
+ async with aiofiles.open(self.file_path, "r", encoding="utf-8") as f:
634
+ content = await f.read()
635
+ return json.loads(content)
636
+ except Exception as e:
637
+ _LOGGER.warning("Error leyendo archivo de predicción horaria: %s", e)
638
+
639
+ return {}
640
+
641
+ def parse_hourly_forecast(self, dia: dict, forecast_time: datetime) -> dict:
642
+ """Convierte una hora de predicción en un diccionario con los datos necesarios."""
643
+ variables = dia.get("variables", {})
644
+ condition_code = next(
645
+ (item["valor"] for item in variables.get("estatCel", {}).get("valors", []) if
646
+ datetime.fromisoformat(item["data"].rstrip("Z")).replace(tzinfo=timezone.utc) == forecast_time),
647
+ -1,
648
+ )
649
+ condition = get_condition_from_code(int(condition_code))
650
+
651
+ return {
652
+ "datetime": forecast_time.isoformat(),
653
+ "temperature": self._get_variable_value(dia, "temp", forecast_time),
654
+ "precipitation": self._get_variable_value(dia, "precipitacio", forecast_time),
655
+ "condition": condition,
656
+ "wind_speed": self._get_variable_value(dia, "velVent", forecast_time),
657
+ "wind_bearing": self._get_variable_value(dia, "dirVent", forecast_time),
658
+ "humidity": self._get_variable_value(dia, "humitat", forecast_time),
659
+ }
660
+
661
+ def get_all_hourly_forecasts(self) -> list[dict]:
662
+ """Obtiene una lista de predicciones horarias procesadas."""
663
+ if not self.data or "dies" not in self.data:
664
+ return []
665
+
666
+ forecasts = []
667
+ now = datetime.now(timezone.utc)
668
+ for dia in self.data["dies"]:
669
+ for forecast in dia.get("variables", {}).get("estatCel", {}).get("valors", []):
670
+ forecast_time = datetime.fromisoformat(forecast["data"].rstrip("Z")).replace(tzinfo=timezone.utc)
671
+ if forecast_time >= now:
672
+ forecasts.append(self.parse_hourly_forecast(dia, forecast_time))
673
+ return forecasts
674
+
675
+ def _get_variable_value(self, dia, variable_name, target_time):
676
+ """Devuelve el valor de una variable específica para una hora determinada."""
677
+ variable = dia.get("variables", {}).get(variable_name, {})
678
+ if not variable:
679
+ _LOGGER.warning("Variable '%s' no encontrada en los datos.", variable_name)
680
+ return None
681
+
682
+ # Obtener lista de valores, soportando tanto 'valors' como 'valor'
683
+ valores = variable.get("valors") or variable.get("valor")
684
+ if not valores:
685
+ _LOGGER.warning("No se encontraron valores para la variable '%s'.", variable_name)
686
+ return None
687
+
688
+ for valor in valores:
689
+ try:
690
+ data_hora = datetime.fromisoformat(valor["data"].rstrip("Z")).replace(tzinfo=timezone.utc)
691
+ if data_hora == target_time:
692
+ return float(valor["valor"])
693
+ except (KeyError, ValueError) as e:
694
+ _LOGGER.warning("Error procesando '%s' para %s: %s", variable_name, valor, e)
695
+ continue
696
+
697
+ _LOGGER.info("No se encontró un valor válido para '%s' en %s.", variable_name, target_time)
698
+ return None
699
+
700
+ class DailyForecastCoordinator(DataUpdateCoordinator):
701
+ """Coordinator para manejar las predicciones diarias desde archivos locales."""
702
+
703
+ def __init__(
704
+ self,
705
+ hass: HomeAssistant,
706
+ entry_data: dict,
707
+ update_interval: timedelta = DEFAULT_DAILY_FORECAST_UPDATE_INTERVAL,
708
+ ):
709
+ """Inicializa el coordinador para predicciones diarias."""
710
+ self.town_name = entry_data["town_name"]
711
+ self.town_id = entry_data["town_id"]
712
+ self.station_name = entry_data["station_name"]
713
+ self.station_id = entry_data["station_id"]
714
+ self.file_path = os.path.join(
715
+ hass.config.path(),
716
+ "custom_components",
717
+ "meteocat",
718
+ "files",
719
+ f"forecast_{self.town_id.lower()}_daily_data.json",
720
+ )
721
+ super().__init__(
722
+ hass,
723
+ _LOGGER,
724
+ name=f"{DOMAIN} Daily Forecast Coordinator",
725
+ update_interval=update_interval,
726
+ )
727
+
728
+ async def _is_data_valid(self) -> bool:
729
+ """Verifica si hay datos válidos y actuales en el archivo JSON."""
730
+ if not os.path.exists(self.file_path):
731
+ return False
732
+
733
+ try:
734
+ async with aiofiles.open(self.file_path, "r", encoding="utf-8") as f:
735
+ content = await f.read()
736
+ data = json.loads(content)
737
+
738
+ if not data or "dies" not in data or not data["dies"]:
739
+ return False
740
+
741
+ today = datetime.now(timezone.utc).date()
742
+ for dia in data["dies"]:
743
+ forecast_date = datetime.fromisoformat(dia["data"].rstrip("Z")).date()
744
+ if forecast_date >= today:
745
+ return True
746
+
747
+ return False
748
+ except Exception as e:
749
+ _LOGGER.warning("Error validando datos diarios en %s: %s", self.file_path, e)
750
+ return False
751
+
752
+ async def _async_update_data(self) -> dict:
753
+ """Lee y filtra los datos de predicción diaria desde el archivo local."""
754
+ if await self._is_data_valid():
755
+ try:
756
+ async with aiofiles.open(self.file_path, "r", encoding="utf-8") as f:
757
+ content = await f.read()
758
+ data = json.loads(content)
759
+
760
+ # Filtrar días pasados
761
+ today = datetime.now(timezone.utc).date()
762
+ data["dies"] = [
763
+ dia for dia in data["dies"]
764
+ if datetime.fromisoformat(dia["data"].rstrip("Z")).date() >= today
765
+ ]
766
+ return data
767
+ except Exception as e:
768
+ _LOGGER.warning("Error leyendo archivo de predicción diaria: %s", e)
769
+
770
+ return {}
771
+
772
+ def get_forecast_for_today(self) -> dict | None:
773
+ """Obtiene los datos diarios para el día actual."""
774
+ if not self.data or "dies" not in self.data or not self.data["dies"]:
775
+ return None
776
+
777
+ today = datetime.now(timezone.utc).date()
778
+ for dia in self.data["dies"]:
779
+ forecast_date = datetime.fromisoformat(dia["data"].rstrip("Z")).date()
780
+ if forecast_date == today:
781
+ return dia
782
+ return None
783
+
784
+ def parse_forecast(self, dia: dict) -> dict:
785
+ """Convierte un día de predicción en un diccionario con los datos necesarios."""
786
+ variables = dia.get("variables", {})
787
+ condition_code = variables.get("estatCel", {}).get("valor", -1)
788
+ condition = get_condition_from_code(int(condition_code))
789
+
790
+ forecast_data = {
791
+ "date": datetime.fromisoformat(dia["data"].rstrip("Z")).date(),
792
+ "temperature_max": float(variables.get("tmax", {}).get("valor", 0.0)),
793
+ "temperature_min": float(variables.get("tmin", {}).get("valor", 0.0)),
794
+ "precipitation": float(variables.get("precipitacio", {}).get("valor", 0.0)),
795
+ "condition": condition,
796
+ }
797
+ return forecast_data
798
+
799
+ def get_all_daily_forecasts(self) -> list[dict]:
800
+ """Obtiene una lista de predicciones diarias procesadas."""
801
+ if not self.data or "dies" not in self.data:
802
+ return []
803
+
804
+ forecasts = []
805
+ for dia in self.data["dies"]:
806
+ forecasts.append(self.parse_forecast(dia))
807
+ return forecasts
808
+
809
+ class MeteocatConditionCoordinator(DataUpdateCoordinator):
810
+ """Coordinator to read and process Condition data from a file."""
811
+
812
+ DEFAULT_CONDITION = {"condition": "unknown", "hour": None, "icon": None, "date": None}
813
+
814
+ def __init__(
815
+ self,
816
+ hass: HomeAssistant,
817
+ entry_data: dict,
818
+ update_interval: timedelta = DEFAULT_CONDITION_SENSOR_UPDATE_INTERVAL,
819
+ ):
820
+ """
821
+ Initialize the Meteocat Condition Coordinator.
822
+
823
+ Args:
824
+ hass (HomeAssistant): Instance of Home Assistant.
825
+ entry_data (dict): Configuration data from core.config_entries.
826
+ update_interval (timedelta): Update interval for the sensor.
827
+ """
828
+ self.town_id = entry_data["town_id"] # Municipality ID
829
+ self.hass = hass
830
+
831
+ super().__init__(
832
+ hass,
833
+ _LOGGER,
834
+ name=f"{DOMAIN} Condition Coordinator",
835
+ update_interval=update_interval,
836
+ )
837
+
838
+ self._file_path = os.path.join(
839
+ hass.config.path("custom_components/meteocat/files"),
840
+ f"forecast_{self.town_id.lower()}_hourly_data.json",
841
+ )
842
+
843
+ async def _async_update_data(self):
844
+ """Read and process condition data for the current hour from the file asynchronously."""
845
+ try:
846
+ async with aiofiles.open(self._file_path, "r", encoding="utf-8") as file:
847
+ raw_data = await file.read()
848
+ raw_data = json.loads(raw_data) # Parse JSON data
849
+ except FileNotFoundError:
850
+ _LOGGER.error(
851
+ "No se ha encontrado el archivo JSON con datos del estado del cielo en %s.",
852
+ self._file_path,
853
+ )
854
+ return self.DEFAULT_CONDITION
855
+ except json.JSONDecodeError:
856
+ _LOGGER.error(
857
+ "Error al decodificar el archivo JSON del estado del cielo en %s.",
858
+ self._file_path,
859
+ )
860
+ return self.DEFAULT_CONDITION
861
+ except Exception as e:
862
+ _LOGGER.error("Error inesperado al leer los datos del archivo %s: %s", self._file_path, e)
863
+ return self.DEFAULT_CONDITION
864
+
865
+ return self._get_condition_for_current_hour(raw_data) or self.DEFAULT_CONDITION
866
+
867
+ def _get_condition_for_current_hour(self, raw_data):
868
+ """Get condition data for the current hour."""
869
+ # Fecha y hora actual
870
+ current_datetime = datetime.now()
871
+ current_date = current_datetime.strftime("%Y-%m-%d")
872
+ current_hour = current_datetime.hour
873
+
874
+ # Busca los datos para la fecha actual
875
+ for day in raw_data.get("dies", []):
876
+ if day["data"].startswith(current_date):
877
+ for value in day["variables"]["estatCel"]["valors"]:
878
+ data_hour = datetime.fromisoformat(value["data"])
879
+ if data_hour.hour == current_hour:
880
+ codi_estatcel = value["valor"]
881
+ condition = get_condition_from_statcel(
882
+ codi_estatcel,
883
+ current_datetime,
884
+ self.hass,
885
+ is_hourly=True,
886
+ )
887
+ # Añadir hora y fecha a los datos de la condición
888
+ condition.update({
889
+ "hour": current_hour,
890
+ "date": current_date,
891
+ })
892
+ return condition
893
+ break # Sale del bucle una vez encontrada la fecha actual
894
+
895
+ # Si no se encuentran datos, devuelve un diccionario vacío con valores predeterminados
896
+ _LOGGER.warning(
897
+ "No se encontraron datos del Estado del Cielo para hoy (%s) y la hora actual (%s).",
898
+ current_date,
899
+ current_hour,
900
+ )
901
+ return {"condition": "unknown", "hour": current_hour, "icon": None, "date": current_date}
902
+