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.
@@ -32,10 +32,10 @@ jobs:
32
32
  id: ha-version
33
33
  run: |
34
34
  # ──────────────────────────────────────────────────────────────
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
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
 
@@ -24,7 +24,7 @@ from .const import DOMAIN, PLATFORMS
24
24
  _LOGGER = logging.getLogger(__name__)
25
25
 
26
26
  # Versión
27
- __version__ = "4.0.3"
27
+ __version__ = "4.0.4"
28
28
 
29
29
  # Definir el esquema de configuración CONFIG_SCHEMA
30
30
  CONFIG_SCHEMA = vol.Schema(
@@ -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: misma lógica que predicción, basada en limit_prediccio.
410
-
411
- - Si `limit_prediccio >= 550` → actualiza **el día siguiente** después de las DEFAULT_VALIDITY_HOURS:DEFAULT_VALIDITY_MINUTES.
412
- - Si `limit_prediccio < 550` → actualiza **dos días después** después de las DEFAULT_VALIDITY_HOURS:DEFAULT_VALIDITY_MINUTES.
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
- async with aiofiles.open(self.uvi_file, "r", encoding="utf-8") as f:
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
- # Fecha del primer día
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
- # === LÓGICA DINÁMICA SEGÚN CUOTA ===
440
+ # Determinar umbrales según cuota
447
441
  if self.limit_prediccio >= PREDICCIO_HIGH_QUOTA_LIMIT:
448
- should_update = days_diff >= DEFAULT_VALIDITY_DAYS and current_time_local >= min_update_time_high
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
- should_update = days_diff > DEFAULT_VALIDITY_DAYS and current_time_local >= min_update_time_low
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: primer_día=%s, hoy=%s días=%d, "
454
- "cuota=%d (%s), hora=%s %s actualizar=%s",
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
- first_date,
457
- today,
458
- days_diff,
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
- "ALTA" if self.limit_prediccio >= 550 else "BAJA",
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) -> List[Dict]:
484
- """Actualiza los datos de UVI desde la API de Meteocat o caché."""
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["uvi"]
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
- await save_json_to_file(data, self.uvi_file)
504
- return data["uvi"]
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
- # === FALLBACK SEGURO ===
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["uvi"])
540
- return cached_data["uvi"]
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."""
@@ -21,5 +21,5 @@
21
21
  "packaging>=20.3",
22
22
  "wrapt>=1.14.0"
23
23
  ],
24
- "version": "4.0.3"
24
+ "version": "4.0.4"
25
25
  }
@@ -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
- if self.coordinator.data:
1301
- return datetime.strptime(self.coordinator.data[0].get("date"), "%Y-%m-%d").date()
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
- today = datetime.now(timezone.utc).date()
1309
- current_time = datetime.now(timezone.utc).time()
1310
- days_difference = (today - first_date).days
1311
- _LOGGER.debug(
1312
- f"Diferencia de días para datos UVI: {days_difference}."
1313
- f"Hora actual de validación: {current_time}."
1314
- f"Para la validación: "
1315
- f"número de días= {DEFAULT_VALIDITY_DAYS}, "
1316
- f"hora de contacto a la API >= {DEFAULT_VALIDITY_HOURS}, "
1317
- f"minutos de contacto a la API >= {DEFAULT_VALIDITY_MINUTES}."
1318
- )
1319
- if days_difference > DEFAULT_VALIDITY_DAYS and current_time >= time(DEFAULT_VALIDITY_HOURS, DEFAULT_VALIDITY_MINUTES):
1320
- return "obsolete"
1321
- return "updated"
1322
- return "unknown"
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 = super().extra_state_attributes or {}
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
@@ -236,6 +236,9 @@
236
236
  "state_attributes": {
237
237
  "update_date": {
238
238
  "name": "Date"
239
+ },
240
+ "data_updatetime": {
241
+ "name": "Updated"
239
242
  }
240
243
  }
241
244
  },
@@ -236,6 +236,9 @@
236
236
  "state_attributes": {
237
237
  "update_date": {
238
238
  "name": "Data"
239
+ },
240
+ "data_updatetime": {
241
+ "name": "Actualitzat"
239
242
  }
240
243
  }
241
244
  },
@@ -236,6 +236,9 @@
236
236
  "state_attributes": {
237
237
  "update_date": {
238
238
  "name": "Date"
239
+ },
240
+ "data_updatetime": {
241
+ "name": "Updated"
239
242
  }
240
243
  }
241
244
  },
@@ -236,6 +236,9 @@
236
236
  "state_attributes": {
237
237
  "update_date": {
238
238
  "name": "Fecha"
239
+ },
240
+ "data_updatetime": {
241
+ "name": "Actualizado"
239
242
  }
240
243
  }
241
244
  },
@@ -1 +1 @@
1
- __version__ = "4.0.3"
1
+ __version__ = "4.0.4"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meteocat",
3
- "version": "4.0.3",
3
+ "version": "4.0.4",
4
4
  "description": "[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)\r [![Python version compatibility](https://img.shields.io/pypi/pyversions/meteocat)](https://pypi.org/project/meteocat)\r [![pipeline status](https://gitlab.com/figorr/meteocat/badges/master/pipeline.svg)](https://gitlab.com/figorr/meteocat/commits/master)",
5
5
  "main": "index.js",
6
6
  "directories": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "meteocat"
3
- version = "4.0.3"
3
+ version = "4.0.4"
4
4
  description = "Script para obtener datos meteorológicos de la API de Meteocat"
5
5
  authors = ["figorr <jdcuartero@yahoo.es>"]
6
6
  license = "Apache-2.0"