meteocat 0.1.38 → 0.1.39
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 +20 -0
- package/conftest.py +11 -0
- package/custom_components/meteocat/__init__.py +31 -5
- package/custom_components/meteocat/condition.py +15 -5
- package/custom_components/meteocat/const.py +9 -4
- package/custom_components/meteocat/coordinator.py +566 -127
- package/custom_components/meteocat/helpers.py +39 -40
- package/custom_components/meteocat/manifest.json +1 -1
- package/custom_components/meteocat/sensor.py +136 -19
- package/custom_components/meteocat/strings.json +20 -0
- package/custom_components/meteocat/translations/ca.json +20 -0
- package/custom_components/meteocat/translations/en.json +20 -0
- package/custom_components/meteocat/translations/es.json +20 -0
- package/custom_components/meteocat/version.py +1 -1
- package/custom_components/meteocat/weather.py +171 -0
- package/filetree.txt +2 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/custom_components/meteocat/entity.py +0 -98
|
@@ -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
|
-
|
|
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 .
|
|
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(
|
|
35
|
-
|
|
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
|
|
52
|
-
"""
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
except
|
|
58
|
-
_LOGGER.
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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,435 @@ class MeteocatUviFileCoordinator(DataUpdateCoordinator):
|
|
|
361
441
|
)
|
|
362
442
|
return {"hour": 0, "uvi": 0, "uvi_clouds": 0}
|
|
363
443
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
#
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 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 or not data["dies"]:
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
# Validar que los datos sean para la fecha actual
|
|
618
|
+
first_date = datetime.fromisoformat(data["dies"][0]["data"].rstrip("Z")).date()
|
|
619
|
+
return first_date == datetime.now(timezone.utc).date()
|
|
620
|
+
except Exception as e:
|
|
621
|
+
_LOGGER.warning("Error validando datos horarios en %s: %s", self.file_path, e)
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
async def _async_update_data(self) -> dict:
|
|
625
|
+
"""Lee los datos de predicción horaria desde el archivo local."""
|
|
626
|
+
if await self._is_data_valid():
|
|
627
|
+
try:
|
|
628
|
+
async with aiofiles.open(self.file_path, "r", encoding="utf-8") as f:
|
|
629
|
+
content = await f.read()
|
|
630
|
+
return json.loads(content)
|
|
631
|
+
except Exception as e:
|
|
632
|
+
_LOGGER.warning("Error leyendo archivo de predicción horaria: %s", e)
|
|
633
|
+
|
|
634
|
+
return {}
|
|
635
|
+
|
|
636
|
+
async def async_forecast_hourly(self) -> list[Forecast] | None:
|
|
637
|
+
"""Devuelve una lista de objetos Forecast para las próximas horas."""
|
|
638
|
+
if not self.data or "dies" not in self.data:
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
forecasts = []
|
|
642
|
+
now = datetime.now(timezone.utc)
|
|
643
|
+
|
|
644
|
+
for dia in self.data["dies"]:
|
|
645
|
+
for forecast in dia.get("variables", {}).get("estatCel", {}).get("valors", []):
|
|
646
|
+
forecast_time = datetime.fromisoformat(forecast["data"].rstrip("Z")).replace(tzinfo=timezone.utc)
|
|
647
|
+
if forecast_time >= now:
|
|
648
|
+
# Usar la función para obtener la condición meteorológica
|
|
649
|
+
condition = get_condition_from_code(int(forecast["valor"]))
|
|
650
|
+
|
|
651
|
+
forecast_data = {
|
|
652
|
+
"datetime": forecast_time,
|
|
653
|
+
"temperature": self._get_variable_value(dia, "temp", forecast_time),
|
|
654
|
+
"precipitation": self._get_variable_value(dia, "precipitacio", forecast_time),
|
|
655
|
+
"condition": condition, # Se usa la condición traducida
|
|
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
|
+
forecasts.append(Forecast(**forecast_data))
|
|
661
|
+
|
|
662
|
+
return forecasts if forecasts else None
|
|
663
|
+
|
|
664
|
+
def _get_variable_value(self, dia, variable_name, target_time):
|
|
665
|
+
"""Devuelve el valor de una variable específica para una hora determinada."""
|
|
666
|
+
variable = dia.get("variables", {}).get(variable_name, {})
|
|
667
|
+
for valor in variable.get("valors", []):
|
|
668
|
+
data_hora = datetime.fromisoformat(valor["data"].rstrip("Z"))
|
|
669
|
+
if data_hora == target_time:
|
|
670
|
+
return valor["valor"]
|
|
671
|
+
return None
|
|
672
|
+
|
|
673
|
+
class DailyForecastCoordinator(DataUpdateCoordinator):
|
|
674
|
+
"""Coordinator para manejar las predicciones diarias desde archivos locales."""
|
|
675
|
+
|
|
676
|
+
def __init__(
|
|
677
|
+
self,
|
|
678
|
+
hass: HomeAssistant,
|
|
679
|
+
entry_data: dict,
|
|
680
|
+
update_interval: timedelta = DEFAULT_DAILY_FORECAST_UPDATE_INTERVAL,
|
|
681
|
+
):
|
|
682
|
+
"""Inicializa el coordinador para predicciones diarias."""
|
|
683
|
+
self.town_name = entry_data["town_name"]
|
|
684
|
+
self.town_id = entry_data["town_id"]
|
|
685
|
+
self.station_name = entry_data["station_name"]
|
|
686
|
+
self.station_id = entry_data["station_id"]
|
|
687
|
+
self.file_path = os.path.join(
|
|
688
|
+
hass.config.path(),
|
|
689
|
+
"custom_components",
|
|
690
|
+
"meteocat",
|
|
691
|
+
"files",
|
|
692
|
+
f"forecast_{self.town_id.lower()}_daily_data.json",
|
|
693
|
+
)
|
|
694
|
+
super().__init__(
|
|
695
|
+
hass,
|
|
696
|
+
_LOGGER,
|
|
697
|
+
name=f"{DOMAIN} Daily Forecast Coordinator",
|
|
698
|
+
update_interval=update_interval,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
async def _is_data_valid(self) -> bool:
|
|
702
|
+
"""Verifica si hay datos válidos y actuales en el archivo JSON."""
|
|
703
|
+
if not os.path.exists(self.file_path):
|
|
704
|
+
return False
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
async with aiofiles.open(self.file_path, "r", encoding="utf-8") as f:
|
|
708
|
+
content = await f.read()
|
|
709
|
+
data = json.loads(content)
|
|
710
|
+
|
|
711
|
+
if not data or "dies" not in data or not data["dies"]:
|
|
712
|
+
return False
|
|
713
|
+
|
|
714
|
+
today = datetime.now(timezone.utc).date()
|
|
715
|
+
for dia in data["dies"]:
|
|
716
|
+
forecast_date = datetime.fromisoformat(dia["data"].rstrip("Z")).date()
|
|
717
|
+
if forecast_date >= today:
|
|
718
|
+
return True
|
|
719
|
+
|
|
720
|
+
return False
|
|
721
|
+
except Exception as e:
|
|
722
|
+
_LOGGER.warning("Error validando datos diarios en %s: %s", self.file_path, e)
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
async def _async_update_data(self) -> dict:
|
|
726
|
+
"""Lee y filtra los datos de predicción diaria desde el archivo local."""
|
|
727
|
+
if await self._is_data_valid():
|
|
728
|
+
try:
|
|
729
|
+
async with aiofiles.open(self.file_path, "r", encoding="utf-8") as f:
|
|
730
|
+
content = await f.read()
|
|
731
|
+
data = json.loads(content)
|
|
732
|
+
|
|
733
|
+
# Filtrar días pasados
|
|
734
|
+
today = datetime.now(timezone.utc).date()
|
|
735
|
+
data["dies"] = [
|
|
736
|
+
dia for dia in data["dies"]
|
|
737
|
+
if datetime.fromisoformat(dia["data"].rstrip("Z")).date() >= today
|
|
738
|
+
]
|
|
739
|
+
return data
|
|
740
|
+
except Exception as e:
|
|
741
|
+
_LOGGER.warning("Error leyendo archivo de predicción diaria: %s", e)
|
|
742
|
+
|
|
743
|
+
return {}
|
|
744
|
+
|
|
745
|
+
def get_forecast_for_today(self) -> dict | None:
|
|
746
|
+
"""Obtiene los datos diarios para el día actual."""
|
|
747
|
+
if not self.data or "dies" not in self.data or not self.data["dies"]:
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
today = datetime.now(timezone.utc).date()
|
|
751
|
+
for dia in self.data["dies"]:
|
|
752
|
+
forecast_date = datetime.fromisoformat(dia["data"].rstrip("Z")).date()
|
|
753
|
+
if forecast_date == today:
|
|
754
|
+
return dia
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
def parse_forecast(self, dia: dict) -> dict:
|
|
758
|
+
"""Convierte un día de predicción en un diccionario con los datos necesarios."""
|
|
759
|
+
variables = dia.get("variables", {})
|
|
760
|
+
condition_code = variables.get("estatCel", {}).get("valor", -1)
|
|
761
|
+
condition = get_condition_from_code(int(condition_code))
|
|
762
|
+
|
|
763
|
+
forecast_data = {
|
|
764
|
+
"date": datetime.fromisoformat(dia["data"].rstrip("Z")).date(),
|
|
765
|
+
"temperature_max": float(variables.get("tmax", {}).get("valor", 0.0)),
|
|
766
|
+
"temperature_min": float(variables.get("tmin", {}).get("valor", 0.0)),
|
|
767
|
+
"precipitation": float(variables.get("precipitacio", {}).get("valor", 0.0)),
|
|
768
|
+
"condition": condition,
|
|
769
|
+
}
|
|
770
|
+
return forecast_data
|
|
771
|
+
|
|
772
|
+
def get_all_daily_forecasts(self) -> list[dict]:
|
|
773
|
+
"""Obtiene una lista de predicciones diarias procesadas."""
|
|
774
|
+
if not self.data or "dies" not in self.data:
|
|
775
|
+
return []
|
|
776
|
+
|
|
777
|
+
forecasts = []
|
|
778
|
+
for dia in self.data["dies"]:
|
|
779
|
+
forecasts.append(self.parse_forecast(dia))
|
|
780
|
+
return forecasts
|
|
781
|
+
|
|
782
|
+
class MeteocatConditionCoordinator(DataUpdateCoordinator):
|
|
783
|
+
"""Coordinator to read and process Condition data from a file."""
|
|
784
|
+
|
|
785
|
+
DEFAULT_CONDITION = {"condition": "unknown", "hour": None, "icon": None, "date": None}
|
|
786
|
+
|
|
787
|
+
def __init__(
|
|
788
|
+
self,
|
|
789
|
+
hass: HomeAssistant,
|
|
790
|
+
entry_data: dict,
|
|
791
|
+
update_interval: timedelta = DEFAULT_CONDITION_SENSOR_UPDATE_INTERVAL,
|
|
792
|
+
):
|
|
793
|
+
"""
|
|
794
|
+
Initialize the Meteocat Condition Coordinator.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
hass (HomeAssistant): Instance of Home Assistant.
|
|
798
|
+
entry_data (dict): Configuration data from core.config_entries.
|
|
799
|
+
update_interval (timedelta): Update interval for the sensor.
|
|
800
|
+
"""
|
|
801
|
+
self.town_id = entry_data["town_id"] # Municipality ID
|
|
802
|
+
self.hass = hass
|
|
803
|
+
|
|
804
|
+
super().__init__(
|
|
805
|
+
hass,
|
|
806
|
+
_LOGGER,
|
|
807
|
+
name=f"{DOMAIN} Condition Coordinator",
|
|
808
|
+
update_interval=update_interval,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
self._file_path = os.path.join(
|
|
812
|
+
hass.config.path("custom_components/meteocat/files"),
|
|
813
|
+
f"forecast_{self.town_id.lower()}_hourly_data.json",
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
async def _async_update_data(self):
|
|
817
|
+
"""Read and process condition data for the current hour from the file asynchronously."""
|
|
818
|
+
try:
|
|
819
|
+
async with aiofiles.open(self._file_path, "r", encoding="utf-8") as file:
|
|
820
|
+
raw_data = await file.read()
|
|
821
|
+
raw_data = json.loads(raw_data) # Parse JSON data
|
|
822
|
+
except FileNotFoundError:
|
|
823
|
+
_LOGGER.error(
|
|
824
|
+
"No se ha encontrado el archivo JSON con datos del estado del cielo en %s.",
|
|
825
|
+
self._file_path,
|
|
826
|
+
)
|
|
827
|
+
return self.DEFAULT_CONDITION
|
|
828
|
+
except json.JSONDecodeError:
|
|
829
|
+
_LOGGER.error(
|
|
830
|
+
"Error al decodificar el archivo JSON del estado del cielo en %s.",
|
|
831
|
+
self._file_path,
|
|
832
|
+
)
|
|
833
|
+
return self.DEFAULT_CONDITION
|
|
834
|
+
except Exception as e:
|
|
835
|
+
_LOGGER.error("Error inesperado al leer los datos del archivo %s: %s", self._file_path, e)
|
|
836
|
+
return self.DEFAULT_CONDITION
|
|
837
|
+
|
|
838
|
+
return self._get_condition_for_current_hour(raw_data) or self.DEFAULT_CONDITION
|
|
839
|
+
|
|
840
|
+
def _get_condition_for_current_hour(self, raw_data):
|
|
841
|
+
"""Get condition data for the current hour."""
|
|
842
|
+
# Fecha y hora actual
|
|
843
|
+
current_datetime = datetime.now()
|
|
844
|
+
current_date = current_datetime.strftime("%Y-%m-%d")
|
|
845
|
+
current_hour = current_datetime.hour
|
|
846
|
+
|
|
847
|
+
# Busca los datos para la fecha actual
|
|
848
|
+
for day in raw_data.get("dies", []):
|
|
849
|
+
if day["data"].startswith(current_date):
|
|
850
|
+
for value in day["variables"]["estatCel"]["valors"]:
|
|
851
|
+
data_hour = datetime.fromisoformat(value["data"])
|
|
852
|
+
if data_hour.hour == current_hour:
|
|
853
|
+
codi_estatcel = value["valor"]
|
|
854
|
+
condition = get_condition_from_statcel(
|
|
855
|
+
codi_estatcel,
|
|
856
|
+
current_datetime,
|
|
857
|
+
self.hass,
|
|
858
|
+
is_hourly=True,
|
|
859
|
+
)
|
|
860
|
+
# Añadir hora y fecha a los datos de la condición
|
|
861
|
+
condition.update({
|
|
862
|
+
"hour": current_hour,
|
|
863
|
+
"date": current_date,
|
|
864
|
+
})
|
|
865
|
+
return condition
|
|
866
|
+
break # Sale del bucle una vez encontrada la fecha actual
|
|
867
|
+
|
|
868
|
+
# Si no se encuentran datos, devuelve un diccionario vacío con valores predeterminados
|
|
869
|
+
_LOGGER.warning(
|
|
870
|
+
"No se encontraron datos del Estado del Cielo para hoy (%s) y la hora actual (%s).",
|
|
871
|
+
current_date,
|
|
872
|
+
current_hour,
|
|
873
|
+
)
|
|
874
|
+
return {"condition": "unknown", "hour": current_hour, "icon": None, "date": current_date}
|
|
875
|
+
|