meteocat 4.0.3 → 4.0.4
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/.github/workflows/publish-zip.yml +4 -4
- package/CHANGELOG.md +7 -0
- package/custom_components/meteocat/__init__.py +1 -1
- package/custom_components/meteocat/const.py +1 -0
- package/custom_components/meteocat/coordinator.py +75 -40
- package/custom_components/meteocat/manifest.json +1 -1
- package/custom_components/meteocat/sensor.py +63 -21
- package/custom_components/meteocat/strings.json +3 -0
- package/custom_components/meteocat/translations/ca.json +3 -0
- package/custom_components/meteocat/translations/en.json +3 -0
- package/custom_components/meteocat/translations/es.json +3 -0
- package/custom_components/meteocat/version.py +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -32,10 +32,10 @@ jobs:
|
|
|
32
32
|
id: ha-version
|
|
33
33
|
run: |
|
|
34
34
|
# ──────────────────────────────────────────────────────────────
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
35
|
+
# Comportamiento normal: usar siempre la última versión
|
|
36
|
+
#latest=$(curl -s https://api.github.com/repos/home-assistant/core/releases/latest | jq -r .tag_name)
|
|
37
|
+
#latest_clean=${latest#v}
|
|
38
|
+
#echo "ha_version=$latest_clean" >> $GITHUB_OUTPUT
|
|
39
39
|
|
|
40
40
|
# Temporal: forzado a 2025.12.5 (enero 2026) por posible incompatibilidad con 2026.1.0
|
|
41
41
|
echo "ha_version=2025.12.5" >> $GITHUB_OUTPUT
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [4.0.4](https://github.com/figorr/meteocat/compare/v4.0.3...v4.0.4) (2026-01-25)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* include last update check to avoid continuous API calls when API returns outdated data ([32760e8](https://github.com/figorr/meteocat/commit/32760e854e3dd2a6af07c48373995df4f78106cb))
|
|
7
|
+
|
|
1
8
|
## [4.0.3](https://github.com/figorr/meteocat/compare/v4.0.2...v4.0.3) (2026-01-10)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -65,6 +65,7 @@ DEFAULT_UVI_LOW_VALIDITY_HOURS = 5 # Hora a partir de la cual la API tiene la i
|
|
|
65
65
|
DEFAULT_UVI_LOW_VALIDITY_MINUTES = 0 # Minutos a partir de los cuales la API tiene la información actualizada de datos UVI disponible para descarga con límite bajo de cuota
|
|
66
66
|
DEFAULT_UVI_HIGH_VALIDITY_HOURS = 9 # Hora a partir de la cual la API tiene la información actualizada de datos UVI disponible para descarga con límite alto de cuota
|
|
67
67
|
DEFAULT_UVI_HIGH_VALIDITY_MINUTES = 0 # Minutos a partir de los cuales la API tiene la información actualizada de datos UVI disponible para descarga con límite alto de cuota
|
|
68
|
+
DEFAULT_UVI_MIN_HOURS_SINCE_LAST_UPDATE = 15 # Horas mínimas desde la última actualización de datos UVI para proceder a una nueva llamada a la API
|
|
68
69
|
DEFAULT_ALERT_VALIDITY_TIME = 120 # Minutos a partir de los cuales las alertas están obsoletas y se se debe proceder a una nueva llamada a la API
|
|
69
70
|
DEFAULT_QUOTES_VALIDITY_TIME = 240 # Minutos a partir de los cuales los datos de cuotas están obsoletos y se se debe proceder a una nueva llamada a la API
|
|
70
71
|
DEFAULT_LIGHTNING_VALIDITY_TIME = 240 # Minutos a partir de los cuales los datos de rayos están obsoletos y se se debe proceder a una nueva llamada a la API
|
|
@@ -56,6 +56,7 @@ from .const import (
|
|
|
56
56
|
DEFAULT_UVI_LOW_VALIDITY_MINUTES,
|
|
57
57
|
DEFAULT_UVI_HIGH_VALIDITY_HOURS,
|
|
58
58
|
DEFAULT_UVI_HIGH_VALIDITY_MINUTES,
|
|
59
|
+
DEFAULT_UVI_MIN_HOURS_SINCE_LAST_UPDATE,
|
|
59
60
|
DEFAULT_ALERT_VALIDITY_TIME,
|
|
60
61
|
DEFAULT_QUOTES_VALIDITY_TIME,
|
|
61
62
|
ALERT_VALIDITY_MULTIPLIER_100,
|
|
@@ -406,26 +407,24 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
406
407
|
)
|
|
407
408
|
|
|
408
409
|
async def is_uvi_data_valid(self) -> Optional[dict]:
|
|
409
|
-
"""Valida datos UVI
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
"""Valida si los datos UVI en caché son aún válidos, considerando:
|
|
411
|
+
1. Antigüedad de la fecha del primer día (lógica existente según cuota)
|
|
412
|
+
2. Hora mínima del día (según cuota)
|
|
413
|
+
3. Han pasado más de DEFAULT_UVI_MIN_HOURS_SINCE_LAST_UPDATE horas desde la última actualización exitosa
|
|
413
414
|
"""
|
|
414
415
|
if not self.uvi_file.exists():
|
|
415
416
|
_LOGGER.debug("Archivo UVI no existe: %s", self.uvi_file)
|
|
416
417
|
return None
|
|
417
418
|
|
|
418
419
|
try:
|
|
419
|
-
|
|
420
|
-
content = await f.read()
|
|
421
|
-
data = json.loads(content)
|
|
420
|
+
data = await load_json_from_file(self.uvi_file)
|
|
422
421
|
|
|
423
422
|
# Validar estructura básica
|
|
424
423
|
if not isinstance(data, dict) or "uvi" not in data or not isinstance(data["uvi"], list) or not data["uvi"]:
|
|
425
424
|
_LOGGER.warning("Estructura UVI inválida en %s", self.uvi_file)
|
|
426
425
|
return None
|
|
427
426
|
|
|
428
|
-
#
|
|
427
|
+
# ── Condición 1: Antigüedad de la fecha del primer día ──
|
|
429
428
|
try:
|
|
430
429
|
first_date_str = data["uvi"][0].get("date")
|
|
431
430
|
first_date = datetime.strptime(first_date_str, "%Y-%m-%d").date()
|
|
@@ -433,39 +432,64 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
433
432
|
_LOGGER.warning("Fecha UVI inválida en %s: %s", self.uvi_file, exc)
|
|
434
433
|
return None
|
|
435
434
|
|
|
436
|
-
# Fecha y hora actual en zona local (Europe/Madrid)
|
|
437
435
|
now_local = datetime.now(TIMEZONE)
|
|
438
436
|
today = now_local.date()
|
|
439
437
|
current_time_local = now_local.time()
|
|
440
|
-
# Horas para actualización según límite de cuota
|
|
441
|
-
min_update_time_high = time(DEFAULT_UVI_HIGH_VALIDITY_HOURS, DEFAULT_UVI_HIGH_VALIDITY_MINUTES) # Hora para cuota alta
|
|
442
|
-
min_update_time_low = time(DEFAULT_UVI_LOW_VALIDITY_HOURS, DEFAULT_UVI_LOW_VALIDITY_MINUTES) # Hora para cuota baja
|
|
443
|
-
# Diferencia en días
|
|
444
438
|
days_diff = (today - first_date).days
|
|
445
439
|
|
|
446
|
-
#
|
|
440
|
+
# Determinar umbrales según cuota
|
|
447
441
|
if self.limit_prediccio >= PREDICCIO_HIGH_QUOTA_LIMIT:
|
|
448
|
-
|
|
442
|
+
min_days = DEFAULT_VALIDITY_DAYS # ej: 1 día
|
|
443
|
+
min_update_time = time(DEFAULT_UVI_HIGH_VALIDITY_HOURS, DEFAULT_UVI_HIGH_VALIDITY_MINUTES)
|
|
444
|
+
quota_level = "ALTA"
|
|
449
445
|
else:
|
|
450
|
-
|
|
446
|
+
min_days = DEFAULT_VALIDITY_DAYS + 1 # ej: 2 días
|
|
447
|
+
min_update_time = time(DEFAULT_UVI_LOW_VALIDITY_HOURS, DEFAULT_UVI_LOW_VALIDITY_MINUTES)
|
|
448
|
+
quota_level = "BAJA"
|
|
449
|
+
|
|
450
|
+
cond1 = days_diff >= min_days
|
|
451
|
+
cond2 = current_time_local >= min_update_time
|
|
452
|
+
|
|
453
|
+
# ── Condición 3: Más de X horas desde última actualización ──
|
|
454
|
+
cond3 = True # por defecto (si no hay dataUpdate → permite actualizar)
|
|
455
|
+
last_update_str = None
|
|
456
|
+
if "actualitzat" in data and "dataUpdate" in data["actualitzat"]:
|
|
457
|
+
try:
|
|
458
|
+
last_update = datetime.fromisoformat(data["actualitzat"]["dataUpdate"])
|
|
459
|
+
time_since = now_local - last_update
|
|
460
|
+
cond3 = time_since > timedelta(hours=DEFAULT_UVI_MIN_HOURS_SINCE_LAST_UPDATE)
|
|
461
|
+
last_update_str = last_update.strftime("%Y-%m-%d %H:%M:%S %z")
|
|
462
|
+
_LOGGER.debug(
|
|
463
|
+
"Tiempo desde última actualización: %s (%s %dh)",
|
|
464
|
+
time_since,
|
|
465
|
+
"supera" if cond3 else "NO supera",
|
|
466
|
+
DEFAULT_UVI_MIN_HOURS_SINCE_LAST_UPDATE
|
|
467
|
+
)
|
|
468
|
+
except ValueError:
|
|
469
|
+
_LOGGER.warning("Formato inválido en dataUpdate: %s", data["actualitzat"]["dataUpdate"])
|
|
470
|
+
cond3 = True # si corrupto → permite actualizar
|
|
471
|
+
|
|
472
|
+
should_update = cond1 and cond2 and cond3
|
|
451
473
|
|
|
452
474
|
_LOGGER.debug(
|
|
453
|
-
"[UVI %s] Validación
|
|
454
|
-
"
|
|
475
|
+
"[UVI %s] Validación → cond1(días >=%d)=%s | cond2(hora >=%s)=%s | "
|
|
476
|
+
"cond3(>%dh desde %s)=%s | cuota=%d (%s) | actualizar=%s",
|
|
455
477
|
self.town_id,
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
478
|
+
min_days,
|
|
479
|
+
cond1,
|
|
480
|
+
min_update_time.strftime("%H:%M"),
|
|
481
|
+
cond2,
|
|
482
|
+
DEFAULT_UVI_MIN_HOURS_SINCE_LAST_UPDATE,
|
|
483
|
+
last_update_str or "nunca",
|
|
484
|
+
cond3,
|
|
459
485
|
self.limit_prediccio,
|
|
460
|
-
|
|
461
|
-
current_time_local.strftime("%H:%M"),
|
|
462
|
-
min_update_time_high.strftime("%H:%M") if self.limit_prediccio >= 550 else min_update_time_low.strftime("%H:%M"),
|
|
486
|
+
quota_level,
|
|
463
487
|
should_update,
|
|
464
488
|
)
|
|
465
489
|
|
|
466
490
|
if should_update:
|
|
467
491
|
_LOGGER.info(
|
|
468
|
-
"Datos UVI obsoletos → llamando API (town=%s, cuota=%d)",
|
|
492
|
+
"Datos UVI obsoletos o antiguos → llamando API (town=%s, cuota=%d)",
|
|
469
493
|
self.town_id, self.limit_prediccio
|
|
470
494
|
)
|
|
471
495
|
return None
|
|
@@ -480,14 +504,15 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
480
504
|
_LOGGER.error("Error validando UVI: %s", e)
|
|
481
505
|
return None
|
|
482
506
|
|
|
483
|
-
async def _async_update_data(self) ->
|
|
484
|
-
"""Actualiza los datos de UVI desde la API
|
|
507
|
+
async def _async_update_data(self) -> Optional[dict]:
|
|
508
|
+
"""Actualiza los datos de UVI desde la API o caché."""
|
|
485
509
|
try:
|
|
486
510
|
valid_data = await self.is_uvi_data_valid()
|
|
487
511
|
if valid_data:
|
|
488
512
|
_LOGGER.debug("Los datos del índice UV están actualizados. No se realiza llamada a la API.")
|
|
489
|
-
return valid_data
|
|
513
|
+
return valid_data
|
|
490
514
|
|
|
515
|
+
# ── Llamada a la API ──
|
|
491
516
|
data = await asyncio.wait_for(
|
|
492
517
|
self.meteocat_uvi_data.get_uvi_index(self.town_id),
|
|
493
518
|
timeout=30,
|
|
@@ -500,8 +525,19 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
500
525
|
_LOGGER.error("Formato inválido: se esperaba un dict con 'uvi' -> %s", data)
|
|
501
526
|
raise ValueError("Formato de datos inválido")
|
|
502
527
|
|
|
503
|
-
|
|
504
|
-
|
|
528
|
+
# Añadir timestamp de actualización exitosa
|
|
529
|
+
now_iso = datetime.now(TIMEZONE).isoformat()
|
|
530
|
+
enhanced_data = {
|
|
531
|
+
"actualitzat": {
|
|
532
|
+
"dataUpdate": now_iso
|
|
533
|
+
},
|
|
534
|
+
**data # conserva ine, nom, comarca, capital, uvi, ...
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
await save_json_to_file(enhanced_data, self.uvi_file)
|
|
538
|
+
_LOGGER.debug("Datos UVI guardados con dataUpdate: %s", now_iso)
|
|
539
|
+
|
|
540
|
+
return enhanced_data # ← en lugar de return data["uvi"]
|
|
505
541
|
|
|
506
542
|
except asyncio.TimeoutError as err:
|
|
507
543
|
_LOGGER.warning("Tiempo de espera agotado al obtener datos UVI.")
|
|
@@ -517,17 +553,16 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
517
553
|
raise
|
|
518
554
|
except Exception as err:
|
|
519
555
|
_LOGGER.exception("Error inesperado al obtener datos del índice UV para %s: %s", self.town_id, err)
|
|
520
|
-
|
|
521
|
-
#
|
|
556
|
+
|
|
557
|
+
# ── FALLBACK SEGURO ──
|
|
522
558
|
cached_data = await load_json_from_file(self.uvi_file)
|
|
523
559
|
if cached_data and "uvi" in cached_data and cached_data["uvi"]:
|
|
524
560
|
raw_date = cached_data["uvi"][0].get("date", "unknown")
|
|
525
|
-
# Formatear fecha para log
|
|
526
561
|
try:
|
|
527
562
|
first_date = datetime.strptime(raw_date, "%Y-%m-%d").strftime("%d/%m/%Y")
|
|
528
563
|
except (ValueError, TypeError):
|
|
529
564
|
first_date = raw_date
|
|
530
|
-
|
|
565
|
+
|
|
531
566
|
_LOGGER.warning(
|
|
532
567
|
"API UVI falló → usando caché local:\n"
|
|
533
568
|
" • Archivo: %s\n"
|
|
@@ -535,13 +570,13 @@ class MeteocatUviCoordinator(DataUpdateCoordinator):
|
|
|
535
570
|
self.uvi_file.name,
|
|
536
571
|
first_date
|
|
537
572
|
)
|
|
538
|
-
|
|
539
|
-
self.async_set_updated_data(cached_data
|
|
540
|
-
return cached_data
|
|
541
|
-
|
|
573
|
+
|
|
574
|
+
self.async_set_updated_data(cached_data)
|
|
575
|
+
return cached_data
|
|
576
|
+
|
|
542
577
|
_LOGGER.error("No hay datos UVI ni en caché para %s", self.town_id)
|
|
543
|
-
self.async_set_updated_data(
|
|
544
|
-
return
|
|
578
|
+
self.async_set_updated_data(None)
|
|
579
|
+
return None
|
|
545
580
|
|
|
546
581
|
class MeteocatUviFileCoordinator(BaseFileCoordinator):
|
|
547
582
|
"""Coordinator to read and process UV data from a file."""
|
|
@@ -1296,37 +1296,79 @@ class MeteocatUviStatusSensor(CoordinatorEntity[MeteocatUviCoordinator], SensorE
|
|
|
1296
1296
|
self._attr_unique_id = f"sensor.{DOMAIN}_{self._town_id}_uvi_status"
|
|
1297
1297
|
self._attr_entity_category = getattr(description, "entity_category", None)
|
|
1298
1298
|
|
|
1299
|
+
def _get_uvi_data_dict(self):
|
|
1300
|
+
"""Devuelve el diccionario completo de datos UVI (incluyendo 'actualitzat') o None."""
|
|
1301
|
+
if self.coordinator.data and isinstance(self.coordinator.data, dict):
|
|
1302
|
+
return self.coordinator.data
|
|
1303
|
+
return None
|
|
1304
|
+
|
|
1299
1305
|
def _get_first_date(self):
|
|
1300
|
-
|
|
1301
|
-
|
|
1306
|
+
data_dict = self._get_uvi_data_dict()
|
|
1307
|
+
if data_dict and "uvi" in data_dict and data_dict["uvi"]:
|
|
1308
|
+
try:
|
|
1309
|
+
return datetime.strptime(data_dict["uvi"][0].get("date"), "%Y-%m-%d").date()
|
|
1310
|
+
except (ValueError, TypeError, KeyError):
|
|
1311
|
+
return None
|
|
1312
|
+
return None
|
|
1313
|
+
|
|
1314
|
+
def _get_last_api_update(self):
|
|
1315
|
+
"""Devuelve el datetime de la última llamada exitosa a la API o None."""
|
|
1316
|
+
data_dict = self._get_uvi_data_dict()
|
|
1317
|
+
if data_dict and "actualitzat" in data_dict and "dataUpdate" in data_dict["actualitzat"]:
|
|
1318
|
+
try:
|
|
1319
|
+
return datetime.fromisoformat(data_dict["actualitzat"]["dataUpdate"])
|
|
1320
|
+
except ValueError:
|
|
1321
|
+
_LOGGER.warning("Formato inválido en dataUpdate del sensor UVI status")
|
|
1322
|
+
return None
|
|
1302
1323
|
return None
|
|
1303
1324
|
|
|
1304
1325
|
@property
|
|
1305
|
-
def native_value(self):
|
|
1326
|
+
def native_value(self) -> str:
|
|
1327
|
+
data_dict = self._get_uvi_data_dict()
|
|
1328
|
+
if not data_dict:
|
|
1329
|
+
_LOGGER.debug("UVI Status: no hay data_dict disponible aún")
|
|
1330
|
+
return "unknown"
|
|
1331
|
+
|
|
1306
1332
|
first_date = self._get_first_date()
|
|
1307
|
-
if first_date:
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1333
|
+
if not first_date:
|
|
1334
|
+
_LOGGER.debug("UVI Status: no se pudo obtener first_date")
|
|
1335
|
+
return "unknown"
|
|
1336
|
+
|
|
1337
|
+
now_local = datetime.now(TIMEZONE)
|
|
1338
|
+
today = now_local.date()
|
|
1339
|
+
current_time = now_local.time()
|
|
1340
|
+
days_difference = (today - first_date).days
|
|
1341
|
+
|
|
1342
|
+
_LOGGER.debug(
|
|
1343
|
+
"UVI Status → días diff: %d | hora actual: %s | umbral días: %d | umbral hora: %02d:%02d",
|
|
1344
|
+
days_difference,
|
|
1345
|
+
current_time.strftime("%H:%M"),
|
|
1346
|
+
DEFAULT_VALIDITY_DAYS,
|
|
1347
|
+
DEFAULT_VALIDITY_HOURS,
|
|
1348
|
+
DEFAULT_VALIDITY_MINUTES,
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
if days_difference > DEFAULT_VALIDITY_DAYS and current_time >= time(DEFAULT_VALIDITY_HOURS, DEFAULT_VALIDITY_MINUTES):
|
|
1352
|
+
return "obsolete"
|
|
1353
|
+
return "updated"
|
|
1323
1354
|
|
|
1324
1355
|
@property
|
|
1325
|
-
def extra_state_attributes(self):
|
|
1326
|
-
attributes =
|
|
1356
|
+
def extra_state_attributes(self) -> dict:
|
|
1357
|
+
attributes = {}
|
|
1358
|
+
data_dict = self._get_uvi_data_dict()
|
|
1359
|
+
|
|
1360
|
+
if not data_dict:
|
|
1361
|
+
attributes["debug_info"] = "Aún no hay datos en el coordinador"
|
|
1362
|
+
return attributes
|
|
1363
|
+
# Primera fecha de los datos UVI
|
|
1327
1364
|
first_date = self._get_first_date()
|
|
1328
1365
|
if first_date:
|
|
1329
1366
|
attributes["update_date"] = first_date.isoformat()
|
|
1367
|
+
# Última actualizaciónde la API
|
|
1368
|
+
last_update = self._get_last_api_update()
|
|
1369
|
+
if last_update:
|
|
1370
|
+
attributes["data_updatetime"] = last_update.isoformat()
|
|
1371
|
+
|
|
1330
1372
|
return attributes
|
|
1331
1373
|
|
|
1332
1374
|
@property
|
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "4.0.
|
|
1
|
+
__version__ = "4.0.4"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meteocat",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.4",
|
|
4
4
|
"description": "[](https://opensource.org/licenses/Apache-2.0)\r [](https://pypi.org/project/meteocat)\r [](https://gitlab.com/figorr/meteocat/commits/master)",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"directories": {
|